From 766191899c563a2ff6da0dcf12538d517b321cbf Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 9 Sep 2025 15:08:28 +0000 Subject: [PATCH] feat(dns): Implement DNS management functionality - Added DnsManager and DnsEntry classes to handle DNS entries. - Introduced new interfaces for DNS entry requests and data structures. - Updated Cloudly class to include DnsManager instance. - Enhanced app state to manage DNS entries and actions for creating, updating, and deleting DNS records. - Created UI components for DNS management, including forms for adding and editing DNS entries. - Updated overview and services views to reflect DNS entries. - Added validation and formatting methods for DNS entries. --- package.json | 2 +- pnpm-lock.yaml | 98 ++-- ts/classes.cloudly.ts | 6 + ts/manager.dns/classes.dnsentry.ts | 148 ++++++ ts/manager.dns/classes.dnsmanager.ts | 152 ++++++ ts/manager.domain/classes.domain.ts | 203 ++++++++ ts/manager.domain/classes.domainmanager.ts | 188 ++++++++ ts_interfaces/data/dns.ts | 81 ++++ ts_interfaces/data/domain.ts | 110 +++++ ts_interfaces/data/index.ts | 2 + ts_interfaces/requests/dns.ts | 93 ++++ ts_interfaces/requests/domain.ts | 99 ++++ ts_interfaces/requests/index.ts | 4 + ts_web/appstate.ts | 174 ++++++- ts_web/elements/cloudly-dashboard.ts | 6 + ts_web/elements/cloudly-view-dns.ts | 358 ++++++++++++-- ts_web/elements/cloudly-view-domains.ts | 529 +++++++++++++++++++++ ts_web/elements/cloudly-view-overview.ts | 6 +- ts_web/elements/cloudly-view-services.ts | 14 +- 19 files changed, 2174 insertions(+), 99 deletions(-) create mode 100644 ts/manager.dns/classes.dnsentry.ts create mode 100644 ts/manager.dns/classes.dnsmanager.ts create mode 100644 ts/manager.domain/classes.domain.ts create mode 100644 ts/manager.domain/classes.domainmanager.ts create mode 100644 ts_interfaces/data/dns.ts create mode 100644 ts_interfaces/data/domain.ts create mode 100644 ts_interfaces/requests/dns.ts create mode 100644 ts_interfaces/requests/domain.ts create mode 100644 ts_web/elements/cloudly-view-domains.ts diff --git a/package.json b/package.json index fc72c64..3fed139 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "@apiclient.xyz/docker": "^1.3.5", "@apiclient.xyz/hetznercloud": "^1.2.0", "@apiclient.xyz/slack": "^3.0.9", - "@design.estate/dees-catalog": "^1.11.2", + "@design.estate/dees-catalog": "^1.11.3", "@design.estate/dees-domtools": "^2.3.3", "@design.estate/dees-element": "^2.1.2", "@git.zone/tsrun": "^1.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 363d665..faa127b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,8 +33,8 @@ importers: specifier: ^3.0.9 version: 3.0.9 '@design.estate/dees-catalog': - specifier: ^1.11.2 - version: 1.11.2(@tiptap/pm@2.26.1) + specifier: ^1.11.3 + version: 1.11.3(@tiptap/pm@2.26.1) '@design.estate/dees-domtools': specifier: ^2.3.3 version: 2.3.3 @@ -476,8 +476,8 @@ packages: '@dabh/diagnostics@2.0.3': resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==} - '@design.estate/dees-catalog@1.11.2': - resolution: {integrity: sha512-gMK+wDKXDBPzfWmaJySotjjp5A9rwk2PQANQF8V6Q52xUfKKUv7gHj4eju+pN6qkUA5OUzdCDplUeUCrA8i37w==} + '@design.estate/dees-catalog@1.11.3': + resolution: {integrity: sha512-gXGi6PlaHY4+lXHo17p+R/L6/QaqtN/3JFzTUXPl4J0fVKqVrEp22+lf7uvgAhs4WpV1Vd/c9yoyQ6JmrNSj4g==} '@design.estate/dees-comms@1.0.27': resolution: {integrity: sha512-GvzTUwkV442LD60T08iqSoqvhA02Mou5lFvvqBPc4yBUiU7cZISqBx+76xvMgMIEI9Dx9JfTl4/2nW8MoVAanw==} @@ -1200,68 +1200,68 @@ packages: '@mongodb-js/saslprep@1.3.0': resolution: {integrity: sha512-zlayKCsIjYb7/IdfqxorK5+xUMyi4vOKcFy10wKJYc63NSdKI8mNME+uJqfatkPmOSMMUiojrL58IePKBm3gvQ==} - '@napi-rs/canvas-android-arm64@0.1.78': - resolution: {integrity: sha512-N1ikxztjrRmh8xxlG5kYm1RuNr8ZW1EINEDQsLhhuy7t0pWI/e7SH91uFVLZKCMDyjel1tyWV93b5fdCAi7ggw==} + '@napi-rs/canvas-android-arm64@0.1.79': + resolution: {integrity: sha512-ih6ZIztNDEXl7axvC4swOwLFrM9lOyJa9VAMq7xIBtEZhR/8IVDa0ZTup2fZEiTCmnjmXolzv7uDviHkOTEMKQ==} engines: {node: '>= 10'} cpu: [arm64] os: [android] - '@napi-rs/canvas-darwin-arm64@0.1.78': - resolution: {integrity: sha512-FA3aCU3G5yGc74BSmnLJTObnZRV+HW+JBTrsU+0WVVaNyVKlb5nMvYAQuieQlRVemsAA2ek2c6nYtHh6u6bwFw==} + '@napi-rs/canvas-darwin-arm64@0.1.79': + resolution: {integrity: sha512-REMz1Fac2VlOYJDg+JjmQWSJc459cCgVom6GvKwWkDqzSjvG9BSo72MDmQY3uhb7r49Xuz5gTFcLYTfNcm4MoA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@napi-rs/canvas-darwin-x64@0.1.78': - resolution: {integrity: sha512-xVij69o9t/frixCDEoyWoVDKgE3ksLGdmE2nvBWVGmoLu94MWUlv2y4Qzf5oozBmydG5Dcm4pRHFBM7YWa1i6g==} + '@napi-rs/canvas-darwin-x64@0.1.79': + resolution: {integrity: sha512-uQxLg6Bll7zv/ljp/YIeiUFWfV9C/ESv+2ioUh60hIAypuhtg6hhtWE/KnoW7G48wQls5VUStvEnJbnJ7bPKlA==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@napi-rs/canvas-linux-arm-gnueabihf@0.1.78': - resolution: {integrity: sha512-aSEXrLcIpBtXpOSnLhTg4jPsjJEnK7Je9KqUdAWjc7T8O4iYlxWxrXFIF8rV8J79h5jNdScgZpAUWYnEcutR3g==} + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.79': + resolution: {integrity: sha512-X37B//TVIipL/3RyvyfNlbQK2uyIaK3PJ2bH7ZeU+jpkaYprBsV15GCN/LHTYAi6R0F/c53zK3aSFNKkGHM/Og==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@napi-rs/canvas-linux-arm64-gnu@0.1.78': - resolution: {integrity: sha512-dlEPRX1hLGKaY3UtGa1dtkA1uGgFITn2mDnfI6YsLlYyLJQNqHx87D1YTACI4zFCUuLr/EzQDzuX+vnp9YveVg==} + '@napi-rs/canvas-linux-arm64-gnu@0.1.79': + resolution: {integrity: sha512-+T1fuau1heabE6zGXiqZBGPH5fTIQF+xEu/u4fuugxEiChRYlhnPjkw26MBi8ePg/jmzxLfJEij6LMJQ4AQa2A==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@napi-rs/canvas-linux-arm64-musl@0.1.78': - resolution: {integrity: sha512-TsCfjOPZtm5Q/NO1EZHR5pwDPSPjPEttvnv44GL32Zn1uvudssjTLbvaG1jHq81Qxm16GTXEiYLmx4jOLZQYlg==} + '@napi-rs/canvas-linux-arm64-musl@0.1.79': + resolution: {integrity: sha512-KsrsR3+6uXv70W/1/kY0yRK4/bbdJgA1Vuxw4KyfSc6mjl1DMoYXDAjpBT/5w7AXy6cGG44jm3upvvt/y/dPfg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@napi-rs/canvas-linux-riscv64-gnu@0.1.78': - resolution: {integrity: sha512-+cpTTb0GDshEow/5Fy8TpNyzaPsYb3clQIjgWRmzRcuteLU+CHEU/vpYvAcSo7JxHYPJd8fjSr+qqh+nI5AtmA==} + '@napi-rs/canvas-linux-riscv64-gnu@0.1.79': + resolution: {integrity: sha512-EXaENnSJD6au6z4aKN2PpU9eVNWUsRI2cApm8gCa0WSRMaiYXZsFkXQmhB+Vz2pXahOS8BN2Zd8S1IeML/LCtg==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - '@napi-rs/canvas-linux-x64-gnu@0.1.78': - resolution: {integrity: sha512-wxRcvKfvYBgtrO0Uy8OmwvjlnTcHpY45LLwkwVNIWHPqHAsyoTyG/JBSfJ0p5tWRzMOPDCDqdhpIO4LOgXjeyg==} + '@napi-rs/canvas-linux-x64-gnu@0.1.79': + resolution: {integrity: sha512-3xZhHlE9e3cd9D7Comy6/TTSs/8PUGXEXymIwYQrA1QxHojAlAOFlVai4rffzXd0bHylZu+/wD76LodvYqF1Yw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@napi-rs/canvas-linux-x64-musl@0.1.78': - resolution: {integrity: sha512-vQFOGwC9QDP0kXlhb2LU1QRw/humXgcbVp8mXlyBqzc/a0eijlLF9wzyarHC1EywpymtS63TAj8PHZnhTYN6hg==} + '@napi-rs/canvas-linux-x64-musl@0.1.79': + resolution: {integrity: sha512-4yv550uCjIEoTFgrpxYZK67nFlDMCQa3LAheM2QrO+B8w1p5w04usIQSCHqHe6aPWlbLQCIqfVcew6/7Q4KuHg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@napi-rs/canvas-win32-x64-msvc@0.1.78': - resolution: {integrity: sha512-/eKlTZBtGUgpRKalzOzRr6h7KVSuziESWXgBcBnXggZmimwIJWPJlEcbrx5Tcwj8rPuZiANXQOG9pPgy9Q4LTQ==} + '@napi-rs/canvas-win32-x64-msvc@0.1.79': + resolution: {integrity: sha512-sD5qP2njBRnhNlTNFJDdpeCN6aR3qVamLySTwhX3ec8sdfeT/chf/x2dw2UXoIGMoVaVk/y2ifwxBj/h2a2jug==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@napi-rs/canvas@0.1.78': - resolution: {integrity: sha512-YaBHJvT+T1DoP16puvWM6w46Lq3VhwKIJ8th5m1iEJyGh7mibk5dT7flBvMQ1EH1LYmMzXJ+OUhu+8wQ9I6u7g==} + '@napi-rs/canvas@0.1.79': + resolution: {integrity: sha512-0SkvRRjyxY35eniEsQsjPYUMWunKlAWvionJOzJJADZF5ZDf/sL+ncJbMTV5LUiHg1iHOvVjWcuDOx/GNXr/lA==} engines: {node: '>= 10'} '@napi-rs/wasm-runtime@1.0.3': @@ -6684,7 +6684,7 @@ snapshots: enabled: 2.0.0 kuler: 2.0.0 - '@design.estate/dees-catalog@1.11.2(@tiptap/pm@2.26.1)': + '@design.estate/dees-catalog@1.11.3(@tiptap/pm@2.26.1)': dependencies: '@design.estate/dees-domtools': 2.3.3 '@design.estate/dees-element': 2.1.2 @@ -7578,48 +7578,48 @@ snapshots: dependencies: sparse-bitfield: 3.0.3 - '@napi-rs/canvas-android-arm64@0.1.78': + '@napi-rs/canvas-android-arm64@0.1.79': optional: true - '@napi-rs/canvas-darwin-arm64@0.1.78': + '@napi-rs/canvas-darwin-arm64@0.1.79': optional: true - '@napi-rs/canvas-darwin-x64@0.1.78': + '@napi-rs/canvas-darwin-x64@0.1.79': optional: true - '@napi-rs/canvas-linux-arm-gnueabihf@0.1.78': + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.79': optional: true - '@napi-rs/canvas-linux-arm64-gnu@0.1.78': + '@napi-rs/canvas-linux-arm64-gnu@0.1.79': optional: true - '@napi-rs/canvas-linux-arm64-musl@0.1.78': + '@napi-rs/canvas-linux-arm64-musl@0.1.79': optional: true - '@napi-rs/canvas-linux-riscv64-gnu@0.1.78': + '@napi-rs/canvas-linux-riscv64-gnu@0.1.79': optional: true - '@napi-rs/canvas-linux-x64-gnu@0.1.78': + '@napi-rs/canvas-linux-x64-gnu@0.1.79': optional: true - '@napi-rs/canvas-linux-x64-musl@0.1.78': + '@napi-rs/canvas-linux-x64-musl@0.1.79': optional: true - '@napi-rs/canvas-win32-x64-msvc@0.1.78': + '@napi-rs/canvas-win32-x64-msvc@0.1.79': optional: true - '@napi-rs/canvas@0.1.78': + '@napi-rs/canvas@0.1.79': optionalDependencies: - '@napi-rs/canvas-android-arm64': 0.1.78 - '@napi-rs/canvas-darwin-arm64': 0.1.78 - '@napi-rs/canvas-darwin-x64': 0.1.78 - '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.78 - '@napi-rs/canvas-linux-arm64-gnu': 0.1.78 - '@napi-rs/canvas-linux-arm64-musl': 0.1.78 - '@napi-rs/canvas-linux-riscv64-gnu': 0.1.78 - '@napi-rs/canvas-linux-x64-gnu': 0.1.78 - '@napi-rs/canvas-linux-x64-musl': 0.1.78 - '@napi-rs/canvas-win32-x64-msvc': 0.1.78 + '@napi-rs/canvas-android-arm64': 0.1.79 + '@napi-rs/canvas-darwin-arm64': 0.1.79 + '@napi-rs/canvas-darwin-x64': 0.1.79 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.79 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.79 + '@napi-rs/canvas-linux-arm64-musl': 0.1.79 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.79 + '@napi-rs/canvas-linux-x64-gnu': 0.1.79 + '@napi-rs/canvas-linux-x64-musl': 0.1.79 + '@napi-rs/canvas-win32-x64-msvc': 0.1.79 optional: true '@napi-rs/wasm-runtime@1.0.3': @@ -12345,7 +12345,7 @@ snapshots: pdfjs-dist@4.10.38: optionalDependencies: - '@napi-rs/canvas': 0.1.78 + '@napi-rs/canvas': 0.1.79 peek-readable@4.1.0: {} diff --git a/ts/classes.cloudly.ts b/ts/classes.cloudly.ts index a9215f2..299bf02 100644 --- a/ts/classes.cloudly.ts +++ b/ts/classes.cloudly.ts @@ -25,6 +25,8 @@ import { ExternalRegistryManager } from './manager.externalregistry/index.js'; import { ImageManager } from './manager.image/classes.imagemanager.js'; import { ServiceManager } from './manager.service/classes.servicemanager.js'; import { DeploymentManager } from './manager.deployment/classes.deploymentmanager.js'; +import { DnsManager } from './manager.dns/classes.dnsmanager.js'; +import { DomainManager } from './manager.domain/classes.domainmanager.js'; import { logger } from './logger.js'; import { CloudlyAuthManager } from './manager.auth/classes.authmanager.js'; import { CloudlySettingsManager } from './manager.settings/classes.settingsmanager.js'; @@ -64,6 +66,8 @@ export class Cloudly { public imageManager: ImageManager; public serviceManager: ServiceManager; public deploymentManager: DeploymentManager; + public dnsManager: DnsManager; + public domainManager: DomainManager; public taskManager: CloudlyTaskmanager; public nodeManager: CloudlyNodeManager; public baremetalManager: CloudlyBaremetalManager; @@ -95,6 +99,8 @@ export class Cloudly { this.imageManager = new ImageManager(this); this.serviceManager = new ServiceManager(this); this.deploymentManager = new DeploymentManager(this); + this.dnsManager = new DnsManager(this); + this.domainManager = new DomainManager(this); this.taskManager = new CloudlyTaskmanager(this); this.secretManager = new CloudlySecretManager(this); this.nodeManager = new CloudlyNodeManager(this); diff --git a/ts/manager.dns/classes.dnsentry.ts b/ts/manager.dns/classes.dnsentry.ts new file mode 100644 index 0000000..1cee4b5 --- /dev/null +++ b/ts/manager.dns/classes.dnsentry.ts @@ -0,0 +1,148 @@ +import * as plugins from '../plugins.js'; +import { DnsManager } from './classes.dnsmanager.js'; + +export class DnsEntry extends plugins.smartdata.SmartDataDbDoc< + DnsEntry, + plugins.servezoneInterfaces.data.IDnsEntry, + DnsManager +> { + // STATIC + public static async getDnsEntryById(dnsEntryIdArg: string) { + const dnsEntry = await this.getInstance({ + id: dnsEntryIdArg, + }); + return dnsEntry; + } + + public static async getDnsEntries(filterArg?: { zone?: string }) { + const filter: any = {}; + if (filterArg?.zone) { + filter['data.zone'] = filterArg.zone; + } + const dnsEntries = await this.getInstances(filter); + return dnsEntries; + } + + public static async getDnsZones() { + const dnsEntries = await this.getInstances({}); + const zones = new Set(); + for (const entry of dnsEntries) { + if (entry.data.zone) { + zones.add(entry.data.zone); + } + } + return Array.from(zones).sort(); + } + + public static async createDnsEntry(dnsEntryDataArg: plugins.servezoneInterfaces.data.IDnsEntry['data']) { + const dnsEntry = new DnsEntry(); + dnsEntry.id = await DnsEntry.getNewId(); + dnsEntry.data = { + ...dnsEntryDataArg, + ttl: dnsEntryDataArg.ttl || 3600, // Default TTL: 1 hour + active: dnsEntryDataArg.active !== false, // Default to active + createdAt: Date.now(), + updatedAt: Date.now(), + }; + await dnsEntry.save(); + return dnsEntry; + } + + public static async updateDnsEntry( + dnsEntryIdArg: string, + dnsEntryDataArg: Partial + ) { + const dnsEntry = await this.getInstance({ + id: dnsEntryIdArg, + }); + if (!dnsEntry) { + throw new Error(`DNS entry with id ${dnsEntryIdArg} not found`); + } + Object.assign(dnsEntry.data, dnsEntryDataArg, { + updatedAt: Date.now(), + }); + await dnsEntry.save(); + return dnsEntry; + } + + public static async deleteDnsEntry(dnsEntryIdArg: string) { + const dnsEntry = await this.getInstance({ + id: dnsEntryIdArg, + }); + if (!dnsEntry) { + throw new Error(`DNS entry with id ${dnsEntryIdArg} not found`); + } + await dnsEntry.delete(); + return true; + } + + // INSTANCE + @plugins.smartdata.svDb() + public id: string; + + @plugins.smartdata.svDb() + public data: plugins.servezoneInterfaces.data.IDnsEntry['data']; + + /** + * Validates the DNS entry data + */ + public validateData(): boolean { + const { type, name, value, zone } = this.data; + + // Basic validation + if (!type || !name || !value || !zone) { + return false; + } + + // Type-specific validation + switch (type) { + case 'A': + // Validate IPv4 address + return /^(\d{1,3}\.){3}\d{1,3}$/.test(value); + case 'AAAA': + // Validate IPv6 address (simplified) + return /^([0-9a-fA-F]{0,4}:){7}[0-9a-fA-F]{0,4}$/.test(value); + case 'MX': + // MX records must have priority + return this.data.priority !== undefined && this.data.priority >= 0; + case 'SRV': + // SRV records must have priority, weight, and port + return ( + this.data.priority !== undefined && + this.data.weight !== undefined && + this.data.port !== undefined + ); + case 'CNAME': + case 'NS': + case 'PTR': + // These should point to valid domain names + return /^[a-zA-Z0-9.-]+$/.test(value); + case 'TXT': + case 'CAA': + case 'SOA': + // These can contain any text + return true; + default: + return false; + } + } + + /** + * Get a formatted string representation of the DNS entry + */ + public toFormattedString(): string { + const { type, name, value, ttl, priority } = this.data; + let result = `${name} ${ttl} IN ${type}`; + + if (priority !== undefined) { + result += ` ${priority}`; + } + + if (type === 'SRV' && this.data.weight !== undefined && this.data.port !== undefined) { + result += ` ${this.data.weight} ${this.data.port}`; + } + + result += ` ${value}`; + return result; + } +} \ No newline at end of file diff --git a/ts/manager.dns/classes.dnsmanager.ts b/ts/manager.dns/classes.dnsmanager.ts new file mode 100644 index 0000000..4bf248e --- /dev/null +++ b/ts/manager.dns/classes.dnsmanager.ts @@ -0,0 +1,152 @@ +import type { Cloudly } from '../classes.cloudly.js'; +import * as plugins from '../plugins.js'; +import { DnsEntry } from './classes.dnsentry.js'; + +export class DnsManager { + public typedrouter = new plugins.typedrequest.TypedRouter(); + public cloudlyRef: Cloudly; + + get db() { + return this.cloudlyRef.mongodbConnector.smartdataDb; + } + + public CDnsEntry = plugins.smartdata.setDefaultManagerForDoc(this, DnsEntry); + + constructor(cloudlyRef: Cloudly) { + this.cloudlyRef = cloudlyRef; + + this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter); + + // Get all DNS entries + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getDnsEntries', + async (reqArg) => { + await plugins.smartguard.passGuardsOrReject(reqArg, [ + this.cloudlyRef.authManager.validIdentityGuard, + ]); + + const dnsEntries = await this.CDnsEntry.getDnsEntries( + reqArg.zone ? { zone: reqArg.zone } : undefined + ); + + return { + dnsEntries: await Promise.all( + dnsEntries.map((entry) => entry.createSavableObject()) + ), + }; + } + ) + ); + + // Get DNS entry by ID + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getDnsEntryById', + async (reqArg) => { + await plugins.smartguard.passGuardsOrReject(reqArg, [ + this.cloudlyRef.authManager.validIdentityGuard, + ]); + + const dnsEntry = await this.CDnsEntry.getDnsEntryById(reqArg.dnsEntryId); + if (!dnsEntry) { + throw new Error(`DNS entry with id ${reqArg.dnsEntryId} not found`); + } + + return { + dnsEntry: await dnsEntry.createSavableObject(), + }; + } + ) + ); + + // Create DNS entry + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'createDnsEntry', + async (reqArg) => { + await plugins.smartguard.passGuardsOrReject(reqArg, [ + this.cloudlyRef.authManager.validIdentityGuard, + ]); + + const dnsEntry = await this.CDnsEntry.createDnsEntry(reqArg.dnsEntryData); + + return { + dnsEntry: await dnsEntry.createSavableObject(), + }; + } + ) + ); + + // Update DNS entry + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'updateDnsEntry', + async (reqArg) => { + await plugins.smartguard.passGuardsOrReject(reqArg, [ + this.cloudlyRef.authManager.validIdentityGuard, + ]); + + const dnsEntry = await this.CDnsEntry.updateDnsEntry( + reqArg.dnsEntryId, + reqArg.dnsEntryData + ); + + return { + dnsEntry: await dnsEntry.createSavableObject(), + }; + } + ) + ); + + // Delete DNS entry + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'deleteDnsEntry', + async (reqArg) => { + await plugins.smartguard.passGuardsOrReject(reqArg, [ + this.cloudlyRef.authManager.validIdentityGuard, + ]); + + const success = await this.CDnsEntry.deleteDnsEntry(reqArg.dnsEntryId); + + return { + success, + }; + } + ) + ); + + // Get DNS zones + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getDnsZones', + async (reqArg) => { + await plugins.smartguard.passGuardsOrReject(reqArg, [ + this.cloudlyRef.authManager.validIdentityGuard, + ]); + + const zones = await this.CDnsEntry.getDnsZones(); + + return { + zones, + }; + } + ) + ); + } + + /** + * Initialize the DNS manager + */ + public async init() { + console.log('DNS Manager initialized'); + } + + /** + * Stop the DNS manager + */ + 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 new file mode 100644 index 0000000..3cfd336 --- /dev/null +++ b/ts/manager.domain/classes.domain.ts @@ -0,0 +1,203 @@ +import * as plugins from '../plugins.js'; +import { DomainManager } from './classes.domainmanager.js'; + +export class Domain extends plugins.smartdata.SmartDataDbDoc< + Domain, + plugins.servezoneInterfaces.data.IDomain, + DomainManager +> { + // STATIC + public static async getDomainById(domainIdArg: string) { + const domain = await this.getInstance({ + id: domainIdArg, + }); + return domain; + } + + public static async getDomainByName(domainNameArg: string) { + const domain = await this.getInstance({ + 'data.name': domainNameArg, + }); + return domain; + } + + public static async getDomains() { + const domains = await this.getInstances({}); + return domains; + } + + public static async createDomain(domainDataArg: plugins.servezoneInterfaces.data.IDomain['data']) { + const domain = new Domain(); + domain.id = await Domain.getNewId(); + domain.data = { + ...domainDataArg, + status: domainDataArg.status || 'pending', + verificationStatus: domainDataArg.verificationStatus || 'pending', + nameservers: domainDataArg.nameservers || [], + autoRenew: domainDataArg.autoRenew !== false, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + await domain.save(); + return domain; + } + + public static async updateDomain( + domainIdArg: string, + domainDataArg: Partial + ) { + const domain = await this.getInstance({ + id: domainIdArg, + }); + if (!domain) { + throw new Error(`Domain with id ${domainIdArg} not found`); + } + Object.assign(domain.data, domainDataArg, { + updatedAt: Date.now(), + }); + await domain.save(); + return domain; + } + + public static async deleteDomain(domainIdArg: string) { + const domain = await this.getInstance({ + id: domainIdArg, + }); + if (!domain) { + throw new Error(`Domain with id ${domainIdArg} not found`); + } + + // Check if there are DNS entries for this domain + const dnsManager = domain.manager.cloudlyRef.dnsManager; + const dnsEntries = await dnsManager.CDnsEntry.getInstances({ + 'data.zone': domain.data.name, + }); + + if (dnsEntries.length > 0) { + console.log(`Warning: Deleting domain ${domain.data.name} with ${dnsEntries.length} DNS entries`); + // Optionally delete associated DNS entries + for (const dnsEntry of dnsEntries) { + await dnsEntry.delete(); + } + } + + await domain.delete(); + return true; + } + + // INSTANCE + @plugins.smartdata.svDb() + public id: string; + + @plugins.smartdata.svDb() + public data: plugins.servezoneInterfaces.data.IDomain['data']; + + /** + * Verify domain ownership + */ + public async verifyDomain(methodArg?: 'dns' | 'http' | 'email' | 'manual') { + const method = methodArg || this.data.verificationMethod || 'dns'; + + // Generate verification token if not exists + if (!this.data.verificationToken) { + this.data.verificationToken = plugins.smartunique.shortId(); + await this.save(); + } + + let verificationResult = { + success: false, + message: '', + details: {} as any, + }; + + switch (method) { + case 'dns': + // Check for TXT record with verification token + verificationResult = await this.verifyViaDns(); + break; + case 'http': + // Check for file at well-known URL + verificationResult = await this.verifyViaHttp(); + break; + case 'email': + // Send verification email + verificationResult = await this.verifyViaEmail(); + break; + case 'manual': + // Manual verification + verificationResult.success = true; + verificationResult.message = 'Manually verified'; + break; + } + + // Update verification status + if (verificationResult.success) { + this.data.verificationStatus = 'verified'; + this.data.lastVerificationAt = Date.now(); + this.data.verificationMethod = method; + } else { + this.data.verificationStatus = 'failed'; + this.data.lastVerificationAt = Date.now(); + } + + await this.save(); + return verificationResult; + } + + private async verifyViaDns(): Promise<{ success: boolean; message: string; details: any }> { + // TODO: Implement DNS verification + // Look for TXT record _cloudly-verify.{domain} with value {verificationToken} + return { + success: false, + message: 'DNS verification not yet implemented', + details: { + expectedRecord: `_cloudly-verify.${this.data.name}`, + expectedValue: this.data.verificationToken, + }, + }; + } + + private async verifyViaHttp(): Promise<{ success: boolean; message: string; details: any }> { + // TODO: Implement HTTP verification + // Check for file at http://{domain}/.well-known/cloudly-verify.txt + return { + success: false, + message: 'HTTP verification not yet implemented', + details: { + expectedUrl: `http://${this.data.name}/.well-known/cloudly-verify.txt`, + expectedContent: this.data.verificationToken, + }, + }; + } + + private async verifyViaEmail(): Promise<{ success: boolean; message: string; details: any }> { + // TODO: Implement email verification + return { + success: false, + message: 'Email verification not yet implemented', + details: {}, + }; + } + + /** + * Check if domain is expiring soon + */ + public isExpiringSoon(daysThreshold: number = 30): boolean { + if (!this.data.expiresAt) { + return false; + } + const daysUntilExpiry = (this.data.expiresAt - Date.now()) / (1000 * 60 * 60 * 24); + return daysUntilExpiry <= daysThreshold; + } + + /** + * Get all DNS entries for this domain + */ + public async getDnsEntries() { + const dnsManager = this.manager.cloudlyRef.dnsManager; + const dnsEntries = await dnsManager.CDnsEntry.getInstances({ + 'data.zone': this.data.name, + }); + return dnsEntries; + } +} \ No newline at end of file diff --git a/ts/manager.domain/classes.domainmanager.ts b/ts/manager.domain/classes.domainmanager.ts new file mode 100644 index 0000000..9c93e8a --- /dev/null +++ b/ts/manager.domain/classes.domainmanager.ts @@ -0,0 +1,188 @@ +import type { Cloudly } from '../classes.cloudly.js'; +import * as plugins from '../plugins.js'; +import { Domain } from './classes.domain.js'; + +export class DomainManager { + public typedrouter = new plugins.typedrequest.TypedRouter(); + public cloudlyRef: Cloudly; + + get db() { + return this.cloudlyRef.mongodbConnector.smartdataDb; + } + + public CDomain = plugins.smartdata.setDefaultManagerForDoc(this, Domain); + + constructor(cloudlyRef: Cloudly) { + this.cloudlyRef = cloudlyRef; + + this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter); + + // Get all domains + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getDomains', + async (reqArg) => { + await plugins.smartguard.passGuardsOrReject(reqArg, [ + this.cloudlyRef.authManager.validIdentityGuard, + ]); + + const domains = await this.CDomain.getDomains(); + + return { + domains: await Promise.all( + domains.map((domain) => domain.createSavableObject()) + ), + }; + } + ) + ); + + // Get domain by ID + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getDomainById', + async (reqArg) => { + await plugins.smartguard.passGuardsOrReject(reqArg, [ + this.cloudlyRef.authManager.validIdentityGuard, + ]); + + const domain = await this.CDomain.getDomainById(reqArg.domainId); + if (!domain) { + throw new Error(`Domain with id ${reqArg.domainId} not found`); + } + + return { + domain: await domain.createSavableObject(), + }; + } + ) + ); + + // Create domain + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'createDomain', + async (reqArg) => { + await plugins.smartguard.passGuardsOrReject(reqArg, [ + this.cloudlyRef.authManager.validIdentityGuard, + ]); + + // Check if domain already exists + const existingDomain = await this.CDomain.getDomainByName(reqArg.domainData.name); + if (existingDomain) { + throw new Error(`Domain ${reqArg.domainData.name} already exists`); + } + + const domain = await this.CDomain.createDomain(reqArg.domainData); + + return { + domain: await domain.createSavableObject(), + }; + } + ) + ); + + // Update domain + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'updateDomain', + async (reqArg) => { + await plugins.smartguard.passGuardsOrReject(reqArg, [ + this.cloudlyRef.authManager.validIdentityGuard, + ]); + + const domain = await this.CDomain.updateDomain( + reqArg.domainId, + reqArg.domainData + ); + + return { + domain: await domain.createSavableObject(), + }; + } + ) + ); + + // Delete domain + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'deleteDomain', + async (reqArg) => { + await plugins.smartguard.passGuardsOrReject(reqArg, [ + this.cloudlyRef.authManager.validIdentityGuard, + ]); + + const success = await this.CDomain.deleteDomain(reqArg.domainId); + + return { + success, + }; + } + ) + ); + + // Verify domain + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'verifyDomain', + async (reqArg) => { + await plugins.smartguard.passGuardsOrReject(reqArg, [ + this.cloudlyRef.authManager.validIdentityGuard, + ]); + + const domain = await this.CDomain.getDomainById(reqArg.domainId); + if (!domain) { + throw new Error(`Domain with id ${reqArg.domainId} not found`); + } + + const verificationResult = await domain.verifyDomain(reqArg.verificationMethod); + + return { + domain: await domain.createSavableObject(), + verificationResult, + }; + } + ) + ); + } + + /** + * Initialize the domain manager + */ + public async init() { + console.log('Domain Manager initialized'); + } + + /** + * Stop the domain manager + */ + public async stop() { + console.log('Domain Manager stopped'); + } + + /** + * Get all active domains + */ + public async getActiveDomains() { + const domains = await this.CDomain.getInstances({ + 'data.status': 'active', + }); + return domains; + } + + /** + * Get domains that are expiring soon + */ + public async getExpiringDomains(daysThreshold: number = 30) { + const domains = await this.CDomain.getDomains(); + return domains.filter(domain => domain.isExpiringSoon(daysThreshold)); + } + + /** + * Check if a domain name is available (not in our system) + */ + public async isDomainAvailable(domainName: string): Promise { + const existingDomain = await this.CDomain.getDomainByName(domainName); + return !existingDomain; + } +} \ No newline at end of file diff --git a/ts_interfaces/data/dns.ts b/ts_interfaces/data/dns.ts new file mode 100644 index 0000000..8b41b7c --- /dev/null +++ b/ts_interfaces/data/dns.ts @@ -0,0 +1,81 @@ +export type TDnsRecordType = 'A' | 'AAAA' | 'CNAME' | 'MX' | 'TXT' | 'NS' | 'SOA' | 'SRV' | 'CAA' | 'PTR'; + +export interface IDnsEntry { + id: string; + data: { + /** + * The DNS record type + */ + type: TDnsRecordType; + + /** + * The DNS record name (e.g., www, @, mail) + * @ represents the root domain + */ + name: string; + + /** + * The value of the DNS record + * - For A/AAAA: IP address + * - For CNAME: Target domain + * - For MX: Mail server hostname + * - For TXT: Text value + * - For NS: Nameserver hostname + * - For SRV: Target hostname + * - For CAA: CAA record value + * - For PTR: Domain name + */ + value: string; + + /** + * Time to live in seconds + * Default: 3600 (1 hour) + */ + ttl: number; + + /** + * Priority (used for MX and SRV records) + * Lower values have higher priority + */ + priority?: number; + + /** + * The DNS zone this entry belongs to + * e.g., example.com + * @deprecated Use domainId instead + */ + zone: string; + + /** + * The domain ID this DNS entry belongs to + * Links to the Domain entity + */ + domainId?: string; + + /** + * Additional fields for SRV records + */ + weight?: number; + port?: number; + + /** + * Whether this DNS entry is active + */ + active: boolean; + + /** + * Optional description for documentation + */ + description?: string; + + /** + * Timestamp when the entry was created + */ + createdAt?: number; + + /** + * Timestamp when the entry was last updated + */ + updatedAt?: number; + }; +} \ No newline at end of file diff --git a/ts_interfaces/data/domain.ts b/ts_interfaces/data/domain.ts new file mode 100644 index 0000000..29dc426 --- /dev/null +++ b/ts_interfaces/data/domain.ts @@ -0,0 +1,110 @@ +export type TDomainStatus = 'active' | 'pending' | 'expired' | 'suspended' | 'transferred'; +export type TDomainVerificationStatus = 'verified' | 'pending' | 'failed' | 'not_required'; + +export interface IDomain { + id: string; + data: { + /** + * The domain name (e.g., example.com) + */ + name: string; + + /** + * Description or notes about the domain + */ + description?: string; + + /** + * Current status of the domain + */ + status: TDomainStatus; + + /** + * Domain verification status + */ + verificationStatus: TDomainVerificationStatus; + + /** + * Nameservers for the domain + */ + nameservers: string[]; + + /** + * Domain registrar information + */ + registrar?: { + name: string; + url?: string; + }; + + /** + * Domain registration date (timestamp) + */ + registeredAt?: number; + + /** + * Domain expiration date (timestamp) + */ + expiresAt?: number; + + /** + * Whether auto-renewal is enabled + */ + autoRenew: boolean; + + /** + * DNSSEC enabled + */ + dnssecEnabled?: boolean; + + /** + * Tags for categorization + */ + tags?: string[]; + + /** + * Whether this domain is primary for the organization + */ + isPrimary?: boolean; + + /** + * SSL certificate status + */ + sslStatus?: 'active' | 'pending' | 'expired' | 'none'; + + /** + * Last verification attempt timestamp + */ + lastVerificationAt?: number; + + /** + * Verification method used + */ + verificationMethod?: 'dns' | 'http' | 'email' | 'manual'; + + /** + * Verification token (for DNS/HTTP verification) + */ + verificationToken?: string; + + /** + * Cloudflare zone ID if managed by Cloudflare + */ + cloudflareZoneId?: string; + + /** + * Whether domain is managed externally + */ + isExternal?: boolean; + + /** + * Creation timestamp + */ + createdAt?: number; + + /** + * Last update timestamp + */ + updatedAt?: number; + }; +} \ No newline at end of file diff --git a/ts_interfaces/data/index.ts b/ts_interfaces/data/index.ts index 0df2068..40ba863 100644 --- a/ts_interfaces/data/index.ts +++ b/ts_interfaces/data/index.ts @@ -2,7 +2,9 @@ export * from './cloudlyconfig.js'; export * from './cluster.js'; export * from './config.js'; export * from './deployment.js'; +export * from './dns.js'; export * from './docker.js'; +export * from './domain.js'; export * from './event.js'; export * from './externalregistry.js'; export * from './image.js'; diff --git a/ts_interfaces/requests/dns.ts b/ts_interfaces/requests/dns.ts new file mode 100644 index 0000000..d114176 --- /dev/null +++ b/ts_interfaces/requests/dns.ts @@ -0,0 +1,93 @@ +import * as plugins from '../plugins.js'; +import type { IDnsEntry } from '../data/dns.js'; +import type { IIdentity } from '../data/user.js'; + +export interface IRequest_Any_Cloudly_GetDnsEntries +extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IRequest_Any_Cloudly_GetDnsEntries +> { + method: 'getDnsEntries'; + request: { + identity: IIdentity; + zone?: string; // Optional filter by zone + }; + response: { + dnsEntries: IDnsEntry[]; + }; +} + +export interface IRequest_Any_Cloudly_GetDnsEntryById +extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IRequest_Any_Cloudly_GetDnsEntryById +> { + method: 'getDnsEntryById'; + request: { + identity: IIdentity; + dnsEntryId: string; + }; + response: { + dnsEntry: IDnsEntry; + }; +} + +export interface IRequest_Any_Cloudly_CreateDnsEntry +extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IRequest_Any_Cloudly_CreateDnsEntry +> { + method: 'createDnsEntry'; + request: { + identity: IIdentity; + dnsEntryData: IDnsEntry['data']; + }; + response: { + dnsEntry: IDnsEntry; + }; +} + +export interface IRequest_Any_Cloudly_UpdateDnsEntry +extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IRequest_Any_Cloudly_UpdateDnsEntry +> { + method: 'updateDnsEntry'; + request: { + identity: IIdentity; + dnsEntryId: string; + dnsEntryData: IDnsEntry['data']; + }; + response: { + dnsEntry: IDnsEntry; + }; +} + +export interface IRequest_Any_Cloudly_DeleteDnsEntry +extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IRequest_Any_Cloudly_DeleteDnsEntry +> { + method: 'deleteDnsEntry'; + request: { + identity: IIdentity; + dnsEntryId: string; + }; + response: { + success: boolean; + }; +} + +export interface IRequest_Any_Cloudly_GetDnsZones +extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IRequest_Any_Cloudly_GetDnsZones +> { + method: 'getDnsZones'; + request: { + identity: IIdentity; + }; + response: { + zones: string[]; + }; +} \ No newline at end of file diff --git a/ts_interfaces/requests/domain.ts b/ts_interfaces/requests/domain.ts new file mode 100644 index 0000000..66362eb --- /dev/null +++ b/ts_interfaces/requests/domain.ts @@ -0,0 +1,99 @@ +import * as plugins from '../plugins.js'; +import type { IDomain } from '../data/domain.js'; +import type { IIdentity } from '../data/user.js'; + +export interface IRequest_Any_Cloudly_GetDomains +extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IRequest_Any_Cloudly_GetDomains +> { + method: 'getDomains'; + request: { + identity: IIdentity; + }; + response: { + domains: IDomain[]; + }; +} + +export interface IRequest_Any_Cloudly_GetDomainById +extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IRequest_Any_Cloudly_GetDomainById +> { + method: 'getDomainById'; + request: { + identity: IIdentity; + domainId: string; + }; + response: { + domain: IDomain; + }; +} + +export interface IRequest_Any_Cloudly_CreateDomain +extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IRequest_Any_Cloudly_CreateDomain +> { + method: 'createDomain'; + request: { + identity: IIdentity; + domainData: IDomain['data']; + }; + response: { + domain: IDomain; + }; +} + +export interface IRequest_Any_Cloudly_UpdateDomain +extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IRequest_Any_Cloudly_UpdateDomain +> { + method: 'updateDomain'; + request: { + identity: IIdentity; + domainId: string; + domainData: IDomain['data']; + }; + response: { + domain: IDomain; + }; +} + +export interface IRequest_Any_Cloudly_DeleteDomain +extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IRequest_Any_Cloudly_DeleteDomain +> { + method: 'deleteDomain'; + request: { + identity: IIdentity; + domainId: string; + }; + response: { + success: boolean; + }; +} + +export interface IRequest_Any_Cloudly_VerifyDomain +extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IRequest_Any_Cloudly_VerifyDomain +> { + method: 'verifyDomain'; + request: { + identity: IIdentity; + domainId: string; + verificationMethod?: 'dns' | 'http' | 'email' | 'manual'; + }; + response: { + domain: IDomain; + verificationResult: { + success: boolean; + message?: string; + details?: any; + }; + }; +} \ No newline at end of file diff --git a/ts_interfaces/requests/index.ts b/ts_interfaces/requests/index.ts index 34c175f..93b5a93 100644 --- a/ts_interfaces/requests/index.ts +++ b/ts_interfaces/requests/index.ts @@ -6,6 +6,8 @@ import * as certificateRequests from './certificate.js'; import * as clusterRequests from './cluster.js'; import * as configRequests from './config.js'; import * as deploymentRequests from './deployment.js'; +import * as dnsRequests from './dns.js'; +import * as domainRequests from './domain.js'; import * as externalRegistryRequests from './externalregistry.js'; import * as identityRequests from './identity.js'; import * as imageRequests from './image.js'; @@ -29,6 +31,8 @@ export { clusterRequests as cluster, configRequests as config, deploymentRequests as deployment, + dnsRequests as dns, + domainRequests as domain, externalRegistryRequests as externalRegistry, identityRequests as identity, imageRequests as image, diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index cf3b315..749fc27 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -50,7 +50,8 @@ export interface IDataState { images?: any[]; services?: plugins.interfaces.data.IService[]; deployments?: plugins.interfaces.data.IDeployment[]; - dns?: any[]; + domains?: plugins.interfaces.data.IDomain[]; + dnsEntries?: plugins.interfaces.data.IDnsEntry[]; mails?: any[]; logs?: any[]; s3?: any[]; @@ -66,7 +67,8 @@ export const dataState = await appstate.getStatePart( images: [], services: [], deployments: [], - dns: [], + domains: [], + dnsEntries: [], mails: [], logs: [], s3: [], @@ -180,6 +182,50 @@ export const getAllDataAction = dataState.createAction(async (statePartArg) => { }; } + // Domains + const trGetDomains = + new domtools.plugins.typedrequest.TypedRequest( + '/typedrequest', + 'getDomains' + ); + try { + const responseDomains = await trGetDomains.fire({ + identity: loginStatePart.getState().identity, + }); + currentState = { + ...currentState, + domains: responseDomains?.domains || [], + }; + } catch (error) { + console.error('Failed to fetch domains:', error); + currentState = { + ...currentState, + domains: [], + }; + } + + // DNS Entries + const trGetDnsEntries = + new domtools.plugins.typedrequest.TypedRequest( + '/typedrequest', + 'getDnsEntries' + ); + try { + const responseDnsEntries = await trGetDnsEntries.fire({ + identity: loginStatePart.getState().identity, + }); + currentState = { + ...currentState, + dnsEntries: responseDnsEntries?.dnsEntries || [], + }; + } catch (error) { + console.error('Failed to fetch DNS entries:', error); + currentState = { + ...currentState, + dnsEntries: [], + }; + } + return currentState; }); @@ -389,6 +435,130 @@ export const deleteDeploymentAction = dataState.createAction( } ); +// DNS Actions +export const createDnsEntryAction = dataState.createAction( + async (statePartArg, payloadArg: { dnsEntryData: plugins.interfaces.data.IDnsEntry['data'] }) => { + let currentState = statePartArg.getState(); + const trCreateDnsEntry = + new domtools.plugins.typedrequest.TypedRequest( + '/typedrequest', + 'createDnsEntry' + ); + const response = await trCreateDnsEntry.fire({ + identity: loginStatePart.getState().identity, + dnsEntryData: payloadArg.dnsEntryData, + }); + currentState = await dataState.dispatchAction(getAllDataAction, null); + return currentState; + } +); + +export const updateDnsEntryAction = dataState.createAction( + async (statePartArg, payloadArg: { dnsEntryId: string; dnsEntryData: plugins.interfaces.data.IDnsEntry['data'] }) => { + let currentState = statePartArg.getState(); + const trUpdateDnsEntry = + new domtools.plugins.typedrequest.TypedRequest( + '/typedrequest', + 'updateDnsEntry' + ); + const response = await trUpdateDnsEntry.fire({ + identity: loginStatePart.getState().identity, + dnsEntryId: payloadArg.dnsEntryId, + dnsEntryData: payloadArg.dnsEntryData, + }); + currentState = await dataState.dispatchAction(getAllDataAction, null); + return currentState; + } +); + +export const deleteDnsEntryAction = dataState.createAction( + async (statePartArg, payloadArg: { dnsEntryId: string }) => { + let currentState = statePartArg.getState(); + const trDeleteDnsEntry = + new domtools.plugins.typedrequest.TypedRequest( + '/typedrequest', + 'deleteDnsEntry' + ); + const response = await trDeleteDnsEntry.fire({ + identity: loginStatePart.getState().identity, + dnsEntryId: payloadArg.dnsEntryId, + }); + currentState = await dataState.dispatchAction(getAllDataAction, null); + return currentState; + } +); + +// Domain Actions +export const createDomainAction = dataState.createAction( + async (statePartArg, payloadArg: { domainData: plugins.interfaces.data.IDomain['data'] }) => { + let currentState = statePartArg.getState(); + const trCreateDomain = + new domtools.plugins.typedrequest.TypedRequest( + '/typedrequest', + 'createDomain' + ); + const response = await trCreateDomain.fire({ + identity: loginStatePart.getState().identity, + domainData: payloadArg.domainData, + }); + currentState = await dataState.dispatchAction(getAllDataAction, null); + return currentState; + } +); + +export const updateDomainAction = dataState.createAction( + async (statePartArg, payloadArg: { domainId: string; domainData: plugins.interfaces.data.IDomain['data'] }) => { + let currentState = statePartArg.getState(); + const trUpdateDomain = + new domtools.plugins.typedrequest.TypedRequest( + '/typedrequest', + 'updateDomain' + ); + const response = await trUpdateDomain.fire({ + identity: loginStatePart.getState().identity, + domainId: payloadArg.domainId, + domainData: payloadArg.domainData, + }); + currentState = await dataState.dispatchAction(getAllDataAction, null); + return currentState; + } +); + +export const deleteDomainAction = dataState.createAction( + async (statePartArg, payloadArg: { domainId: string }) => { + let currentState = statePartArg.getState(); + const trDeleteDomain = + new domtools.plugins.typedrequest.TypedRequest( + '/typedrequest', + 'deleteDomain' + ); + const response = await trDeleteDomain.fire({ + identity: loginStatePart.getState().identity, + domainId: payloadArg.domainId, + }); + currentState = await dataState.dispatchAction(getAllDataAction, null); + return currentState; + } +); + +export const verifyDomainAction = dataState.createAction( + async (statePartArg, payloadArg: { domainId: string; verificationMethod?: 'dns' | 'http' | 'email' | 'manual' }) => { + let currentState = statePartArg.getState(); + const trVerifyDomain = + new domtools.plugins.typedrequest.TypedRequest( + '/typedrequest', + 'verifyDomain' + ); + const response = await trVerifyDomain.fire({ + identity: loginStatePart.getState().identity, + domainId: payloadArg.domainId, + verificationMethod: payloadArg.verificationMethod, + }); + currentState = await dataState.dispatchAction(getAllDataAction, null); + return currentState; + } +); + // cluster export const addClusterAction = dataState.createAction( async ( diff --git a/ts_web/elements/cloudly-dashboard.ts b/ts_web/elements/cloudly-dashboard.ts index 76fcb16..89af155 100644 --- a/ts_web/elements/cloudly-dashboard.ts +++ b/ts_web/elements/cloudly-dashboard.ts @@ -16,6 +16,7 @@ import { CloudlyViewClusters } from './cloudly-view-clusters.js'; import { CloudlyViewDbs } from './cloudly-view-dbs.js'; import { CloudlyViewDeployments } from './cloudly-view-deployments.js'; import { CloudlyViewDns } from './cloudly-view-dns.js'; +import { CloudlyViewDomains } from './cloudly-view-domains.js'; import { CloudlyViewImages } from './cloudly-view-images.js'; import { CloudlyViewLogs } from './cloudly-view-logs.js'; import { CloudlyViewMails } from './cloudly-view-mails.js'; @@ -125,6 +126,11 @@ export class CloudlyDashboard extends DeesElement { iconName: 'lucide:Rocket', element: CloudlyViewDeployments, }, + { + name: 'Domains', + iconName: 'lucide:Globe2', + element: CloudlyViewDomains, + }, { name: 'DNS', iconName: 'lucide:Globe', diff --git a/ts_web/elements/cloudly-view-dns.ts b/ts_web/elements/cloudly-view-dns.ts index 9691d7b..6b570ce 100644 --- a/ts_web/elements/cloudly-view-dns.ts +++ b/ts_web/elements/cloudly-view-dns.ts @@ -18,65 +18,191 @@ export class CloudlyViewDns extends DeesElement { private data: appstate.IDataState = { secretGroups: [], secretBundles: [], + dnsEntries: [], }; constructor() { super(); - const subecription = appstate.dataState + const subscription = appstate.dataState .select((stateArg) => stateArg) .subscribe((dataArg) => { this.data = dataArg; }); - this.rxSubscriptions.push(subecription); + this.rxSubscriptions.push(subscription); } public static styles = [ cssManager.defaultStyles, shared.viewHostCss, - css` + css` + .dns-type-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.85em; + font-weight: 500; + color: white; + } + .type-A { background: #4CAF50; } + .type-AAAA { background: #45a049; } + .type-CNAME { background: #2196F3; } + .type-MX { background: #FF9800; } + .type-TXT { background: #9C27B0; } + .type-NS { background: #795548; } + .type-SOA { background: #607D8B; } + .type-SRV { background: #E91E63; } + .type-CAA { background: #00BCD4; } + .type-PTR { background: #673AB7; } + + .status-active { + color: #4CAF50; + } + .status-inactive { + color: #f44336; + } `, ]; + private getRecordTypeBadge(type: string) { + return html`${type}`; + } + + private getStatusBadge(active: boolean) { + return html` + ${active ? '✓ Active' : '✗ Inactive'} + `; + } + public render() { return html` - DNS + DNS Management { + .heading1=${'DNS Entries'} + .heading2=${'Manage DNS records for your domains'} + .data=${this.data.dnsEntries || []} + .displayFunction=${(itemArg: plugins.interfaces.data.IDnsEntry) => { return { - id: itemArg.id, - serverAmount: itemArg.data.servers.length, + Type: this.getRecordTypeBadge(itemArg.data.type), + Name: itemArg.data.name === '@' ? '' : itemArg.data.name, + Value: itemArg.data.value, + TTL: `${itemArg.data.ttl}s`, + Priority: itemArg.data.priority || '-', + Zone: itemArg.data.zone, + Status: this.getStatusBadge(itemArg.data.active), + Description: itemArg.data.description || '-', }; }} .dataActions=${[ { - name: 'add configBundle', + name: 'Add DNS Entry', iconName: 'plus', type: ['header', 'footer'], actionFunc: async (dataActionArg) => { const modal = await plugins.deesCatalog.DeesModal.createAndShow({ - heading: 'Add ConfigBundle', + heading: 'Add DNS Entry', content: html` - - - + + + + + + + + + + + + + + + + + + + + `, menuOptions: [ - { name: 'create', action: async (modalArg) => {} }, { - name: 'cancel', + name: 'Create DNS Entry', + action: async (modalArg) => { + const form = modalArg.shadowRoot.querySelector('dees-form') as any; + const formData = await form.gatherData(); + + await appstate.dataState.dispatchAction(appstate.createDnsEntryAction, { + dnsEntryData: { + type: formData.type, + zone: formData.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(); + }, + }, + { + name: 'Cancel', action: async (modalArg) => { modalArg.destroy(); }, @@ -86,34 +212,192 @@ export class CloudlyViewDns extends DeesElement { }, }, { - name: 'delete', + name: 'Edit', + iconName: 'edit', + type: ['contextmenu', 'inRow'], + actionFunc: async (actionDataArg) => { + const dnsEntry = actionDataArg.item as plugins.interfaces.data.IDnsEntry; + const modal = await plugins.deesCatalog.DeesModal.createAndShow({ + heading: `Edit DNS Entry`, + content: html` + + + + + + + + + + + + + + + + + + + + + + + `, + menuOptions: [ + { + name: 'Update DNS Entry', + action: async (modalArg) => { + const form = modalArg.shadowRoot.querySelector('dees-form') as any; + const formData = await form.gatherData(); + + await appstate.dataState.dispatchAction(appstate.updateDnsEntryAction, { + dnsEntryId: dnsEntry.id, + dnsEntryData: { + ...dnsEntry.data, + type: formData.type, + zone: formData.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(); + }, + }, + { + name: 'Cancel', + action: async (modalArg) => { + modalArg.destroy(); + }, + }, + ], + }); + }, + }, + { + name: 'Duplicate', + iconName: 'copy', + type: ['contextmenu', 'inRow'], + actionFunc: async (actionDataArg) => { + const dnsEntry = actionDataArg.item as plugins.interfaces.data.IDnsEntry; + await appstate.dataState.dispatchAction(appstate.createDnsEntryAction, { + dnsEntryData: { + ...dnsEntry.data, + description: `Copy of ${dnsEntry.data.description || dnsEntry.data.name}`, + }, + }); + }, + }, + { + name: 'Toggle Active', + iconName: 'power', + type: ['contextmenu', 'inRow'], + actionFunc: async (actionDataArg) => { + const dnsEntry = actionDataArg.item as plugins.interfaces.data.IDnsEntry; + await appstate.dataState.dispatchAction(appstate.updateDnsEntryAction, { + dnsEntryId: dnsEntry.id, + dnsEntryData: { + ...dnsEntry.data, + active: !dnsEntry.data.active, + }, + }); + }, + }, + { + name: 'Delete', iconName: 'trash', type: ['contextmenu', 'inRow'], actionFunc: async (actionDataArg) => { + const dnsEntry = actionDataArg.item as plugins.interfaces.data.IDnsEntry; plugins.deesCatalog.DeesModal.createAndShow({ - heading: `Delete ConfigBundle ${actionDataArg.item.id}`, + heading: `Delete DNS Entry`, content: html`
- Do you really want to delete the ConfigBundle? + Are you sure you want to delete this DNS entry?
-
- ${actionDataArg.item.id} +
+
+ ${dnsEntry.data.type} - ${dnsEntry.data.name}.${dnsEntry.data.zone} +
+
+ ${dnsEntry.data.value} +
+ ${dnsEntry.data.description ? html` +
+ ${dnsEntry.data.description} +
+ ` : ''}
`, menuOptions: [ { - name: 'cancel', + name: 'Cancel', action: async (modalArg) => { await modalArg.destroy(); }, }, { - name: 'delete', + name: 'Delete', action: async (modalArg) => { - appstate.dataState.dispatchAction(appstate.deleteSecretBundleAction, { - configBundleId: actionDataArg.item.id, + await appstate.dataState.dispatchAction(appstate.deleteDnsEntryAction, { + dnsEntryId: dnsEntry.id, }); await modalArg.destroy(); }, @@ -126,4 +410,4 @@ export class CloudlyViewDns extends DeesElement { > `; } -} +} \ No newline at end of file diff --git a/ts_web/elements/cloudly-view-domains.ts b/ts_web/elements/cloudly-view-domains.ts new file mode 100644 index 0000000..ee0629e --- /dev/null +++ b/ts_web/elements/cloudly-view-domains.ts @@ -0,0 +1,529 @@ +import * as plugins from '../plugins.js'; +import * as shared from '../elements/shared/index.js'; + +import { + DeesElement, + customElement, + html, + state, + css, + cssManager, +} from '@design.estate/dees-element'; + +import * as appstate from '../appstate.js'; + +@customElement('cloudly-view-domains') +export class CloudlyViewDomains extends DeesElement { + @state() + private data: appstate.IDataState = { + secretGroups: [], + secretBundles: [], + domains: [], + dnsEntries: [], + }; + + constructor() { + super(); + const subscription = appstate.dataState + .select((stateArg) => stateArg) + .subscribe((dataArg) => { + this.data = dataArg; + }); + this.rxSubscriptions.push(subscription); + } + + public static styles = [ + cssManager.defaultStyles, + shared.viewHostCss, + css` + .status-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.85em; + font-weight: 500; + color: white; + } + .status-active { background: #4CAF50; } + .status-pending { background: #FF9800; } + .status-expired { background: #f44336; } + .status-suspended { background: #9E9E9E; } + .status-transferred { background: #607D8B; } + + .verification-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.85em; + font-weight: 500; + } + .verification-verified { background: #4CAF50; color: white; } + .verification-pending { background: #FF9800; color: white; } + .verification-failed { background: #f44336; color: white; } + .verification-not_required { background: #E0E0E0; color: #333; } + + .ssl-badge { + display: inline-block; + padding: 2px 6px; + border-radius: 3px; + font-size: 0.8em; + } + .ssl-active { color: #4CAF50; } + .ssl-pending { color: #FF9800; } + .ssl-expired { color: #f44336; } + .ssl-none { color: #9E9E9E; } + + .nameserver-list { + font-size: 0.85em; + color: #666; + } + + .expiry-warning { + color: #FF9800; + font-weight: 500; + } + + .expiry-critical { + color: #f44336; + font-weight: bold; + } + `, + ]; + + 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 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`; + } + } + + public render() { + return html` + Domain Management + { + const dnsCount = this.data.dnsEntries?.filter(dns => dns.data.zone === itemArg.data.name).length || 0; + return { + Domain: html` +
+
${itemArg.data.name}
+ ${itemArg.data.description ? html`
${itemArg.data.description}
` : ''} +
+ `, + Status: this.getStatusBadge(itemArg.data.status), + Verification: this.getVerificationBadge(itemArg.data.verificationStatus), + SSL: this.getSslBadge(itemArg.data.sslStatus), + 'DNS Records': dnsCount, + Registrar: itemArg.data.registrar?.name || '—', + Expires: this.getExpiryDisplay(itemArg.data.expiresAt), + 'Auto-Renew': itemArg.data.autoRenew ? '✓' : '✗', + Nameservers: html`
${itemArg.data.nameservers?.join(', ') || '—'}
`, + }; + }} + .dataActions=${[ + { + name: 'Add Domain', + iconName: 'plus', + type: ['header', 'footer'], + actionFunc: async (dataActionArg) => { + const modal = await plugins.deesCatalog.DeesModal.createAndShow({ + heading: 'Add Domain', + content: html` + + + + + + + + + + + + + + + + + + + + + + + + + `, + menuOptions: [ + { + name: 'Create Domain', + action: async (modalArg) => { + const form = modalArg.shadowRoot.querySelector('dees-form') as any; + const formData = await form.gatherData(); + + const nameservers = formData.nameservers + ? formData.nameservers.split(',').map((ns: string) => ns.trim()).filter((ns: string) => ns) + : []; + + const tags = formData.tags + ? formData.tags.split(',').map((tag: string) => tag.trim()).filter((tag: string) => tag) + : []; + + await appstate.dataState.dispatchAction(appstate.createDomainAction, { + domainData: { + name: formData.name, + description: formData.description || undefined, + status: formData.status, + verificationStatus: 'pending', + nameservers, + registrar: formData.registrarName ? { + name: formData.registrarName, + url: formData.registrarUrl || undefined, + } : undefined, + expiresAt: formData.expiresAt ? new Date(formData.expiresAt).getTime() : undefined, + autoRenew: formData.autoRenew, + dnssecEnabled: formData.dnssecEnabled, + isPrimary: formData.isPrimary, + tags: tags.length > 0 ? tags : undefined, + }, + }); + + await modalArg.destroy(); + }, + }, + { + name: 'Cancel', + action: async (modalArg) => { + modalArg.destroy(); + }, + }, + ], + }); + }, + }, + { + name: 'Edit', + iconName: 'edit', + type: ['contextmenu', 'inRow'], + actionFunc: async (actionDataArg) => { + const domain = actionDataArg.item as plugins.interfaces.data.IDomain; + const modal = await plugins.deesCatalog.DeesModal.createAndShow({ + heading: `Edit Domain: ${domain.data.name}`, + content: html` + + + + + + + + + + + + + + + + + + + + + + + + + `, + menuOptions: [ + { + name: 'Update Domain', + action: async (modalArg) => { + const form = modalArg.shadowRoot.querySelector('dees-form') as any; + const formData = await form.gatherData(); + + const nameservers = formData.nameservers + ? formData.nameservers.split(',').map((ns: string) => ns.trim()).filter((ns: string) => ns) + : []; + + const tags = formData.tags + ? formData.tags.split(',').map((tag: string) => tag.trim()).filter((tag: string) => tag) + : []; + + await appstate.dataState.dispatchAction(appstate.updateDomainAction, { + domainId: domain.id, + domainData: { + ...domain.data, + name: formData.name, + description: formData.description || undefined, + status: formData.status, + nameservers, + registrar: formData.registrarName ? { + name: formData.registrarName, + url: formData.registrarUrl || undefined, + } : undefined, + expiresAt: formData.expiresAt ? new Date(formData.expiresAt).getTime() : undefined, + autoRenew: formData.autoRenew, + dnssecEnabled: formData.dnssecEnabled, + isPrimary: formData.isPrimary, + tags: tags.length > 0 ? tags : undefined, + }, + }); + + await modalArg.destroy(); + }, + }, + { + name: 'Cancel', + action: async (modalArg) => { + modalArg.destroy(); + }, + }, + ], + }); + }, + }, + { + name: 'Verify', + iconName: 'check-circle', + type: ['contextmenu', 'inRow'], + actionFunc: async (actionDataArg) => { + const domain = actionDataArg.item as plugins.interfaces.data.IDomain; + const modal = await plugins.deesCatalog.DeesModal.createAndShow({ + heading: `Verify Domain: ${domain.data.name}`, + content: html` +
+

Choose a verification method for ${domain.data.name}

+ + + + + ${domain.data.verificationToken ? html` +
+
Verification Token:
+ ${domain.data.verificationToken} +
+ ` : ''} +
+ `, + menuOptions: [ + { + name: 'Start Verification', + action: async (modalArg) => { + const form = modalArg.shadowRoot.querySelector('dees-form') as any; + const formData = await form.gatherData(); + + await appstate.dataState.dispatchAction(appstate.verifyDomainAction, { + domainId: domain.id, + verificationMethod: formData.method, + }); + + await modalArg.destroy(); + }, + }, + { + name: 'Cancel', + action: async (modalArg) => { + modalArg.destroy(); + }, + }, + ], + }); + }, + }, + { + name: 'View DNS Records', + iconName: 'list', + type: ['contextmenu', 'inRow'], + actionFunc: async (actionDataArg) => { + const domain = actionDataArg.item as plugins.interfaces.data.IDomain; + // Navigate to DNS view with filter for this domain + // TODO: Implement navigation with filter + console.log('View DNS records for domain:', domain.data.name); + }, + }, + { + name: 'Delete', + iconName: 'trash', + type: ['contextmenu', 'inRow'], + actionFunc: async (actionDataArg) => { + const domain = actionDataArg.item as plugins.interfaces.data.IDomain; + const dnsCount = this.data.dnsEntries?.filter(dns => dns.data.zone === domain.data.name).length || 0; + + plugins.deesCatalog.DeesModal.createAndShow({ + heading: `Delete Domain`, + content: html` +
+ Are you sure you want to delete this domain? +
+
+
+ ${domain.data.name} +
+ ${domain.data.description ? html` +
+ ${domain.data.description} +
+ ` : ''} + ${dnsCount > 0 ? html` +
+ ⚠️ This domain has ${dnsCount} DNS record${dnsCount > 1 ? 's' : ''} that will also be deleted +
+ ` : ''} +
+ `, + menuOptions: [ + { + name: 'Cancel', + action: async (modalArg) => { + await modalArg.destroy(); + }, + }, + { + name: 'Delete', + action: async (modalArg) => { + await appstate.dataState.dispatchAction(appstate.deleteDomainAction, { + domainId: domain.id, + }); + await modalArg.destroy(); + }, + }, + ], + }); + }, + }, + ] as plugins.deesCatalog.ITableAction[]} + >
+ `; + } +} \ No newline at end of file diff --git a/ts_web/elements/cloudly-view-overview.ts b/ts_web/elements/cloudly-view-overview.ts index 9c03636..c9007b6 100644 --- a/ts_web/elements/cloudly-view-overview.ts +++ b/ts_web/elements/cloudly-view-overview.ts @@ -104,11 +104,11 @@ export class CloudlyViewOverview extends DeesElement { }, { id: 'dns', - title: 'DNS Zones', - value: this.data.dns?.length || 0, + title: 'DNS Entries', + value: this.data.dnsEntries?.length || 0, type: 'number' as const, iconName: 'lucide:Globe', - description: 'Managed DNS zones' + description: 'Managed DNS records' }, { id: 'databases', diff --git a/ts_web/elements/cloudly-view-services.ts b/ts_web/elements/cloudly-view-services.ts index c7ce1bd..97ac321 100644 --- a/ts_web/elements/cloudly-view-services.ts +++ b/ts_web/elements/cloudly-view-services.ts @@ -120,21 +120,21 @@ export class CloudlyViewServices extends DeesElement { @@ -223,14 +223,14 @@ export class CloudlyViewServices extends DeesElement { @@ -256,7 +256,7 @@ export class CloudlyViewServices extends DeesElement {