Compare commits

...

12 Commits

Author SHA1 Message Date
501f4f9de6 v13.15.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 12:07:13 +00:00
fa926eb10b feat(stats): add typed network stats response fields for bandwidth, domain activity, and protocol distribution 2026-04-13 12:07:13 +00:00
f2d0a9ec1b v13.14.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 11:04:15 +00:00
035173702d feat(network): add bandwidth-ranked IP and domain activity metrics to network monitoring 2026-04-13 11:04:15 +00:00
07a3365496 v13.13.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-13 09:47:19 +00:00
1c4f7dbb11 feat(dns): add domain migration between dcrouter and provider-managed DNS with unified ACME managed-domain handling 2026-04-13 09:47:19 +00:00
1fdff79dd0 v13.12.0
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-12 23:46:31 +00:00
59b52d08fa feat(email-domains): support creating email domains on optional subdomains 2026-04-12 23:46:31 +00:00
2cdc392a40 v13.11.0
Some checks failed
Docker (tags) / security (push) Failing after 2s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-12 22:09:20 +00:00
433047bbf1 feat(email-domains): add email domain management with DNS provisioning, validation, and ops dashboard support 2026-04-12 22:09:20 +00:00
0b81c95de2 v13.10.0
Some checks failed
Docker (tags) / security (push) Failing after 3s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-12 20:43:57 +00:00
196e5dfc1b feat(web-ui): standardize settings views for ACME and email security panels 2026-04-12 20:43:57 +00:00
35 changed files with 2170 additions and 432 deletions

View File

@@ -1,5 +1,46 @@
# Changelog
## 2026-04-13 - 13.15.0 - feat(stats)
add typed network stats response fields for bandwidth, domain activity, and protocol distribution
- extends the network stats request interface with top IP bandwidth, domain activity, and frontend/backend protocol distribution data
- updates app state to use a typed getNetworkStats request instead of casting the response to any
## 2026-04-13 - 13.14.0 - feat(network)
add bandwidth-ranked IP and domain activity metrics to network monitoring
- Expose top IPs by bandwidth and aggregated domain activity from route metrics.
- Replace estimated per-connection values with real per-IP throughput data in ops handlers and stats responses.
- Update the network UI to show bandwidth-ranked IPs and domain activity while removing the recent request table.
## 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)
add email domain management with DNS provisioning, validation, and ops dashboard support
- Introduce EmailDomainManager with persisted email domain records, DKIM configuration, DNS record generation, provisioning, and validation.
- Add opsserver typed request handlers and shared interfaces for listing, creating, updating, deleting, validating, and provisioning email domains.
- Add ops dashboard email domains view and app state integration for managing domains and inspecting required DNS records.
## 2026-04-12 - 13.10.0 - feat(web-ui)
standardize settings views for ACME and email security panels
- replace custom ACME settings layouts with the reusable dees-settings component for configured and empty states
- update the email security view to present settings through dees-settings and open a modal-based read-only edit dialog
- bump @design.estate/dees-catalog to ^3.78.0 to support the updated UI components
## 2026-04-12 - 13.9.2 - fix(web-ui)
improve form field descriptions and align certificate settings with tile components

View File

@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.9.2",
"version": "13.15.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"exports": {
@@ -35,7 +35,7 @@
"@api.global/typedserver": "^8.4.6",
"@api.global/typedsocket": "^4.1.2",
"@apiclient.xyz/cloudflare": "^7.1.0",
"@design.estate/dees-catalog": "^3.76.1",
"@design.estate/dees-catalog": "^3.78.2",
"@design.estate/dees-element": "^2.2.4",
"@push.rocks/lik": "^6.4.0",
"@push.rocks/projectinfo": "^5.1.0",

43
pnpm-lock.yaml generated
View File

