From 6cc3700d296e3a26c98f0c0da95c0ca8c4223ed5 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sun, 14 Sep 2025 17:38:16 +0000 Subject: [PATCH] feat(domains): enhance domain management with activation states and sync options --- ts/manager.dns/classes.dnsmanager.ts | 12 +++- ts/manager.domain/classes.domain.ts | 6 +- ts/manager.task/predefinedtasks.ts | 86 +++++++++++++++++++++++++- ts_interfaces/data/domain.ts | 16 ++++- ts_web/elements/views/dns/index.ts | 14 ++++- ts_web/elements/views/domains/index.ts | 11 +++- 6 files changed, 136 insertions(+), 9 deletions(-) diff --git a/ts/manager.dns/classes.dnsmanager.ts b/ts/manager.dns/classes.dnsmanager.ts index ad4b496..dac9dd0 100644 --- a/ts/manager.dns/classes.dnsmanager.ts +++ b/ts/manager.dns/classes.dnsmanager.ts @@ -69,12 +69,15 @@ export class DnsManager { this.cloudlyRef.authManager.validIdentityGuard, ]); - // Validate domain exists if domainId is provided + // Validate domain exists and is activated if domainId is provided if (reqArg.dnsEntryData.domainId) { const domain = await this.cloudlyRef.domainManager.CDomain.getDomainById(reqArg.dnsEntryData.domainId); if (!domain) { throw new Error(`Domain with id ${reqArg.dnsEntryData.domainId} not found`); } + if ((domain.data as any).activationState !== 'activated') { + throw new Error(`Domain ${domain.data.name} is not activated; DNS changes are not allowed.`); + } // Set the zone from the domain name reqArg.dnsEntryData.zone = domain.data.name; } @@ -97,12 +100,15 @@ export class DnsManager { this.cloudlyRef.authManager.validIdentityGuard, ]); - // Validate domain exists if domainId is provided + // Validate domain exists and is activated if domainId is provided if (reqArg.dnsEntryData.domainId) { const domain = await this.cloudlyRef.domainManager.CDomain.getDomainById(reqArg.dnsEntryData.domainId); if (!domain) { throw new Error(`Domain with id ${reqArg.dnsEntryData.domainId} not found`); } + if ((domain.data as any).activationState !== 'activated') { + throw new Error(`Domain ${domain.data.name} is not activated; DNS changes are not allowed.`); + } // Set the zone from the domain name reqArg.dnsEntryData.zone = domain.data.name; } @@ -258,4 +264,4 @@ export class DnsManager { public async stop() { console.log('DNS Manager stopped'); } -} \ No newline at end of file +} diff --git a/ts/manager.domain/classes.domain.ts b/ts/manager.domain/classes.domain.ts index 9a0f29f..c25a7a2 100644 --- a/ts/manager.domain/classes.domain.ts +++ b/ts/manager.domain/classes.domain.ts @@ -36,6 +36,9 @@ export class Domain extends plugins.smartdata.SmartDataDbDoc< verificationStatus: domainDataArg.verificationStatus || 'pending', nameservers: domainDataArg.nameservers || [], autoRenew: domainDataArg.autoRenew !== false, + activationState: domainDataArg.activationState || 'available', + syncSource: domainDataArg.syncSource ?? null, + lastSyncAt: domainDataArg.lastSyncAt, createdAt: Date.now(), updatedAt: Date.now(), }; @@ -55,6 +58,7 @@ export class Domain extends plugins.smartdata.SmartDataDbDoc< } Object.assign(domain.data, domainDataArg, { updatedAt: Date.now(), + activationState: domain.data.activationState || 'available', }); await domain.save(); return domain; @@ -201,4 +205,4 @@ export class Domain extends plugins.smartdata.SmartDataDbDoc< }); return dnsEntries; } -} \ No newline at end of file +} diff --git a/ts/manager.task/predefinedtasks.ts b/ts/manager.task/predefinedtasks.ts index e1b01bd..0eebe5b 100644 --- a/ts/manager.task/predefinedtasks.ts +++ b/ts/manager.task/predefinedtasks.ts @@ -7,6 +7,86 @@ import { logger } from '../logger.js'; */ export function createPredefinedTasks(taskManager: CloudlyTaskManager) { + // Cloudflare Domain Sync Task + const cfDomainSync = new plugins.taskbuffer.Task({ + name: 'cloudflare-domain-sync', + taskFunction: async () => { + const execution = taskManager.getCurrentExecution('cloudflare-domain-sync'); + try { + await execution?.addLog('Starting Cloudflare domain sync…', 'info'); + const cf = taskManager.cloudlyRef.cloudflareConnector?.cloudflare; + if (!cf) { + await execution?.addLog('Cloudflare not configured; skipping sync.', 'warning'); + return { created: 0, updated: 0, totalZones: 0 }; + } + + const zones = await cf.convenience.listZones(); + await execution?.setMetric('totalZones', zones.length); + await execution?.addLog(`Fetched ${zones.length} zones from Cloudflare`, 'info'); + + let created = 0; + let updated = 0; + const now = Date.now(); + + for (const zone of zones) { + // zone fields from Cloudflare typings + const zoneName = (zone as any).name as string; + const zoneId = (zone as any).id as string; + const zoneStatus = ((zone as any).status || 'active') as 'active'|'pending'|'suspended'|'transferred'|'expired'; + const nameServers: string[] = (zone as any).name_servers || []; + + const existing = await taskManager.cloudlyRef.domainManager.CDomain.getDomainByName(zoneName); + if (existing) { + if (execution && (taskManager.isCancellationRequested(execution.id) || existing.data == null)) { + await execution?.addLog('Cancellation requested. Stopping CF sync…', 'warning'); + break; + } + await execution?.addLog(`Updating domain: ${zoneName}`, 'info'); + await taskManager.cloudlyRef.domainManager.CDomain.updateDomain(existing.id, { + status: zoneStatus as any, + nameservers: nameServers, + cloudflareZoneId: zoneId, + syncSource: 'cloudflare', + lastSyncAt: now, + activationState: existing.data.activationState || 'available', + }); + updated++; + } else { + await execution?.addLog(`Creating domain: ${zoneName}`, 'info'); + await taskManager.cloudlyRef.domainManager.CDomain.createDomain({ + name: zoneName, + description: `Synced from Cloudflare zone ${zoneId}`, + status: zoneStatus as any, + verificationStatus: 'pending', + nameservers: nameServers, + autoRenew: true, + cloudflareZoneId: zoneId, + activationState: 'available', + syncSource: 'cloudflare', + lastSyncAt: now, + } as any); + created++; + } + } + + await execution?.setMetric('created', created); + await execution?.setMetric('updated', updated); + await execution?.addLog(`Cloudflare sync done: ${created} created, ${updated} updated`, 'success'); + return { created, updated, totalZones: zones.length }; + } catch (error) { + await execution?.addLog(`Cloudflare sync error: ${error.message}`, 'error'); + throw error; + } + }, + }); + + taskManager.registerTask('cloudflare-domain-sync', cfDomainSync, { + description: 'Import and update domains from Cloudflare zones', + category: 'system', + schedule: '0 3 * * *', // Daily at 3 AM + enabled: true, + }); + // DNS Sync Task const dnsSync = new plugins.taskbuffer.Task({ name: 'dns-sync', @@ -79,8 +159,10 @@ export function createPredefinedTasks(taskManager: CloudlyTaskManager) { return; } - // Get all domains - const domains = await taskManager.cloudlyRef.domainManager.CDomain.getInstances({}); + // Get all domains (only activated ones are considered for renewal) + const domains = await taskManager.cloudlyRef.domainManager.CDomain.getInstances({ + 'data.activationState': 'activated', + } as any); await execution?.setMetric('totalDomains', domains.length); let renewedCount = 0; diff --git a/ts_interfaces/data/domain.ts b/ts_interfaces/data/domain.ts index 29dc426..0826d92 100644 --- a/ts_interfaces/data/domain.ts +++ b/ts_interfaces/data/domain.ts @@ -71,6 +71,14 @@ export interface IDomain { * SSL certificate status */ sslStatus?: 'active' | 'pending' | 'expired' | 'none'; + + /** + * Cloudly activation state controls whether we actively manage DNS/certificates + * - available: discovered/imported, not actively managed + * - activated: actively managed (DNS edits allowed, certs considered) + * - ignored: explicitly ignored from management/automation + */ + activationState?: 'available' | 'activated' | 'ignored'; /** * Last verification attempt timestamp @@ -91,6 +99,12 @@ export interface IDomain { * Cloudflare zone ID if managed by Cloudflare */ cloudflareZoneId?: string; + + /** + * Sync metadata + */ + syncSource?: 'cloudflare' | 'manual' | null; + lastSyncAt?: number; /** * Whether domain is managed externally @@ -107,4 +121,4 @@ export interface IDomain { */ updatedAt?: number; }; -} \ No newline at end of file +} diff --git a/ts_web/elements/views/dns/index.ts b/ts_web/elements/views/dns/index.ts index a11da3b..e59ea97 100644 --- a/ts_web/elements/views/dns/index.ts +++ b/ts_web/elements/views/dns/index.ts @@ -93,6 +93,12 @@ export class CloudlyViewDns extends DeesElement { { name: 'Create DNS Entry', action: async (modalArg: any) => { const form = modalArg.shadowRoot.querySelector('dees-form') as any; const formData = await form.gatherData(); + // Guard: only allow on activated domains + const domain = (this.data.domains || []).find((d: any) => d.id === formData.domainId); + if (!domain || (domain.data as any).activationState !== 'activated') { + plugins.deesCatalog.DeesToast.createAndShow({ message: 'Selected domain is not activated. Activate it first.', type: 'error' }); + return; + } await appstate.dataState.dispatchAction(appstate.createDnsEntryAction, { dnsEntryData: { type: formData.type, domainId: formData.domainId, zone: '', name: formData.name || '@', value: formData.value, ttl: parseInt(formData.ttl) || 3600, priority: formData.priority ? parseInt(formData.priority) : undefined, weight: formData.weight ? parseInt(formData.weight) : undefined, port: formData.port ? parseInt(formData.port) : undefined, active: formData.active, description: formData.description || undefined, }, }); await modalArg.destroy(); } }, @@ -123,6 +129,13 @@ export class CloudlyViewDns extends DeesElement { { name: 'Update DNS Entry', action: async (modalArg: any) => { const form = modalArg.shadowRoot.querySelector('dees-form') as any; const formData = await form.gatherData(); + if (formData.domainId) { + const domain = (this.data.domains || []).find((d: any) => d.id === formData.domainId); + if (!domain || (domain.data as any).activationState !== 'activated') { + plugins.deesCatalog.DeesToast.createAndShow({ message: 'Selected domain is not activated. Activate it first.', type: 'error' }); + return; + } + } await appstate.dataState.dispatchAction(appstate.updateDnsEntryAction, { dnsEntryId: dnsEntry.id, dnsEntryData: { ...dnsEntry.data, type: formData.type, domainId: formData.domainId, zone: '', name: formData.name || '@', value: formData.value, ttl: parseInt(formData.ttl) || 3600, priority: formData.priority ? parseInt(formData.priority) : undefined, weight: formData.weight ? parseInt(formData.weight) : undefined, port: formData.port ? parseInt(formData.port) : undefined, active: formData.active, description: formData.description || undefined, }, }); await modalArg.destroy(); } }, @@ -140,4 +153,3 @@ export class CloudlyViewDns extends DeesElement { } declare global { interface HTMLElementTagNameMap { 'cloudly-view-dns': CloudlyViewDns; } } - diff --git a/ts_web/elements/views/domains/index.ts b/ts_web/elements/views/domains/index.ts index 547b961..64929b8 100644 --- a/ts_web/elements/views/domains/index.ts +++ b/ts_web/elements/views/domains/index.ts @@ -37,6 +37,10 @@ export class CloudlyViewDomains extends DeesElement { .ssl-expired { color: #f44336; } .ssl-none { color: #9E9E9E; } .nameserver-list { font-size: 0.85em; color: #666; } + .activation-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.85em; font-weight: 500; } + .activation-available { background: #2b2b2b; color: #bbb; border: 1px solid #444; } + .activation-activated { background: #4CAF50; color: #fff; } + .activation-ignored { background: #9E9E9E; color: #fff; } .expiry-warning { color: #FF9800; font-weight: 500; } .expiry-critical { color: #f44336; font-weight: bold; } `, @@ -45,6 +49,7 @@ export class CloudlyViewDomains extends DeesElement { private getStatusBadge(status: string) { return html`${status.toUpperCase()}`; } private getVerificationBadge(status: string) { const displayText = status === 'not_required' ? 'Not Required' : status.replace('_', ' ').toUpperCase(); return html`${displayText}`; } private getSslBadge(sslStatus?: string) { if (!sslStatus) return html``; const icon = sslStatus === 'active' ? '🔒' : sslStatus === 'expired' ? '⚠️' : '🔓'; return html`${icon} ${sslStatus.toUpperCase()}`; } + private getActivationBadge(state?: 'available'|'activated'|'ignored') { const s = state || 'available'; return html`${s.toUpperCase()}`; } private formatDate(timestamp?: number) { if (!timestamp) return '—'; const date = new Date(timestamp); return date.toLocaleDateString(); } private getDaysUntilExpiry(expiresAt?: number) { if (!expiresAt) return null; const days = Math.floor((expiresAt - Date.now()) / (1000 * 60 * 60 * 24)); return days; } private getExpiryDisplay(expiresAt?: number) { const days = this.getDaysUntilExpiry(expiresAt); if (days === null) return '—'; if (days < 0) { return html`Expired ${Math.abs(days)} days ago`; } else if (days <= 30) { return html`Expires in ${days} days`; } else { return `${days} days`; } } @@ -63,6 +68,7 @@ export class CloudlyViewDomains extends DeesElement { Status: this.getStatusBadge(itemArg.data.status), Verification: this.getVerificationBadge(itemArg.data.verificationStatus), SSL: this.getSslBadge(itemArg.data.sslStatus), + Activation: this.getActivationBadge((itemArg.data as any).activationState), 'DNS Records': dnsCount, Registrar: itemArg.data.registrar?.name || '—', Expires: this.getExpiryDisplay(itemArg.data.expiresAt), @@ -71,6 +77,7 @@ export class CloudlyViewDomains extends DeesElement { }; }} .dataActions=${[ + { name: 'Sync from Cloudflare', iconName: 'cloud', type: ['header'], actionFunc: async () => { await appstate.dataState.dispatchAction(appstate.taskActions.triggerTask, { taskName: 'cloudflare-domain-sync' } as any); await appstate.dataState.dispatchAction(appstate.getAllDataAction, null); plugins.deesCatalog.DeesToast.createAndShow({ message: 'Triggered Cloudflare sync', type: 'success' }); } }, { name: 'Add Domain', iconName: 'plus', type: ['header', 'footer'], actionFunc: async () => { const modal = await plugins.deesCatalog.DeesModal.createAndShow({ heading: 'Add Domain', @@ -164,6 +171,9 @@ export class CloudlyViewDomains extends DeesElement { menuOptions: [ { name: 'Cancel', action: async (modalArg: any) => { await modalArg.destroy(); } }, { name: 'Delete', action: async (modalArg: any) => { await appstate.dataState.dispatchAction(appstate.deleteDomainAction, { domainId: domain.id, }); await modalArg.destroy(); } }, ], }); } }, + { name: 'Activate', iconName: 'check', type: ['contextmenu', 'inRow'], actionFunc: async (actionDataArg: any) => { const domain = actionDataArg.item as plugins.interfaces.data.IDomain; await appstate.dataState.dispatchAction(appstate.updateDomainAction, { domainId: domain.id, domainData: { ...(domain.data as any), activationState: 'activated' } as any }); } }, + { name: 'Deactivate', iconName: 'slash', type: ['contextmenu', 'inRow'], actionFunc: async (actionDataArg: any) => { const domain = actionDataArg.item as plugins.interfaces.data.IDomain; await appstate.dataState.dispatchAction(appstate.updateDomainAction, { domainId: domain.id, domainData: { ...(domain.data as any), activationState: 'available' } as any }); } }, + { name: 'Ignore', iconName: 'ban', type: ['contextmenu', 'inRow'], actionFunc: async (actionDataArg: any) => { const domain = actionDataArg.item as plugins.interfaces.data.IDomain; await appstate.dataState.dispatchAction(appstate.updateDomainAction, { domainId: domain.id, domainData: { ...(domain.data as any), activationState: 'ignored' } as any }); } }, ] as plugins.deesCatalog.ITableAction[]} > `; @@ -173,4 +183,3 @@ export class CloudlyViewDomains extends DeesElement { declare global { interface HTMLElementTagNameMap { 'cloudly-view-domains': CloudlyViewDomains; } } -