@@ -24,8 +24,8 @@ importers:
specifier: ^7.1.0
version: 7.1.0
'@design.estate/dees-catalog':
specifier: ^3.76.1
version: 3.76.1(@tiptap/pm@2.27.2)
specifier: ^3.78.2
version: 3.78.2(@tiptap/pm@2.27.2)
'@design.estate/dees-element':
specifier: ^2.2.4
version: 2.2.4
@@ -353,8 +353,8 @@ packages:
'@configvault.io/interfaces@1.0.17':
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
'@design.estate/dees-catalog@3.76.1':
resolution: {integrity: sha512-DSnu1NHz0C9CI13e6HMUV6lFiAKzOoPccZUZu6wDrpTcGha1trvFftcRzsieJ0NrvNJ6qZrh1vGL6ZYhu5RO0A==}
'@design.estate/dees-catalog@3.78.2':
resolution: {integrity: sha512-9MKKCvx+vxoIp6UpqVQklreokdg7ZSSODz4FlKyNFqjfZiDDme6pjwxWoMSA+Tn4bkboYyCBosUrVfc0nxa1HA==}
'@design.estate/dees-comms@1.0.30':
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
@@ -368,8 +368,8 @@ packages:
'@design.estate/dees-wcctools@3.8.0':
resolution: {integrity: sha512-CC14iVKUrguzD9jIrdPBd9fZ4egVJEZMxl5y8iy0l7WLumeoYvGsoXj5INVkRPLRVLqziIdi4Je1hXqHt2NU+g==}
'@design.estate/dees-wcctools@3.8.4':
resolution: {integrity: sha512-KpFK/azK+a/Xpq33pXKcho+tdFKVHhKZM5ArvHqo9QMwTczgp5DZZgowTDUuqAofjZwnuVfCPHK/Pw9e64N46A==}
'@design.estate/dees-wcctools@3.9.0':
resolution: {integrity: sha512-0vZBaGBEGIbl8hx+8BezIIea3U5T7iSHHF9VqlJZGf+nOFIW4zBAxcCljH8YzZ1Yayp6BEjxp/pQXjHN2YB3Jg==}
'@emnapi/core@1.9.2':
resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
@@ -1997,6 +1997,12 @@ packages:
'@types/debug@4.1.13':
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':
resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==}
@@ -3166,6 +3172,9 @@ packages:
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
mediabunny@1.40.1:
resolution: {integrity: sha512-HU/stGzAkdWaJIly6ypbUVgAUvT9kt39DIg0IaErR7/1fwtTmgUYs4i8uEPYcgcjPjbB9gtBmUXOLnXi6J2LDw==}
memory-pager@1.5.0:
resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
@@ -4318,7 +4327,7 @@ snapshots:
'@api.global/typedrequest-interfaces': 3.0.19
'@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.3)
'@cloudflare/workers-types': 4.20260405.1
'@design.estate/dees-catalog': 3.76.1(@tiptap/pm@2.27.2)
'@design.estate/dees-catalog': 3.78.2(@tiptap/pm@2.27.2)
'@design.estate/dees-comms': 1.0.30
'@push.rocks/lik': 6.4.0
'@push.rocks/smartdelay': 3.0.5
@@ -4847,11 +4856,11 @@ snapshots:
dependencies:
'@api.global/typedrequest-interfaces': 3.0.19
'@design.estate/dees-catalog@3.76.1(@tiptap/pm@2.27.2)':
'@design.estate/dees-catalog@3.78.2(@tiptap/pm@2.27.2)':
dependencies:
'@design.estate/dees-domtools': 2.5.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/free-brands-svg-icons': 7.2.0
'@fortawesome/free-regular-svg-icons': 7.2.0
@@ -4940,12 +4949,13 @@ snapshots:
- supports-color
- vue
'@design.estate/dees-wcctools@3.8.4':
'@design.estate/dees-wcctools@3.9.0':
dependencies:
'@design.estate/dees-domtools': 2.5.4
'@design.estate/dees-element': 2.2.4
'@push.rocks/smartdelay': 3.0.5
lit: 3.3.2
mediabunny: 1.40.1
transitivePeerDependencies:
- '@nuxt/kit'
- react
@@ -6915,7 +6925,7 @@ snapshots:
'@serve.zone/catalog@2.12.3(@tiptap/pm@2.27.2)':
dependencies:
'@design.estate/dees-catalog': 3.76.1(@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-element': 2.2.4
'@design.estate/dees-wcctools': 3.8.0
@@ -7464,6 +7474,12 @@ snapshots:
dependencies:
'@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':
dependencies:
'@types/jsonfile': 6.1.4
@@ -8819,6 +8835,11 @@ snapshots:
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: {}
micromark-core-commonmark@2.0.3:

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.9.2',
version: '13.15.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}

View File

@@ -29,6 +29,7 @@ import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
import { DnsManager } from './dns/manager.dns.js';
import { AcmeConfigManager } from './acme/manager.acme-config.js';
import { EmailDomainManager } from './email/classes.email-domain.manager.js';
export interface IDcRouterOptions {
/** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
@@ -279,6 +280,7 @@ export class DcRouter {
// ACME configuration (DB-backed singleton, replaces tls.contactEmail)
public acmeConfigManager?: AcmeConfigManager;
public emailDomainManager?: EmailDomainManager;
// Auto-discovered public IP (populated by generateAuthoritativeRecords)
public detectedPublicIp: string | null = null;
@@ -439,6 +441,21 @@ export class DcRouter {
);
}
// Email Domain Manager: optional, depends on DcRouterDb
if (this.options.dbConfig?.enabled !== false) {
this.serviceManager.addService(
new plugins.taskbuffer.Service('EmailDomainManager')
.optional()
.dependsOn('DcRouterDb')
.withStart(async () => {
this.emailDomainManager = new EmailDomainManager(this);
})
.withStop(async () => {
this.emailDomainManager = undefined;
}),
);
}
// SmartProxy: critical, depends on DcRouterDb + DnsManager + AcmeConfigManager (if enabled)
const smartProxyDeps: string[] = [];
if (this.options.dbConfig?.enabled !== false) {
@@ -913,15 +930,16 @@ export class DcRouter {
}
// Configure DNS-01 challenge if any DnsProviderDoc exists in the DB AND
// ACME is enabled. The DnsManager dispatches each challenge to the right
// provider client based on the FQDN being certificated.
// ACME is enabled. The DnsManager dispatches each challenge through the
// unified createRecord()/deleteRecord() path — works for both dcrouter-hosted
// zones and provider-managed zones. Only domains under management get certs.
let challengeHandlers: any[] = [];
if (
acmeConfig &&
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 dns01Handler = new plugins.smartacme.handlers.Dns01Handler(convenientDnsProvider);
challengeHandlers.push(dns01Handler);

View File

@@ -0,0 +1,56 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type {
IEmailDomainDkim,
IEmailDomainRateLimits,
IEmailDomainDnsStatus,
} from '../../../ts_interfaces/data/email-domain.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class EmailDomainDoc extends plugins.smartdata.SmartDataDbDoc<EmailDomainDoc, EmailDomainDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id!: string;
@plugins.smartdata.svDb()
public domain: string = '';
@plugins.smartdata.svDb()
public linkedDomainId: string = '';
@plugins.smartdata.svDb()
public subdomain?: string;
@plugins.smartdata.svDb()
public dkim!: IEmailDomainDkim;
@plugins.smartdata.svDb()
public rateLimits?: IEmailDomainRateLimits;
@plugins.smartdata.svDb()
public dnsStatus!: IEmailDomainDnsStatus;
@plugins.smartdata.svDb()
public createdAt!: string;
@plugins.smartdata.svDb()
public updatedAt!: string;
constructor() {
super();
}
public static async findById(id: string): Promise<EmailDomainDoc | null> {
return await EmailDomainDoc.getInstance({ id });
}
public static async findByDomain(domain: string): Promise<EmailDomainDoc | null> {
return await EmailDomainDoc.getInstance({ domain: domain.toLowerCase() });
}
public static async findAll(): Promise<EmailDomainDoc[]> {
return await EmailDomainDoc.getInstances({});
}
}

View File

@@ -33,3 +33,6 @@ export * from './classes.dns-record.doc.js';
// ACME configuration (singleton)
export * from './classes.acme-config.doc.js';
// Email domain management
export * from './classes.email-domain.doc.js';

View File

@@ -296,70 +296,99 @@ export class DnsManager {
}
/**
* True if any cloudflare provider exists in the DB. Used by setupSmartProxy()
* to decide whether to wire SmartAcme with a DNS-01 handler.
* Find the DomainDoc that covers a given FQDN, regardless of source
* (dcrouter-hosted or provider-managed). Uses longest-suffix match.
*/
public async hasAcmeCapableProvider(): Promise<boolean> {
const providers = await DnsProviderDoc.findAll();
return providers.length > 0;
public async findDomainForFqdn(fqdn: string): Promise<DomainDoc | null> {
const lower = fqdn.toLowerCase().replace(/^\*\./, '').replace(/\.$/, '');
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
* the right provider client (whichever provider type owns the parent zone),
* based on the challenge's hostName. Provider-agnostic — uses the IDnsProviderClient
* interface, so any registered provider implementation works.
* Returned object plugs directly into smartacme's Dns01Handler.
* Delete all DNS records matching a name and type under a domain.
* Used for ACME challenge cleanup (may have multiple TXT records at the same name).
*/
public async deleteRecordsByNameAndType(
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 {
const self = this;
const adapter = {
async acmeSetDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) {
const client = await self.getProviderClientForDomain(dnsChallenge.hostName);
if (!client) {
const domainDoc = await self.findDomainForFqdn(dnsChallenge.hostName);
if (!domainDoc) {
throw new Error(
`DnsManager: no DNS provider configured for ${dnsChallenge.hostName}. ` +
'Add one in the Domains > Providers UI before issuing certificates.',
`DnsManager: no managed domain found for ${dnsChallenge.hostName}. ` +
'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 {
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(() => {});
}
}
await self.deleteRecordsByNameAndType(domainDoc.id, dnsChallenge.hostName, 'TXT');
} catch (err: unknown) {
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,
type: 'TXT',
value: dnsChallenge.challenge,
ttl: 120,
createdBy: 'acme-dns01',
});
},
async acmeRemoveDnsChallenge(dnsChallenge: { hostName: string; challenge: string }) {
const client = await self.getProviderClientForDomain(dnsChallenge.hostName);
if (!client) {
const domainDoc = await self.findDomainForFqdn(dnsChallenge.hostName);
if (!domainDoc) {
// 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);
}
}
await self.deleteRecordsByNameAndType(domainDoc.id, dnsChallenge.hostName, 'TXT');
} 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;
const domainDoc = await self.findDomainForFqdn(domain);
return !!domainDoc;
},
};
return { convenience: adapter } as plugins.tsclass.network.IConvenientDnsProvider;
@@ -642,6 +671,151 @@ export class DnsManager {
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: {
domainId: string;
name: string;
@@ -759,14 +933,24 @@ export class DnsManager {
}
}
}
// For local 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.
// For dcrouter-hosted records: unregister the handler from the embedded DnsServer
// so the record stops being served immediately (not just after restart).
if (domain.source === 'dcrouter' && this.dnsServer) {
this.unregisterRecordFromDnsServer(doc);
}
await doc.delete();
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
// ==========================================================================

View File

@@ -0,0 +1,321 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { EmailDomainDoc } from '../db/documents/classes.email-domain.doc.js';
import { DomainDoc } from '../db/documents/classes.domain.doc.js';
import { DnsRecordDoc } from '../db/documents/classes.dns-record.doc.js';
import type { DnsManager } from '../dns/manager.dns.js';
import type { IEmailDomain, IEmailDnsRecord, TDnsRecordStatus } from '../../ts_interfaces/data/email-domain.js';
/**
* EmailDomainManager — orchestrates email domain setup.
*
* Wires smartmta's DKIMCreator (key generation) with dcrouter's DnsManager
* (record creation for dcrouter-hosted and provider-managed zones) to provide
* a single entry point for setting up an email domain from A to Z.
*/
export class EmailDomainManager {
private dcRouter: any; // DcRouter — avoids circular import
constructor(dcRouterRef: any) {
this.dcRouter = dcRouterRef;
}
private get dnsManager(): DnsManager | undefined {
return this.dcRouter.dnsManager;
}
private get dkimCreator(): any | undefined {
return this.dcRouter.emailServer?.dkimCreator;
}
private get emailHostname(): string {
return this.dcRouter.options?.emailConfig?.hostname || this.dcRouter.options?.tls?.domain || 'localhost';
}
// ---------------------------------------------------------------------------
// CRUD
// ---------------------------------------------------------------------------
public async getAll(): Promise<IEmailDomain[]> {
const docs = await EmailDomainDoc.findAll();
return docs.map((d) => this.docToInterface(d));
}
public async getById(id: string): Promise<IEmailDomain | null> {
const doc = await EmailDomainDoc.findById(id);
return doc ? this.docToInterface(doc) : null;
}
public async createEmailDomain(opts: {
linkedDomainId: string;
subdomain?: string;
dkimSelector?: string;
dkimKeySize?: number;
rotateKeys?: boolean;
rotationIntervalDays?: number;
}): Promise<IEmailDomain> {
// Resolve the linked DNS domain
const domainDoc = await DomainDoc.findById(opts.linkedDomainId);
if (!domainDoc) {
throw new Error(`DNS domain not found: ${opts.linkedDomainId}`);
}
const baseDomain = domainDoc.name;
const subdomain = opts.subdomain?.trim() || undefined;
const domainName = subdomain ? `${subdomain}.${baseDomain}` : baseDomain;
// Check for duplicates
const existing = await EmailDomainDoc.findByDomain(domainName);
if (existing) {
throw new Error(`Email domain already exists for ${domainName}`);
}
const selector = opts.dkimSelector || 'default';
const keySize = opts.dkimKeySize || 2048;
const now = new Date().toISOString();
// Generate DKIM keys
let publicKey: string | undefined;
if (this.dkimCreator) {
try {
await this.dkimCreator.handleDKIMKeysForDomain(domainName);
const dnsRecord = await this.dkimCreator.getDNSRecordForSelector(domainName, selector);
// Extract public key from the DNS record value
const match = dnsRecord?.value?.match(/p=([A-Za-z0-9+/=]+)/);
publicKey = match ? match[1] : undefined;
logger.log('info', `DKIM keys generated for ${domainName} (selector: ${selector})`);
} catch (err: unknown) {
logger.log('warn', `DKIM key generation failed for ${domainName}: ${(err as Error).message}`);
}
}
// Create the document
const doc = new EmailDomainDoc();
doc.id = plugins.smartunique.shortId();
doc.domain = domainName.toLowerCase();
doc.linkedDomainId = opts.linkedDomainId;
doc.subdomain = subdomain;
doc.dkim = {
selector,
keySize,
publicKey,
rotateKeys: opts.rotateKeys ?? false,
rotationIntervalDays: opts.rotationIntervalDays ?? 90,
};
doc.dnsStatus = {
mx: 'unchecked',
spf: 'unchecked',
dkim: 'unchecked',
dmarc: 'unchecked',
};
doc.createdAt = now;
doc.updatedAt = now;
await doc.save();
logger.log('info', `Email domain created: ${domainName}`);
return this.docToInterface(doc);
}
public async updateEmailDomain(
id: string,
changes: {
rotateKeys?: boolean;
rotationIntervalDays?: number;
rateLimits?: IEmailDomain['rateLimits'];
},
): Promise<void> {
const doc = await EmailDomainDoc.findById(id);
if (!doc) throw new Error(`Email domain not found: ${id}`);
if (changes.rotateKeys !== undefined) doc.dkim.rotateKeys = changes.rotateKeys;
if (changes.rotationIntervalDays !== undefined) doc.dkim.rotationIntervalDays = changes.rotationIntervalDays;
if (changes.rateLimits !== undefined) doc.rateLimits = changes.rateLimits;
doc.updatedAt = new Date().toISOString();
await doc.save();
}
public async deleteEmailDomain(id: string): Promise<void> {
const doc = await EmailDomainDoc.findById(id);
if (!doc) throw new Error(`Email domain not found: ${id}`);
await doc.delete();
logger.log('info', `Email domain deleted: ${doc.domain}`);
}
// ---------------------------------------------------------------------------
// DNS record computation
// ---------------------------------------------------------------------------
/**
* Compute the 4 required DNS records for an email domain.
*/
public async getRequiredDnsRecords(id: string): Promise<IEmailDnsRecord[]> {
const doc = await EmailDomainDoc.findById(id);
if (!doc) throw new Error(`Email domain not found: ${id}`);
const domain = doc.domain;
const selector = doc.dkim.selector;
const publicKey = doc.dkim.publicKey || '';
const hostname = this.emailHostname;
const records: IEmailDnsRecord[] = [
{
type: 'MX',
name: domain,
value: `10 ${hostname}`,
status: doc.dnsStatus.mx,
},
{
type: 'TXT',
name: domain,
value: 'v=spf1 a mx ~all',
status: doc.dnsStatus.spf,
},
{
type: 'TXT',
name: `${selector}._domainkey.${domain}`,
value: `v=DKIM1; h=sha256; k=rsa; p=${publicKey}`,
status: doc.dnsStatus.dkim,
},
{
type: 'TXT',
name: `_dmarc.${domain}`,
value: `v=DMARC1; p=none; rua=mailto:dmarc@${domain}`,
status: doc.dnsStatus.dmarc,
},
];
return records;
}
// ---------------------------------------------------------------------------
// DNS provisioning
// ---------------------------------------------------------------------------
/**
* Auto-create missing DNS records via the linked domain's DNS path.
*/
public async provisionDnsRecords(id: string): Promise<number> {
const doc = await EmailDomainDoc.findById(id);
if (!doc) throw new Error(`Email domain not found: ${id}`);
if (!this.dnsManager) throw new Error('DnsManager not available');
const requiredRecords = await this.getRequiredDnsRecords(id);
const domainId = doc.linkedDomainId;
// Get existing DNS records for the linked domain
const existingRecords = await DnsRecordDoc.findByDomainId(domainId);
let provisioned = 0;
for (const required of requiredRecords) {
// Check if a matching record already exists
const exists = existingRecords.some((r) => {
if (required.type === 'MX') {
return r.type === 'MX' && r.name.toLowerCase() === required.name.toLowerCase();
}
// For TXT records, match by name AND check value prefix (v=spf1, v=DKIM1, v=DMARC1)
if (r.type !== 'TXT' || r.name.toLowerCase() !== required.name.toLowerCase()) return false;
if (required.value.startsWith('v=spf1')) return r.value.includes('v=spf1');
if (required.value.startsWith('v=DKIM1')) return r.value.includes('v=DKIM1');
if (required.value.startsWith('v=DMARC1')) return r.value.includes('v=DMARC1');
return false;
});
if (!exists) {
try {
await this.dnsManager.createRecord({
domainId,
name: required.name,
type: required.type as any,
value: required.value,
ttl: 3600,
createdBy: 'email-domain-manager',
});
provisioned++;
logger.log('info', `Provisioned ${required.type} record for ${required.name}`);
} catch (err: unknown) {
logger.log('warn', `Failed to provision ${required.type} for ${required.name}: ${(err as Error).message}`);
}
}
}
// Re-validate after provisioning
await this.validateDns(id);
return provisioned;
}
// ---------------------------------------------------------------------------
// DNS validation
// ---------------------------------------------------------------------------
/**
* Validate DNS records via live lookups.
*/
public async validateDns(id: string): Promise<IEmailDnsRecord[]> {
const doc = await EmailDomainDoc.findById(id);
if (!doc) throw new Error(`Email domain not found: ${id}`);
const domain = doc.domain;
const selector = doc.dkim.selector;
const resolver = new plugins.dns.promises.Resolver();
// MX check
doc.dnsStatus.mx = await this.checkMx(resolver, domain);
// SPF check
doc.dnsStatus.spf = await this.checkTxtRecord(resolver, domain, 'v=spf1');
// DKIM check
doc.dnsStatus.dkim = await this.checkTxtRecord(resolver, `${selector}._domainkey.${domain}`, 'v=DKIM1');
// DMARC check
doc.dnsStatus.dmarc = await this.checkTxtRecord(resolver, `_dmarc.${domain}`, 'v=DMARC1');
doc.dnsStatus.lastCheckedAt = new Date().toISOString();
doc.updatedAt = new Date().toISOString();
await doc.save();
return this.getRequiredDnsRecords(id);
}
private async checkMx(resolver: plugins.dns.promises.Resolver, domain: string): Promise<TDnsRecordStatus> {
try {
const records = await resolver.resolveMx(domain);
return records && records.length > 0 ? 'valid' : 'missing';
} catch {
return 'missing';
}
}
private async checkTxtRecord(
resolver: plugins.dns.promises.Resolver,
name: string,
prefix: string,
): Promise<TDnsRecordStatus> {
try {
const records = await resolver.resolveTxt(name);
const flat = records.map((r) => r.join(''));
const found = flat.some((r) => r.startsWith(prefix));
return found ? 'valid' : 'missing';
} catch {
return 'missing';
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
private docToInterface(doc: EmailDomainDoc): IEmailDomain {
return {
id: doc.id,
domain: doc.domain,
linkedDomainId: doc.linkedDomainId,
subdomain: doc.subdomain,
dkim: doc.dkim,
rateLimits: doc.rateLimits,
dnsStatus: doc.dnsStatus,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
};
}
}

1
ts/email/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './classes.email-domain.manager.js';

View File

@@ -553,12 +553,14 @@ export class MetricsManager {
connectionsByIP: new Map<string, number>(),
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
topIPs: [] as Array<{ ip: string; count: number }>,
topIPsByBandwidth: [] as Array<{ ip: string; count: number; bwIn: number; bwOut: number }>,
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
throughputHistory: [] as Array<{ timestamp: number; in: number; out: number }>,
throughputByIP: new Map<string, { in: number; out: number }>(),
requestsPerSecond: 0,
requestsTotal: 0,
backends: [] as Array<any>,
domainActivity: [] as Array<{ domain: string; bytesInPerSecond: number; bytesOutPerSecond: number; activeConnections: number; routeCount: number }>,
};
}
@@ -572,7 +574,7 @@ export class MetricsManager {
bytesOutPerSecond: instantThroughput.out
};
// Get top IPs
// Get top IPs by connection count
const topIPs = proxyMetrics.connections.topIPs(10);
// Get total data transferred
@@ -699,10 +701,83 @@ export class MetricsManager {
}
}
// Build top 10 IPs by bandwidth (sorted by total throughput desc)
const allIPData = new Map<string, { count: number; bwIn: number; bwOut: number }>();
for (const [ip, count] of connectionsByIP) {
allIPData.set(ip, { count, bwIn: 0, bwOut: 0 });
}
for (const [ip, tp] of throughputByIP) {
const existing = allIPData.get(ip);
if (existing) {
existing.bwIn = tp.in;
existing.bwOut = tp.out;
} else {
allIPData.set(ip, { count: 0, bwIn: tp.in, bwOut: tp.out });
}
}
const topIPsByBandwidth = Array.from(allIPData.entries())
.sort((a, b) => (b[1].bwIn + b[1].bwOut) - (a[1].bwIn + a[1].bwOut))
.slice(0, 10)
.map(([ip, data]) => ({ ip, count: data.count, bwIn: data.bwIn, bwOut: data.bwOut }));
// Build domain activity from per-route metrics
const connectionsByRoute = proxyMetrics.connections.byRoute();
const throughputByRoute = proxyMetrics.throughput.byRoute();
// Map route name → primary domain using dcrouter's route configs
const routeToDomain = new Map<string, string>();
if (this.dcRouter.smartProxy) {
for (const route of this.dcRouter.smartProxy.routeManager.getRoutes()) {
if (!route.name || !route.match.domains) continue;
const domains = Array.isArray(route.match.domains)
? route.match.domains
: [route.match.domains];
if (domains.length > 0) {
routeToDomain.set(route.name, domains[0]);
}
}
}
// Aggregate metrics by domain
const domainAgg = new Map<string, {
activeConnections: number;
bytesInPerSec: number;
bytesOutPerSec: number;
routeCount: number;
}>();
for (const [routeName, activeConns] of connectionsByRoute) {
const domain = routeToDomain.get(routeName) || routeName;
const tp = throughputByRoute.get(routeName) || { in: 0, out: 0 };
const existing = domainAgg.get(domain);
if (existing) {
existing.activeConnections += activeConns;
existing.bytesInPerSec += tp.in;
existing.bytesOutPerSec += tp.out;
existing.routeCount++;
} else {
domainAgg.set(domain, {
activeConnections: activeConns,
bytesInPerSec: tp.in,
bytesOutPerSec: tp.out,
routeCount: 1,
});
}
}
const domainActivity = Array.from(domainAgg.entries())
.map(([domain, data]) => ({
domain,
bytesInPerSecond: data.bytesInPerSec,
bytesOutPerSecond: data.bytesOutPerSec,
activeConnections: data.activeConnections,
routeCount: data.routeCount,
}))
.sort((a, b) => (b.bytesInPerSecond + b.bytesOutPerSecond) - (a.bytesInPerSecond + a.bytesOutPerSecond));
return {
connectionsByIP,
throughputRate,
topIPs,
topIPsByBandwidth,
totalDataTransferred,
throughputHistory,
throughputByIP,
@@ -711,6 +786,7 @@ export class MetricsManager {
backends,
frontendProtocols,
backendProtocols,
domainActivity,
};
}, 1000); // 1s cache — matches typical dashboard poll interval
}

View File

@@ -37,6 +37,7 @@ export class OpsServer {
private domainHandler!: handlers.DomainHandler;
private dnsRecordHandler!: handlers.DnsRecordHandler;
private acmeConfigHandler!: handlers.AcmeConfigHandler;
private emailDomainHandler!: handlers.EmailDomainHandler;
constructor(dcRouterRefArg: DcRouter) {
this.dcRouterRef = dcRouterRefArg;
@@ -104,6 +105,7 @@ export class OpsServer {
this.domainHandler = new handlers.DomainHandler(this);
this.dnsRecordHandler = new handlers.DnsRecordHandler(this);
this.acmeConfigHandler = new handlers.AcmeConfigHandler(this);
this.emailDomainHandler = new handlers.EmailDomainHandler(this);
console.log('✅ OpsServer TypedRequest handlers initialized');
}

View File

@@ -127,7 +127,7 @@ export class ConfigHandler {
// (replaces the legacy `dnsChallenge.cloudflareApiKey` constructor field).
let dnsChallengeEnabled = false;
try {
dnsChallengeEnabled = (await dcRouter.dnsManager?.hasAcmeCapableProvider()) ?? false;
dnsChallengeEnabled = (await dcRouter.dnsManager?.hasAnyManagedDomain()) ?? false;
} catch {
dnsChallengeEnabled = false;
}

View File

@@ -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,
});
},
),
);
}
}

View File

@@ -0,0 +1,195 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
/**
* CRUD + DNS provisioning handler for email domains.
*
* Auth: admin JWT or API token with `email-domains:read` / `email-domains:write` scope.
*/
export class EmailDomainHandler {
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 get manager() {
return this.opsServerRef.dcRouterRef.emailDomainManager;
}
private registerHandlers(): void {
// List all email domains
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDomains>(
'getEmailDomains',
async (dataArg) => {
await this.requireAuth(dataArg, 'email-domains:read' as any);
if (!this.manager) return { domains: [] };
return { domains: await this.manager.getAll() };
},
),
);
// Get single email domain
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDomain>(
'getEmailDomain',
async (dataArg) => {
await this.requireAuth(dataArg, 'email-domains:read' as any);
if (!this.manager) return { domain: null };
return { domain: await this.manager.getById(dataArg.id) };
},
),
);
// Create email domain
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateEmailDomain>(
'createEmailDomain',
async (dataArg) => {
await this.requireAuth(dataArg, 'email-domains:write' as any);
if (!this.manager) {
return { success: false, message: 'EmailDomainManager not initialized' };
}
try {
const domain = await this.manager.createEmailDomain({
linkedDomainId: dataArg.linkedDomainId,
subdomain: dataArg.subdomain,
dkimSelector: dataArg.dkimSelector,
dkimKeySize: dataArg.dkimKeySize,
rotateKeys: dataArg.rotateKeys,
rotationIntervalDays: dataArg.rotationIntervalDays,
});
return { success: true, domain };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Update email domain
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateEmailDomain>(
'updateEmailDomain',
async (dataArg) => {
await this.requireAuth(dataArg, 'email-domains:write' as any);
if (!this.manager) {
return { success: false, message: 'EmailDomainManager not initialized' };
}
try {
await this.manager.updateEmailDomain(dataArg.id, {
rotateKeys: dataArg.rotateKeys,
rotationIntervalDays: dataArg.rotationIntervalDays,
rateLimits: dataArg.rateLimits,
});
return { success: true };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Delete email domain
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteEmailDomain>(
'deleteEmailDomain',
async (dataArg) => {
await this.requireAuth(dataArg, 'email-domains:write' as any);
if (!this.manager) {
return { success: false, message: 'EmailDomainManager not initialized' };
}
try {
await this.manager.deleteEmailDomain(dataArg.id);
return { success: true };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Validate DNS records
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ValidateEmailDomain>(
'validateEmailDomain',
async (dataArg) => {
await this.requireAuth(dataArg, 'email-domains:read' as any);
if (!this.manager) {
return { success: false, message: 'EmailDomainManager not initialized' };
}
try {
const records = await this.manager.validateDns(dataArg.id);
const domain = await this.manager.getById(dataArg.id);
return { success: true, domain: domain ?? undefined, records };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Get required DNS records
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailDomainDnsRecords>(
'getEmailDomainDnsRecords',
async (dataArg) => {
await this.requireAuth(dataArg, 'email-domains:read' as any);
if (!this.manager) return { records: [] };
return { records: await this.manager.getRequiredDnsRecords(dataArg.id) };
},
),
);
// Auto-provision DNS records
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ProvisionEmailDomainDns>(
'provisionEmailDomainDns',
async (dataArg) => {
await this.requireAuth(dataArg, 'email-domains:write' as any);
if (!this.manager) {
return { success: false, message: 'EmailDomainManager not initialized' };
}
try {
const provisioned = await this.manager.provisionDnsRecords(dataArg.id);
return { success: true, provisioned };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
}
}

View File

@@ -17,4 +17,5 @@ export * from './users.handler.js';
export * from './dns-provider.handler.js';
export * from './domain.handler.js';
export * from './dns-record.handler.js';
export * from './acme-config.handler.js';
export * from './acme-config.handler.js';
export * from './email-domain.handler.js';

View File

@@ -51,8 +51,8 @@ export class SecurityHandler {
startTime: conn.startTime,
protocol: conn.type === 'http' ? 'https' : conn.type as any,
state: conn.status as any,
bytesReceived: Math.floor(conn.bytesTransferred / 2),
bytesSent: Math.floor(conn.bytesTransferred / 2),
bytesReceived: (conn as any)._throughputIn || 0,
bytesSent: (conn as any)._throughputOut || 0,
}));
const summary = {
@@ -96,9 +96,11 @@ export class SecurityHandler {
connectionsByIP: Array.from(networkStats.connectionsByIP.entries()).map(([ip, count]) => ({ ip, count })),
throughputRate: networkStats.throughputRate,
topIPs: networkStats.topIPs,
topIPsByBandwidth: networkStats.topIPsByBandwidth,
totalDataTransferred: networkStats.totalDataTransferred,
throughputHistory: networkStats.throughputHistory || [],
throughputByIP,
domainActivity: networkStats.domainActivity || [],
requestsPerSecond: networkStats.requestsPerSecond || 0,
requestsTotal: networkStats.requestsTotal || 0,
backends: networkStats.backends || [],
@@ -110,9 +112,11 @@ export class SecurityHandler {
connectionsByIP: [],
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
topIPs: [],
topIPsByBandwidth: [],
totalDataTransferred: { bytesIn: 0, bytesOut: 0 },
throughputHistory: [],
throughputByIP: [],
domainActivity: [],
requestsPerSecond: 0,
requestsTotal: 0,
backends: [],
@@ -251,31 +255,31 @@ export class SecurityHandler {
const connectionInfo = await this.opsServerRef.dcRouterRef.metricsManager.getConnectionInfo();
const networkStats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
// Use IP-based connection data from the new metrics API
// One aggregate row per IP with real throughput data
if (networkStats.connectionsByIP && networkStats.connectionsByIP.size > 0) {
let connIndex = 0;
const publicIp = this.opsServerRef.dcRouterRef.options.publicIp || 'server';
for (const [ip, count] of networkStats.connectionsByIP) {
// Create a connection entry for each active IP connection
for (let i = 0; i < Math.min(count, 5); i++) { // Limit to 5 connections per IP for UI performance
connections.push({
id: `conn-${connIndex++}`,
type: 'http',
source: {
ip: ip,
port: Math.floor(Math.random() * 50000) + 10000, // High port range
},
destination: {
ip: publicIp,
port: 443,
service: 'proxy',
},
startTime: Date.now() - Math.floor(Math.random() * 3600000), // Within last hour
bytesTransferred: Math.floor(networkStats.totalDataTransferred.bytesIn / networkStats.connectionsByIP.size),
status: 'active',
});
}
const tp = networkStats.throughputByIP?.get(ip);
connections.push({
id: `ip-${connIndex++}`,
type: 'http',
source: {
ip: ip,
port: 0,
},
destination: {
ip: publicIp,
port: 443,
service: 'proxy',
},
startTime: 0,
bytesTransferred: count, // Store connection count here
status: 'active',
// Attach real throughput for the handler mapping
...(tp ? { _throughputIn: tp.in, _throughputOut: tp.out } : {}),
} as any);
}
} else if (connectionInfo.length > 0) {
// Fallback to route-based connection info if no IP data available

View File

@@ -291,6 +291,20 @@ export class StatsHandler {
}
}
// Build connectionDetails from real per-IP data
const connectionDetails: interfaces.data.IConnectionDetails[] = [];
for (const [ip, count] of stats.connectionsByIP) {
const tp = stats.throughputByIP?.get(ip);
connectionDetails.push({
remoteAddress: ip,
protocol: 'https',
state: 'connected',
startTime: 0,
bytesIn: tp?.in || 0,
bytesOut: tp?.out || 0,
});
}
metrics.network = {
totalBandwidth: {
in: stats.throughputRate.bytesInPerSecond,
@@ -301,12 +315,18 @@ export class StatsHandler {
out: stats.totalDataTransferred.bytesOut,
},
activeConnections: serverStats.activeConnections,
connectionDetails: [],
connectionDetails,
topEndpoints: stats.topIPs.map(ip => ({
endpoint: ip.ip,
requests: ip.count,
connections: ip.count,
bandwidth: ipBandwidth.get(ip.ip) || { in: 0, out: 0 },
})),
topEndpointsByBandwidth: stats.topIPsByBandwidth.map(ip => ({
endpoint: ip.ip,
connections: ip.count,
bandwidth: { in: ip.bwIn, out: ip.bwOut },
})),
domainActivity: stats.domainActivity || [],
throughputHistory: stats.throughputHistory || [],
requestsPerSecond: stats.requestsPerSecond || 0,
requestsTotal: stats.requestsTotal || 0,

View File

@@ -0,0 +1,75 @@
/**
* DNS record validation status for a single email-related record (MX, SPF, DKIM, DMARC).
*/
export type TDnsRecordStatus = 'valid' | 'missing' | 'invalid' | 'unchecked';
/**
* An email domain managed by dcrouter.
*
* Each email domain is linked to an existing dcrouter DNS domain (dcrouter-hosted
* or provider-managed). The DNS management path is inherited from the linked domain
* — no separate DNS mode is needed.
*/
export interface IEmailDomain {
id: string;
/** Fully qualified email domain name (e.g. 'example.com' or 'mail.example.com'). */
domain: string;
/** ID of the linked dcrouter DNS domain — determines how DNS records are managed. */
linkedDomainId: string;
/** Optional subdomain prefix (e.g. 'mail' for mail.example.com). Empty/undefined = bare domain. */
subdomain?: string;
/** DKIM configuration and key state. */
dkim: IEmailDomainDkim;
/** Optional per-domain rate limits. */
rateLimits?: IEmailDomainRateLimits;
/** DNS record validation status — populated by validateDns(). */
dnsStatus: IEmailDomainDnsStatus;
createdAt: string;
updatedAt: string;
}
export interface IEmailDomainDkim {
/** DKIM selector (default: 'default'). */
selector: string;
/** RSA key size in bits (default: 2048). */
keySize: number;
/** Base64-encoded public key — populated after key generation. */
publicKey?: string;
/** Whether automatic key rotation is enabled. */
rotateKeys: boolean;
/** Days between key rotations (default: 90). */
rotationIntervalDays: number;
/** ISO date of last key rotation. */
lastRotatedAt?: string;
}
export interface IEmailDomainRateLimits {
outbound?: {
messagesPerMinute?: number;
messagesPerHour?: number;
messagesPerDay?: number;
};
inbound?: {
messagesPerMinute?: number;
connectionsPerIp?: number;
recipientsPerMessage?: number;
};
}
export interface IEmailDomainDnsStatus {
mx: TDnsRecordStatus;
spf: TDnsRecordStatus;
dkim: TDnsRecordStatus;
dmarc: TDnsRecordStatus;
lastCheckedAt?: string;
}
/**
* A single required DNS record for an email domain — used for display / copy-paste.
*/
export interface IEmailDnsRecord {
type: 'MX' | 'TXT';
name: string;
value: string;
status: TDnsRecordStatus;
}

View File

@@ -7,4 +7,5 @@ export * from './vpn.js';
export * from './dns-provider.js';
export * from './domain.js';
export * from './dns-record.js';
export * from './acme-config.js';
export * from './acme-config.js';
export * from './email-domain.js';

View File

@@ -143,6 +143,14 @@ export interface IHealthStatus {
version?: string;
}
export interface IDomainActivity {
domain: string;
bytesInPerSecond: number;
bytesOutPerSecond: number;
activeConnections: number;
routeCount: number;
}
export interface INetworkMetrics {
totalBandwidth: {
in: number;
@@ -156,12 +164,21 @@ export interface INetworkMetrics {
connectionDetails: IConnectionDetails[];
topEndpoints: Array<{
endpoint: string;
requests: number;
connections: number;
bandwidth: {
in: number;
out: number;
};
}>;
topEndpointsByBandwidth: Array<{
endpoint: string;
connections: number;
bandwidth: {
in: number;
out: number;
};
}>;
domainActivity: IDomainActivity[];
throughputHistory?: Array<{ timestamp: number; in: number; out: number }>;
requestsPerSecond?: number;
requestsTotal?: number;

View File

@@ -148,3 +148,31 @@ export interface IReq_SyncDomain extends plugins.typedrequestInterfaces.implemen
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;
};
}

View File

@@ -0,0 +1,178 @@
import * as plugins from '../plugins.js';
import type * as authInterfaces from '../data/auth.js';
import type { IEmailDomain, IEmailDnsRecord } from '../data/email-domain.js';
// ============================================================================
// Email Domain Endpoints
// ============================================================================
/**
* List all email domains.
*/
export interface IReq_GetEmailDomains extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetEmailDomains
> {
method: 'getEmailDomains';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
};
response: {
domains: IEmailDomain[];
};
}
/**
* Get a single email domain by id.
*/
export interface IReq_GetEmailDomain extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetEmailDomain
> {
method: 'getEmailDomain';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
};
response: {
domain: IEmailDomain | null;
};
}
/**
* Create an email domain. Links to an existing dcrouter DNS domain.
* Generates DKIM keys and computes the required DNS records.
*/
export interface IReq_CreateEmailDomain extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_CreateEmailDomain
> {
method: 'createEmailDomain';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
/** ID of the existing dcrouter DNS domain to link to. */
linkedDomainId: string;
/** Optional subdomain prefix (e.g. 'mail' for mail.example.com). Leave empty for bare domain. */
subdomain?: string;
/** DKIM selector (default: 'default'). */
dkimSelector?: string;
/** RSA key size (default: 2048). */
dkimKeySize?: number;
/** Enable automatic key rotation (default: false). */
rotateKeys?: boolean;
/** Days between rotations (default: 90). */
rotationIntervalDays?: number;
};
response: {
success: boolean;
domain?: IEmailDomain;
message?: string;
};
}
/**
* Update an email domain's configuration.
*/
export interface IReq_UpdateEmailDomain extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_UpdateEmailDomain
> {
method: 'updateEmailDomain';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
rotateKeys?: boolean;
rotationIntervalDays?: number;
rateLimits?: IEmailDomain['rateLimits'];
};
response: {
success: boolean;
message?: string;
};
}
/**
* Delete an email domain.
*/
export interface IReq_DeleteEmailDomain extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DeleteEmailDomain
> {
method: 'deleteEmailDomain';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Trigger DNS validation for an email domain.
* Performs live lookups for MX, SPF, DKIM, and DMARC records.
*/
export interface IReq_ValidateEmailDomain extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_ValidateEmailDomain
> {
method: 'validateEmailDomain';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
};
response: {
success: boolean;
domain?: IEmailDomain;
records?: IEmailDnsRecord[];
message?: string;
};
}
/**
* Get the required DNS records for an email domain (for display / copy-paste).
*/
export interface IReq_GetEmailDomainDnsRecords extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetEmailDomainDnsRecords
> {
method: 'getEmailDomainDnsRecords';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
};
response: {
records: IEmailDnsRecord[];
};
}
/**
* Auto-provision DNS records for an email domain.
* Creates any missing MX, SPF, DKIM, and DMARC records via the linked
* domain's DNS path (dcrouter zone or provider API).
*/
export interface IReq_ProvisionEmailDomainDns extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_ProvisionEmailDomainDns
> {
method: 'provisionEmailDomainDns';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
id: string;
};
response: {
success: boolean;
/** Number of records created. */
provisioned?: number;
message?: string;
};
}

View File

@@ -17,4 +17,5 @@ export * from './users.js';
export * from './dns-providers.js';
export * from './domains.js';
export * from './dns-records.js';
export * from './acme-config.js';
export * from './acme-config.js';
export * from './email-domains.js';

View File

@@ -180,5 +180,9 @@ export interface IReq_GetNetworkStats extends plugins.typedrequestInterfaces.imp
requestsPerSecond: number;
requestsTotal: number;
backends?: statsInterfaces.IBackendInfo[];
topIPsByBandwidth: Array<{ ip: string; count: number; bwIn: number; bwOut: number }>;
domainActivity: statsInterfaces.IDomainActivity[];
frontendProtocols?: statsInterfaces.IProtocolDistribution | null;
backendProtocols?: statsInterfaces.IProtocolDistribution | null;
};
}

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.9.2',
version: '13.15.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}

View File

@@ -52,7 +52,9 @@ export interface INetworkState {
throughputRate: { bytesInPerSecond: number; bytesOutPerSecond: number };
totalBytes: { in: number; out: number };
topIPs: Array<{ ip: string; count: number }>;
topIPsByBandwidth: Array<{ ip: string; count: number; bwIn: number; bwOut: number }>;
throughputByIP: Array<{ ip: string; in: number; out: number }>;
domainActivity: interfaces.data.IDomainActivity[];
throughputHistory: Array<{ timestamp: number; in: number; out: number }>;
requestsPerSecond: number;
requestsTotal: number;
@@ -160,7 +162,9 @@ export const networkStatePart = await appState.getStatePart<INetworkState>(
throughputRate: { bytesInPerSecond: 0, bytesOutPerSecond: 0 },
totalBytes: { in: 0, out: 0 },
topIPs: [],
topIPsByBandwidth: [],
throughputByIP: [],
domainActivity: [],
throughputHistory: [],
requestsPerSecond: 0,
requestsTotal: 0,
@@ -518,14 +522,13 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
});
// Get network stats for throughput and IP data
const networkStatsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest(
'/typedrequest',
'getNetworkStats'
);
const networkStatsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetNetworkStats
>('/typedrequest', 'getNetworkStats');
const networkStatsResponse = await networkStatsRequest.fire({
identity: context.identity,
}) as any;
});
// Use the connections data for the connection list
// and network stats for throughput and IP analytics
@@ -552,7 +555,9 @@ export const fetchNetworkStatsAction = networkStatePart.createAction(async (stat
? { in: networkStatsResponse.totalDataTransferred.bytesIn, out: networkStatsResponse.totalDataTransferred.bytesOut }
: { in: 0, out: 0 },
topIPs: networkStatsResponse.topIPs || [],
topIPsByBandwidth: networkStatsResponse.topIPsByBandwidth || [],
throughputByIP: networkStatsResponse.throughputByIP || [],
domainActivity: networkStatsResponse.domainActivity || [],
throughputHistory: networkStatsResponse.throughputHistory || [],
requestsPerSecond: networkStatsResponse.requestsPerSecond || 0,
requestsTotal: networkStatsResponse.requestsTotal || 0,
@@ -1887,6 +1892,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<{
domainId: string;
name: string;
@@ -2377,6 +2408,130 @@ export const toggleApiTokenAction = routeManagementStatePart.createAction<{
}
});
// ============================================================================
// Email Domains State
// ============================================================================
export interface IEmailDomainsState {
domains: interfaces.data.IEmailDomain[];
isLoading: boolean;
lastUpdated: number;
}
export const emailDomainsStatePart = await appState.getStatePart<IEmailDomainsState>(
'emailDomains',
{
domains: [],
isLoading: false,
lastUpdated: 0,
},
'soft',
);
export const fetchEmailDomainsAction = emailDomainsStatePart.createAction(
async (statePartArg): Promise<IEmailDomainsState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
if (!context.identity) return currentState;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetEmailDomains
>('/typedrequest', 'getEmailDomains');
const response = await request.fire({ identity: context.identity });
return {
...currentState,
domains: response.domains,
isLoading: false,
lastUpdated: Date.now(),
};
} catch {
return { ...currentState, isLoading: false };
}
},
);
export const createEmailDomainAction = emailDomainsStatePart.createAction<{
linkedDomainId: string;
subdomain?: string;
dkimSelector?: string;
dkimKeySize?: number;
rotateKeys?: boolean;
rotationIntervalDays?: number;
}>(async (statePartArg, args, actionContext) => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateEmailDomain
>('/typedrequest', 'createEmailDomain');
await request.fire({ identity: context.identity!, ...args });
return await actionContext!.dispatch(fetchEmailDomainsAction, null);
} catch {
return currentState;
}
});
export const deleteEmailDomainAction = emailDomainsStatePart.createAction<string>(
async (statePartArg, id, actionContext) => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteEmailDomain
>('/typedrequest', 'deleteEmailDomain');
await request.fire({ identity: context.identity!, id });
return await actionContext!.dispatch(fetchEmailDomainsAction, null);
} catch {
return currentState;
}
},
);
export const validateEmailDomainAction = emailDomainsStatePart.createAction<string>(
async (statePartArg, id, actionContext) => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ValidateEmailDomain
>('/typedrequest', 'validateEmailDomain');
await request.fire({ identity: context.identity!, id });
return await actionContext!.dispatch(fetchEmailDomainsAction, null);
} catch {
return currentState;
}
},
);
export const provisionEmailDomainDnsAction = emailDomainsStatePart.createAction<string>(
async (statePartArg, id, actionContext) => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ProvisionEmailDomainDns
>('/typedrequest', 'provisionEmailDomainDns');
await request.fire({ identity: context.identity!, id });
return await actionContext!.dispatch(fetchEmailDomainsAction, null);
} catch {
return currentState;
}
},
);
// ============================================================================
// Email Domain Standalone Functions
// ============================================================================
export async function fetchEmailDomainDnsRecords(id: string) {
const context = getActionContext();
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetEmailDomainDnsRecords
>('/typedrequest', 'getEmailDomainDnsRecords');
return request.fire({ identity: context.identity!, id });
}
// ============================================================================
// TypedSocket Client for Real-time Log Streaming
// ============================================================================
@@ -2499,67 +2654,52 @@ async function dispatchCombinedRefreshActionInner() {
if (combinedResponse.metrics.network && currentView === 'network') {
const network = combinedResponse.metrics.network;
const connectionsByIP: { [ip: string]: number } = {};
// Convert connection details to IP counts
// Build connectionsByIP from connectionDetails (now populated with real per-IP data)
network.connectionDetails.forEach(conn => {
connectionsByIP[conn.remoteAddress] = (connectionsByIP[conn.remoteAddress] || 0) + 1;
});
// Fetch detailed connections for the network view
try {
const connectionsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetActiveConnections
>('/typedrequest', 'getActiveConnections');
const connectionsResponse = await connectionsRequest.fire({
identity: context.identity,
});
// Build connections from connectionDetails (real per-IP aggregates)
const connections: interfaces.data.IConnectionInfo[] = network.connectionDetails.map((conn, i) => ({
id: `ip-${conn.remoteAddress}`,
remoteAddress: conn.remoteAddress,
localAddress: 'server',
startTime: conn.startTime,
protocol: conn.protocol as any,
state: conn.state as any,
bytesReceived: conn.bytesIn,
bytesSent: conn.bytesOut,
}));
networkStatePart.setState({
...networkStatePart.getState()!,
connections: connectionsResponse.connections,
connectionsByIP,
throughputRate: {
bytesInPerSecond: network.totalBandwidth.in,
bytesOutPerSecond: network.totalBandwidth.out
},
totalBytes: network.totalBytes || { in: 0, out: 0 },
topIPs: network.topEndpoints.map(e => ({ ip: e.endpoint, count: e.requests })),
throughputByIP: network.topEndpoints.map(e => ({ ip: e.endpoint, in: e.bandwidth?.in || 0, out: e.bandwidth?.out || 0 })),
throughputHistory: network.throughputHistory || [],
requestsPerSecond: network.requestsPerSecond || 0,
requestsTotal: network.requestsTotal || 0,
backends: network.backends || [],
frontendProtocols: network.frontendProtocols || null,
backendProtocols: network.backendProtocols || null,
lastUpdated: Date.now(),
isLoading: false,
error: null,
});
} catch (error: unknown) {
console.error('Failed to fetch connections:', error);
networkStatePart.setState({
...networkStatePart.getState()!,
connections: [],
connectionsByIP,
throughputRate: {
bytesInPerSecond: network.totalBandwidth.in,
bytesOutPerSecond: network.totalBandwidth.out
},
totalBytes: network.totalBytes || { in: 0, out: 0 },
topIPs: network.topEndpoints.map(e => ({ ip: e.endpoint, count: e.requests })),
throughputByIP: network.topEndpoints.map(e => ({ ip: e.endpoint, in: e.bandwidth?.in || 0, out: e.bandwidth?.out || 0 })),
throughputHistory: network.throughputHistory || [],
requestsPerSecond: network.requestsPerSecond || 0,
requestsTotal: network.requestsTotal || 0,
backends: network.backends || [],
frontendProtocols: network.frontendProtocols || null,
backendProtocols: network.backendProtocols || null,
lastUpdated: Date.now(),
isLoading: false,
error: null,
});
}
networkStatePart.setState({
...networkStatePart.getState()!,
connections,
connectionsByIP,
throughputRate: {
bytesInPerSecond: network.totalBandwidth.in,
bytesOutPerSecond: network.totalBandwidth.out,
},
totalBytes: network.totalBytes || { in: 0, out: 0 },
topIPs: network.topEndpoints.map(e => ({ ip: e.endpoint, count: e.connections })),
topIPsByBandwidth: (network.topEndpointsByBandwidth || []).map(e => ({
ip: e.endpoint,
count: e.connections,
bwIn: e.bandwidth?.in || 0,
bwOut: e.bandwidth?.out || 0,
})),
throughputByIP: network.topEndpoints.map(e => ({ ip: e.endpoint, in: e.bandwidth?.in || 0, out: e.bandwidth?.out || 0 })),
domainActivity: network.domainActivity || [],
throughputHistory: network.throughputHistory || [],
requestsPerSecond: network.requestsPerSecond || 0,
requestsTotal: network.requestsTotal || 0,
backends: network.backends || [],
frontendProtocols: network.frontendProtocols || null,
backendProtocols: network.backendProtocols || null,
lastUpdated: Date.now(),
isLoading: false,
error: null,
});
}
// Refresh certificate data if on Domains > Certificates subview

View File

@@ -54,51 +54,6 @@ export class OpsViewCertificates extends DeesElement {
gap: 24px;
}
.acmeTileHeader {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 8px 12px;
}
.acmeTileHeading {
font-size: 14px;
font-weight: 600;
}
.acmeEmptyContent {
padding: 16px;
font-size: 13px;
line-height: 1.5;
color: ${cssManager.bdTheme('#78350f', '#fde68a')};
}
.acmeGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px 24px;
padding: 16px;
}
.acmeField {
display: flex;
flex-direction: column;
gap: 2px;
}
.acmeLabel {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.03em;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.acmeValue {
font-size: 13px;
color: ${cssManager.bdTheme('#111827', '#f3f4f6')};
}
.statusBadge {
display: inline-flex;
align-items: center;
@@ -227,60 +182,26 @@ export class OpsViewCertificates extends DeesElement {
if (!config) {
return html`
<dees-tile .heading=${'ACME Settings'}>
<div slot="header" class="acmeTileHeader">
<span class="acmeTileHeading">ACME Settings</span>
<dees-button
@click=${() => this.showEditAcmeDialog()}
.type=${'highlighted'}
>Configure</dees-button>
</div>
<div class="acmeEmptyContent">
No ACME configuration yet. Click <strong>Configure</strong> to set up automated TLS
certificate issuance via Let's Encrypt. You'll also need at least one DNS provider
under <strong>Domains &gt; Providers</strong>.
</div>
</dees-tile>
<dees-settings
.heading=${'ACME Settings'}
.description=${'No ACME configuration yet. Click Configure to set up automated TLS certificate issuance via Let\'s Encrypt. You\'ll also need at least one DNS provider under Domains > Providers.'}
.actions=${[{ name: 'Configure', action: () => this.showEditAcmeDialog() }]}
></dees-settings>
`;
}
return html`
<dees-tile>
<div slot="header" class="acmeTileHeader">
<span class="acmeTileHeading">ACME Settings</span>
<dees-button @click=${() => this.showEditAcmeDialog()}>Edit</dees-button>
</div>
<div class="acmeGrid">
<div class="acmeField">
<span class="acmeLabel">Account email</span>
<span class="acmeValue">${config.accountEmail || '(not set)'}</span>
</div>
<div class="acmeField">
<span class="acmeLabel">Status</span>
<span class="acmeValue">
<span class="statusBadge ${config.enabled ? 'valid' : 'unknown'}">
${config.enabled ? 'enabled' : 'disabled'}
</span>
</span>
</div>
<div class="acmeField">
<span class="acmeLabel">Mode</span>
<span class="acmeValue">
<span class="statusBadge ${config.useProduction ? 'valid' : 'provisioning'}">
${config.useProduction ? 'production' : 'staging'}
</span>
</span>
</div>
<div class="acmeField">
<span class="acmeLabel">Auto-renew</span>
<span class="acmeValue">${config.autoRenew ? 'on' : 'off'}</span>
</div>
<div class="acmeField">
<span class="acmeLabel">Renewal threshold</span>
<span class="acmeValue">${config.renewThresholdDays} days</span>
</div>
</div>
</dees-tile>
<dees-settings
.heading=${'ACME Settings'}
.settingsFields=${[
{ key: 'email', label: 'Account email', value: config.accountEmail || '(not set)' },
{ key: 'status', label: 'Status', value: config.enabled ? 'enabled' : 'disabled' },
{ key: 'mode', label: 'Mode', value: config.useProduction ? 'production' : 'staging' },
{ key: 'autoRenew', label: 'Auto-renew', value: config.autoRenew ? 'on' : 'off' },
{ key: 'threshold', label: 'Renewal threshold', value: `${config.renewThresholdDays} days` },
]}
.actions=${[{ name: 'Edit', action: () => this.showEditAcmeDialog() }]}
></dees-settings>
`;
}

View File

@@ -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',
iconName: 'lucide:trash2',
@@ -308,6 +317,94 @@ 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 }[] = [];
for (const p of providers) {
// Skip current source
if (p.builtIn && domain.source === 'dcrouter') continue;
if (!p.builtIn && domain.source === 'provider' && domain.providerId === p.id) continue;
const label = p.builtIn ? 'DcRouter (self)' : `${p.name} (${p.type})`;
const key = p.builtIn ? 'dcrouter' : `provider:${p.id}`;
targetOptions.push({ option: label, key });
}
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 (self)'
: 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) {
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({

View File

@@ -1,2 +1,3 @@
export * from './ops-view-emails.js';
export * from './ops-view-email-security.js';
export * from './ops-view-email-domains.js';

View File

@@ -0,0 +1,396 @@
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 { type IStatsTile } from '@design.estate/dees-catalog';
declare global {
interface HTMLElementTagNameMap {
'ops-view-email-domains': OpsViewEmailDomains;
}
}
@customElement('ops-view-email-domains')
export class OpsViewEmailDomains extends DeesElement {
@state()
accessor emailDomainsState: appstate.IEmailDomainsState =
appstate.emailDomainsStatePart.getState()!;
@state()
accessor domainsState: appstate.IDomainsState = appstate.domainsStatePart.getState()!;
constructor() {
super();
const sub = appstate.emailDomainsStatePart.select().subscribe((s) => {
this.emailDomainsState = s;
});
this.rxSubscriptions.push(sub);
const domSub = appstate.domainsStatePart.select().subscribe((s) => {
this.domainsState = s;
});
this.rxSubscriptions.push(domSub);
}
async connectedCallback() {
await super.connectedCallback();
await appstate.emailDomainsStatePart.dispatchAction(appstate.fetchEmailDomainsAction, null);
await appstate.domainsStatePart.dispatchAction(appstate.fetchDomainsAndProvidersAction, null);
}
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
.emailDomainsContainer {
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;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.statusBadge.valid {
background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
color: ${cssManager.bdTheme('#166534', '#4ade80')};
}
.statusBadge.missing {
background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
}
.statusBadge.invalid {
background: ${cssManager.bdTheme('#fff7ed', '#431407')};
color: ${cssManager.bdTheme('#9a3412', '#fb923c')};
}
.statusBadge.unchecked {
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
color: ${cssManager.bdTheme('#4b5563', '#9ca3af')};
}
.sourceBadge {
display: inline-flex;
align-items: center;
padding: 3px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
`,
];
public render(): TemplateResult {
const domains = this.emailDomainsState.domains;
const validCount = domains.filter(
(d) =>
d.dnsStatus.mx === 'valid' &&
d.dnsStatus.spf === 'valid' &&
d.dnsStatus.dkim === 'valid' &&
d.dnsStatus.dmarc === 'valid',
).length;
const issueCount = domains.length - validCount;
const tiles: IStatsTile[] = [
{
id: 'total',
title: 'Total Domains',
value: domains.length,
type: 'number',
icon: 'lucide:globe',
color: '#3b82f6',
},
{
id: 'valid',
title: 'Valid DNS',
value: validCount,
type: 'number',
icon: 'lucide:Check',
color: '#22c55e',
},
{
id: 'issues',
title: 'Issues',
value: issueCount,
type: 'number',
icon: 'lucide:TriangleAlert',
color: issueCount > 0 ? '#ef4444' : '#22c55e',
},
{
id: 'dkim',
title: 'DKIM Active',
value: domains.filter((d) => d.dkim.publicKey).length,
type: 'number',
icon: 'lucide:KeyRound',
color: '#8b5cf6',
},
];
return html`
<dees-heading level="3">Email Domains</dees-heading>
<div class="emailDomainsContainer">
<dees-statsgrid
.tiles=${tiles}
.minTileWidth=${200}
.gridActions=${[
{
name: 'Refresh',
iconName: 'lucide:RefreshCw',
action: async () => {
await appstate.emailDomainsStatePart.dispatchAction(
appstate.fetchEmailDomainsAction,
null,
);
},
},
]}
></dees-statsgrid>
<dees-table
.heading1=${'Email Domains'}
.heading2=${'DKIM, SPF, DMARC and MX management'}
.data=${domains}
.showColumnFilters=${true}
.displayFunction=${(d: interfaces.data.IEmailDomain) => ({
Domain: d.domain,
Source: this.renderSourceBadge(d.linkedDomainId),
MX: this.renderDnsStatus(d.dnsStatus.mx),
SPF: this.renderDnsStatus(d.dnsStatus.spf),
DKIM: this.renderDnsStatus(d.dnsStatus.dkim),
DMARC: this.renderDnsStatus(d.dnsStatus.dmarc),
})}
.dataActions=${[
{
name: 'Add Email Domain',
iconName: 'lucide:plus',
type: ['header'] as any,
actionFunc: async () => {
await this.showCreateDialog();
},
},
{
name: 'Validate DNS',
iconName: 'lucide:search-check',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const d = actionData.item as interfaces.data.IEmailDomain;
await appstate.emailDomainsStatePart.dispatchAction(
appstate.validateEmailDomainAction,
d.id,
);
const { DeesToast } = await import('@design.estate/dees-catalog');
DeesToast.show({ message: `DNS validated for ${d.domain}`, type: 'success', duration: 2500 });
},
},
{
name: 'Provision DNS',
iconName: 'lucide:wand-sparkles',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const d = actionData.item as interfaces.data.IEmailDomain;
await appstate.emailDomainsStatePart.dispatchAction(
appstate.provisionEmailDomainDnsAction,
d.id,
);
const { DeesToast } = await import('@design.estate/dees-catalog');
DeesToast.show({ message: `DNS records provisioned for ${d.domain}`, type: 'success', duration: 2500 });
},
},
{
name: 'View DNS Records',
iconName: 'lucide:list',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const d = actionData.item as interfaces.data.IEmailDomain;
await this.showDnsRecordsDialog(d);
},
},
{
name: 'Delete',
iconName: 'lucide:trash2',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const d = actionData.item as interfaces.data.IEmailDomain;
await appstate.emailDomainsStatePart.dispatchAction(
appstate.deleteEmailDomainAction,
d.id,
);
},
},
]}
dataName="email domain"
></dees-table>
</div>
`;
}
private renderDnsStatus(status: interfaces.data.TDnsRecordStatus): TemplateResult {
return html`<span class="statusBadge ${status}">${status}</span>`;
}
private renderSourceBadge(linkedDomainId: string): TemplateResult {
const domain = this.domainsState.domains.find((d) => d.id === linkedDomainId);
if (!domain) return html`<span class="sourceBadge">unknown</span>`;
const label =
domain.source === 'dcrouter'
? 'dcrouter'
: this.domainsState.providers.find((p) => p.id === domain.providerId)?.name || 'provider';
return html`<span class="sourceBadge">${label}</span>`;
}
private async showCreateDialog() {
const { DeesModal } = await import('@design.estate/dees-catalog');
const domainOptions = this.domainsState.domains.map((d) => ({
option: `${d.name} (${d.source})`,
key: d.id,
}));
DeesModal.createAndShow({
heading: 'Add Email Domain',
content: html`
<dees-form>
<dees-input-dropdown
.key=${'linkedDomainId'}
.label=${'Domain'}
.description=${'Select an existing DNS domain'}
.options=${domainOptions}
.required=${true}
></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
.key=${'dkimSelector'}
.label=${'DKIM Selector'}
.description=${'Identifier used in DNS record name'}
.value=${'default'}
></dees-input-text>
<dees-input-dropdown
.key=${'dkimKeySize'}
.label=${'DKIM Key Size'}
.options=${[
{ option: '2048 (recommended)', key: '2048' },
{ option: '1024', key: '1024' },
{ option: '4096', key: '4096' },
]}
.selectedOption=${{ option: '2048 (recommended)', key: '2048' }}
></dees-input-dropdown>
<dees-input-checkbox
.key=${'rotateKeys'}
.label=${'Auto-rotate DKIM keys'}
.value=${false}
></dees-input-checkbox>
</dees-form>
`,
menuOptions: [
{ name: 'Cancel', action: async (m: any) => m.destroy() },
{
name: 'Create',
action: async (m: any) => {
const form = m.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
const linkedDomainId =
typeof data.linkedDomainId === 'object'
? data.linkedDomainId.key
: data.linkedDomainId;
const keySize =
typeof data.dkimKeySize === 'object'
? parseInt(data.dkimKeySize.key, 10)
: parseInt(data.dkimKeySize || '2048', 10);
const subdomain = data.subdomain?.trim() || undefined;
await appstate.emailDomainsStatePart.dispatchAction(
appstate.createEmailDomainAction,
{
linkedDomainId,
subdomain,
dkimSelector: data.dkimSelector || 'default',
dkimKeySize: keySize,
rotateKeys: Boolean(data.rotateKeys),
},
);
m.destroy();
},
},
],
});
}
private async showDnsRecordsDialog(emailDomain: interfaces.data.IEmailDomain) {
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
// Fetch required DNS records
let records: interfaces.data.IEmailDnsRecord[] = [];
try {
const response = await appstate.fetchEmailDomainDnsRecords(emailDomain.id);
records = response.records;
} catch {
records = [];
}
DeesModal.createAndShow({
heading: `DNS Records: ${emailDomain.domain}`,
content: html`
<dees-table
.data=${records}
.displayFunction=${(r: interfaces.data.IEmailDnsRecord) => ({
Type: r.type,
Name: r.name,
Value: r.value,
Status: html`<span class="statusBadge ${r.status}">${r.status}</span>`,
})}
.dataActions=${[
{
name: 'Copy Value',
iconName: 'lucide:copy',
type: ['inRow'] as any,
actionFunc: async (actionData: any) => {
const rec = actionData.item as interfaces.data.IEmailDnsRecord;
await navigator.clipboard.writeText(rec.value);
DeesToast.show({ message: 'Copied to clipboard', type: 'success', duration: 1500 });
},
},
]}
dataName="DNS record"
></dees-table>
`,
menuOptions: [
{
name: 'Auto-Provision All',
action: async (m: any) => {
await appstate.emailDomainsStatePart.dispatchAction(
appstate.provisionEmailDomainDnsAction,
emailDomain.id,
);
DeesToast.show({ message: 'DNS records provisioned', type: 'success', duration: 2500 });
m.destroy();
},
},
{ name: 'Close', action: async (m: any) => m.destroy() },
],
});
}
}

View File

@@ -37,25 +37,10 @@ export class OpsViewEmailSecurity extends DeesElement {
cssManager.defaultStyles,
viewHostCss,
css`
h2 {
margin: 32px 0 16px 0;
font-size: 24px;
font-weight: 600;
color: ${cssManager.bdTheme('#333', '#ccc')};
}
dees-statsgrid {
margin-bottom: 32px;
}
.securityCard {
background: ${cssManager.bdTheme('#fff', '#222')};
border: 1px solid ${cssManager.bdTheme('#e9ecef', '#333')};
border-radius: 8px;
padding: 24px;
position: relative;
overflow: hidden;
}
.actionButton {
margin-top: 16px;
.securityContainer {
display: flex;
flex-direction: column;
gap: 24px;
}
`,
];
@@ -113,48 +98,44 @@ export class OpsViewEmailSecurity extends DeesElement {
return html`
<dees-heading level="3">Email Security</dees-heading>
<dees-statsgrid
.tiles=${tiles}
.minTileWidth=${200}
></dees-statsgrid>
<div class="securityContainer">
<dees-statsgrid
.tiles=${tiles}
.minTileWidth=${200}
></dees-statsgrid>
<h2>Email Security Configuration</h2>
<div class="securityCard">
<dees-form>
<dees-input-checkbox
.key=${'enableSPF'}
.label=${'Enable SPF checking'}
.value=${true}
></dees-input-checkbox>
<dees-input-checkbox
.key=${'enableDKIM'}
.label=${'Enable DKIM validation'}
.value=${true}
></dees-input-checkbox>
<dees-input-checkbox
.key=${'enableDMARC'}
.label=${'Enable DMARC policy enforcement'}
.value=${true}
></dees-input-checkbox>
<dees-input-checkbox
.key=${'enableSpamFilter'}
.label=${'Enable spam filtering'}
.value=${true}
></dees-input-checkbox>
</dees-form>
<dees-button
class="actionButton"
type="highlighted"
@click=${() => this.saveEmailSecuritySettings()}
>
Save Settings
</dees-button>
<dees-settings
.heading=${'Security Configuration'}
.settingsFields=${[
{ key: 'spf', label: 'SPF checking', value: 'enabled' },
{ key: 'dkim', label: 'DKIM validation', value: 'enabled' },
{ key: 'dmarc', label: 'DMARC policy', value: 'enabled' },
{ key: 'spam', label: 'Spam filtering', value: 'enabled' },
]}
.actions=${[{ name: 'Edit', action: () => this.showEditSecurityDialog() }]}
></dees-settings>
</div>
`;
}
private async saveEmailSecuritySettings() {
// Config is read-only from the UI for now
alert('Email security settings are read-only. Update the dcrouter configuration file to change these settings.');
private async showEditSecurityDialog() {
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'Edit Security Configuration',
content: html`
<dees-form>
<dees-input-checkbox .key=${'enableSPF'} .label=${'SPF checking'} .value=${true}></dees-input-checkbox>
<dees-input-checkbox .key=${'enableDKIM'} .label=${'DKIM validation'} .value=${true}></dees-input-checkbox>
<dees-input-checkbox .key=${'enableDMARC'} .label=${'DMARC policy enforcement'} .value=${true}></dees-input-checkbox>
<dees-input-checkbox .key=${'enableSpamFilter'} .label=${'Spam filtering'} .value=${true}></dees-input-checkbox>
</dees-form>
<p style="margin-top: 12px; font-size: 12px; opacity: 0.7;">
These settings are read-only for now. Update the dcrouter configuration to change them.
</p>
`,
menuOptions: [
{ name: 'Close', action: async (modalArg: any) => modalArg.destroy() },
],
});
}
}

View File

@@ -10,22 +10,6 @@ declare global {
}
}
interface INetworkRequest {
id: string;
timestamp: number;
method: string;
url: string;
hostname: string;
port: number;
protocol: 'http' | 'https' | 'tcp' | 'udp';
statusCode?: number;
duration: number;
bytesIn: number;
bytesOut: number;
remoteIp: string;
route?: string;
}
@customElement('ops-view-network-activity')
export class OpsViewNetworkActivity extends DeesElement {
/** How far back the traffic chart shows */
@@ -42,9 +26,6 @@ export class OpsViewNetworkActivity extends DeesElement {
accessor networkState = appstate.networkStatePart.getState()!;
@state()
accessor networkRequests: INetworkRequest[] = [];
@state()
accessor trafficDataIn: Array<{ x: string | number; y: number }> = [];
@@ -314,108 +295,21 @@ export class OpsViewNetworkActivity extends DeesElement {
<!-- Protocol Distribution Charts -->
${this.renderProtocolCharts()}
<!-- Top IPs Section -->
<!-- Top IPs by Connection Count -->
${this.renderTopIPs()}
<!-- Top IPs by Bandwidth -->
${this.renderTopIPsByBandwidth()}
<!-- Domain Activity -->
${this.renderDomainActivity()}
<!-- Backend Protocols Section -->
${this.renderBackendProtocols()}
<!-- Requests Table -->
<dees-table
.data=${this.networkRequests}
.rowKey=${'id'}
.highlightUpdates=${'flash'}
.displayFunction=${(req: INetworkRequest) => ({
Time: new Date(req.timestamp).toLocaleTimeString(),
Protocol: html`<span class="protocolBadge ${req.protocol}">${req.protocol.toUpperCase()}</span>`,
Method: req.method,
'Host:Port': `${req.hostname}:${req.port}`,
Path: this.truncateUrl(req.url),
Status: this.renderStatus(req.statusCode),
Duration: `${req.duration}ms`,
'In/Out': `${this.formatBytes(req.bytesIn)} / ${this.formatBytes(req.bytesOut)}`,
'Remote IP': req.remoteIp,
})}
.dataActions=${[
{
name: 'View Details',
iconName: 'fa:magnifyingGlass',
type: ['inRow', 'doubleClick', 'contextmenu'],
actionFunc: async (actionData) => {
await this.showRequestDetails(actionData.item);
}
}
]}
heading1="Recent Network Activity"
heading2="Recent network requests"
searchable
.showColumnFilters=${true}
.pagination=${true}
.paginationSize=${50}
dataName="request"
></dees-table>
</div>
`;
}
private async showRequestDetails(request: INetworkRequest) {
const { DeesModal } = await import('@design.estate/dees-catalog');
await DeesModal.createAndShow({
heading: 'Request Details',
content: html`
<div style="padding: 20px;">
<dees-dataview-codebox
.heading=${'Request Information'}
progLang="json"
.codeToDisplay=${JSON.stringify({
id: request.id,
timestamp: new Date(request.timestamp).toISOString(),
protocol: request.protocol,
method: request.method,
url: request.url,
hostname: request.hostname,
port: request.port,
statusCode: request.statusCode,
duration: `${request.duration}ms`,
bytesIn: request.bytesIn,
bytesOut: request.bytesOut,
remoteIp: request.remoteIp,
route: request.route,
}, null, 2)}
></dees-dataview-codebox>
</div>
`,
menuOptions: [
{
name: 'Copy Request ID',
iconName: 'lucide:Copy',
action: async () => {
await navigator.clipboard.writeText(request.id);
}
}
]
});
}
private renderStatus(statusCode?: number): TemplateResult {
if (!statusCode) {
return html`<span class="statusBadge warning">N/A</span>`;
}
const statusClass = statusCode >= 200 && statusCode < 300 ? 'success' :
statusCode >= 400 ? 'error' : 'warning';
return html`<span class="statusBadge ${statusClass}">${statusCode}</span>`;
}
private truncateUrl(url: string, maxLength = 50): string {
if (url.length <= maxLength) return url;
return url.substring(0, maxLength - 3) + '...';
}
private formatNumber(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
@@ -619,6 +513,66 @@ export class OpsViewNetworkActivity extends DeesElement {
`;
}
private renderTopIPsByBandwidth(): TemplateResult {
if (!this.networkState.topIPsByBandwidth || this.networkState.topIPsByBandwidth.length === 0) {
return html``;
}
return html`
<dees-table
.data=${this.networkState.topIPsByBandwidth}
.rowKey=${'ip'}
.highlightUpdates=${'flash'}
.displayFunction=${(ipData: { ip: string; count: number; bwIn: number; bwOut: number }) => {
return {
'IP Address': ipData.ip,
'Bandwidth In': this.formatBitsPerSecond(ipData.bwIn),
'Bandwidth Out': this.formatBitsPerSecond(ipData.bwOut),
'Total Bandwidth': this.formatBitsPerSecond(ipData.bwIn + ipData.bwOut),
'Connections': ipData.count,
};
}}
heading1="Top IPs by Bandwidth"
heading2="IPs with highest throughput"
searchable
.showColumnFilters=${true}
.pagination=${false}
dataName="ip"
></dees-table>
`;
}
private renderDomainActivity(): TemplateResult {
if (!this.networkState.domainActivity || this.networkState.domainActivity.length === 0) {
return html``;
}
return html`
<dees-table
.data=${this.networkState.domainActivity}
.rowKey=${'domain'}
.highlightUpdates=${'flash'}
.displayFunction=${(item: interfaces.data.IDomainActivity) => {
const totalBytesPerMin = (item.bytesInPerSecond + item.bytesOutPerSecond) * 60;
return {
'Domain': item.domain,
'Throughput In': this.formatBitsPerSecond(item.bytesInPerSecond),
'Throughput Out': this.formatBitsPerSecond(item.bytesOutPerSecond),
'Transferred / min': this.formatBytes(totalBytesPerMin),
'Connections': item.activeConnections,
'Routes': item.routeCount,
};
}}
heading1="Domain Activity"
heading2="Per-domain network activity aggregated from route metrics"
searchable
.showColumnFilters=${true}
.pagination=${false}
dataName="domain"
></dees-table>
`;
}
private renderBackendProtocols(): TemplateResult {
const backends = this.networkState.backends;
if (!backends || backends.length === 0) {
@@ -730,25 +684,6 @@ export class OpsViewNetworkActivity extends DeesElement {
this.requestsPerSecHistory.shift();
}
// Reassign unconditionally so dees-table's flash diff can compare per-cell
// values against the previous snapshot. Row identity is preserved via
// rowKey='id', so DOM nodes are reused across ticks.
this.networkRequests = this.networkState.connections.map((conn) => ({
id: conn.id,
timestamp: conn.startTime,
method: 'GET', // Default method for proxy connections
url: '/',
hostname: conn.remoteAddress,
port: conn.protocol === 'https' ? 443 : 80,
protocol: conn.protocol === 'https' || conn.protocol === 'http' ? conn.protocol : 'tcp',
statusCode: conn.state === 'connected' ? 200 : undefined,
duration: Date.now() - conn.startTime,
bytesIn: conn.bytesReceived,
bytesOut: conn.bytesSent,
remoteIp: conn.remoteAddress,
route: 'proxy',
}));
// Load server-side throughput history into chart (once)
if (!this.historyLoaded && this.networkState.throughputHistory && this.networkState.throughputHistory.length > 0) {
this.loadThroughputHistory();

View File

@@ -32,6 +32,7 @@ import { OpsViewVpn } from './network/ops-view-vpn.js';
// Email group
import { OpsViewEmails } from './email/ops-view-emails.js';
import { OpsViewEmailSecurity } from './email/ops-view-email-security.js';
import { OpsViewEmailDomains } from './email/ops-view-email-domains.js';
// Access group
import { OpsViewApiTokens } from './access/ops-view-apitokens.js';
@@ -108,6 +109,7 @@ export class OpsDashboard extends DeesElement {
subViews: [
{ slug: 'log', name: 'Email Log', iconName: 'lucide:scrollText', element: OpsViewEmails },
{ slug: 'security', name: 'Email Security', iconName: 'lucide:shieldCheck', element: OpsViewEmailSecurity },
{ slug: 'domains', name: 'Email Domains', iconName: 'lucide:globe', element: OpsViewEmailDomains },
],
},
{

View File

@@ -10,7 +10,7 @@ const flatViews = ['logs'] as const;
const subviewMap: Record<string, readonly string[]> = {
overview: ['stats', 'configuration'] as const,
network: ['activity', 'routes', 'sourceprofiles', 'networktargets', 'targetprofiles', 'remoteingress', 'vpn'] as const,
email: ['log', 'security'] as const,
email: ['log', 'security', 'domains'] as const,
access: ['apitokens', 'users'] as const,
security: ['overview', 'blocked', 'authentication'] as const,
domains: ['providers', 'domains', 'dns', 'certificates'] as const,