/** * DNS Manager for Onebox * * Manages DNS records via Cloudflare API */ import Cloudflare from 'npm:cloudflare@5.2.0'; import { logger } from '../logging.ts'; import { OneboxDatabase } from './database.ts'; export class OneboxDnsManager { private oneboxRef: any; private database: OneboxDatabase; private cloudflareClient: Cloudflare | null = null; private zones: Map = new Map(); // zone name -> zone ID private serverIP: string | null = null; constructor(oneboxRef: any) { this.oneboxRef = oneboxRef; this.database = oneboxRef.database; } /** * Initialize DNS manager with Cloudflare credentials */ async init(): Promise { try { // Get Cloudflare credentials from settings const apiKey = this.database.getSetting('cloudflareAPIKey'); const serverIP = this.database.getSetting('serverIP'); if (!apiKey) { logger.warn('Cloudflare credentials not configured. DNS management will be disabled.'); logger.info('Configure with: onebox config set cloudflareAPIKey '); return; } this.serverIP = serverIP; // Initialize Cloudflare client // The official Cloudflare SDK expects the API token logger.info(`Initializing Cloudflare client with token: ${apiKey.substring(0, 10)}...`); this.cloudflareClient = new Cloudflare({ apiToken: apiKey, }); logger.info('Cloudflare client initialized successfully'); // Load all available zones logger.info('Loading Cloudflare zones...'); const zoneList = []; for await (const zone of this.cloudflareClient.zones.list()) { zoneList.push(zone); this.zones.set(zone.name, zone.id); } if (zoneList.length === 0) { logger.warn('No Cloudflare zones found for this account'); return; } logger.info(`Loaded ${zoneList.length} Cloudflare zone(s):`); for (const zone of zoneList) { logger.info(` - ${zone.name} (ID: ${zone.id})`); } logger.success('DNS manager initialized with multi-zone support'); } catch (error) { logger.error(`Failed to initialize DNS manager: ${error.message}`); if (error.message && error.message.includes('Authorization header')) { logger.error('The provided API key appears to be invalid.'); logger.error('Make sure you are using a Cloudflare API TOKEN (not the global API key).'); logger.info('Create an API Token at: https://dash.cloudflare.com/profile/api-tokens'); logger.info('The token needs "Zone:Read" and "DNS:Edit" permissions.'); } throw error; } } /** * Check if DNS manager is configured */ isConfigured(): boolean { return this.cloudflareClient !== null && this.zones.size > 0; } /** * Find the zone ID for a given domain * Example: "api.task.vc" -> finds "task.vc" zone */ private findZoneForDomain(domain: string): string | null { // Try exact match first if (this.zones.has(domain)) { return this.zones.get(domain)!; } // Try finding parent zone (e.g., "api.task.vc" -> "task.vc") const parts = domain.split('.'); for (let i = 1; i < parts.length; i++) { const candidate = parts.slice(i).join('.'); if (this.zones.has(candidate)) { return this.zones.get(candidate)!; } } return null; } /** * Add a DNS record for a domain */ async addDNSRecord(domain: string, ip?: string): Promise { try { if (!this.isConfigured()) { throw new Error('DNS manager not configured'); } logger.info(`Adding DNS record for ${domain}`); const targetIP = ip || this.serverIP; if (!targetIP) { throw new Error('Server IP not configured. Set with: onebox config set serverIP '); } // Find the zone for this domain const zoneID = this.findZoneForDomain(domain); if (!zoneID) { throw new Error( `No Cloudflare zone found for domain ${domain}. Available zones: ${Array.from(this.zones.keys()).join(', ')}` ); } // Check if record already exists const existing = await this.getDNSRecord(domain); if (existing) { logger.info(`DNS record already exists for ${domain}`); return; } // Create A record const response = await this.cloudflareClient!.dns.records.create({ zone_id: zoneID, type: 'A', name: domain, content: targetIP, ttl: 1, // Auto proxied: false, // Don't proxy through Cloudflare for direct SSL }); // Store in database await this.database.query( 'INSERT INTO dns_records (domain, type, value, cloudflare_id, zone_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)', [domain, 'A', targetIP, response.id, zoneID, Date.now(), Date.now()] ); logger.success(`DNS record created for ${domain} → ${targetIP}`); } catch (error) { logger.error(`Failed to add DNS record for ${domain}: ${error.message}`); throw error; } } /** * Remove a DNS record */ async removeDNSRecord(domain: string): Promise { try { if (!this.isConfigured()) { throw new Error('DNS manager not configured'); } logger.info(`Removing DNS record for ${domain}`); // Get record from database const rows = this.database.query('SELECT cloudflare_id, zone_id FROM dns_records WHERE domain = ?', [ domain, ]); if (rows.length === 0) { logger.warn(`DNS record not found for ${domain}`); return; } const cloudflareID = String((rows[0] as any).cloudflare_id || rows[0][0]); const zoneID = String((rows[0] as any).zone_id || rows[0][1]); // Delete from Cloudflare if (cloudflareID && zoneID) { await this.cloudflareClient!.dns.records.delete(cloudflareID, { zone_id: zoneID, }); } // Delete from database this.database.query('DELETE FROM dns_records WHERE domain = ?', [domain]); logger.success(`DNS record removed for ${domain}`); } catch (error) { logger.error(`Failed to remove DNS record for ${domain}: ${error.message}`); throw error; } } /** * Get DNS record for a domain */ async getDNSRecord(domain: string): Promise { try { if (!this.isConfigured()) { return null; } // Get from database first const rows = this.database.query('SELECT * FROM dns_records WHERE domain = ?', [domain]); if (rows.length > 0) { const row = rows[0] as any; return { domain: String(row.domain || row[1]), type: String(row.type || row[2]), value: String(row.value || row[3]), cloudflareID: row.cloudflare_id ? String(row.cloudflare_id) : (row[4] ? String(row[4]) : null), zoneID: row.zone_id ? String(row.zone_id) : (row[5] ? String(row[5]) : null), }; } return null; } catch (error) { logger.error(`Failed to get DNS record for ${domain}: ${error.message}`); return null; } } /** * List all DNS records */ listDNSRecords(): any[] { try { const rows = this.database.query('SELECT * FROM dns_records ORDER BY created_at DESC'); return rows.map((row: any) => ({ id: Number(row.id || row[0]), domain: String(row.domain || row[1]), type: String(row.type || row[2]), value: String(row.value || row[3]), cloudflareID: row.cloudflare_id ? String(row.cloudflare_id) : (row[4] ? String(row[4]) : null), zoneID: row.zone_id ? String(row.zone_id) : (row[5] ? String(row[5]) : null), createdAt: Number(row.created_at || row[6]), updatedAt: Number(row.updated_at || row[7]), })); } catch (error) { logger.error(`Failed to list DNS records: ${error.message}`); return []; } } /** * Sync DNS records from Cloudflare */ async syncFromCloudflare(): Promise { try { if (!this.isConfigured()) { throw new Error('DNS manager not configured'); } logger.info('Syncing DNS records from all Cloudflare zones...'); // Sync records from all zones for (const [zoneName, zoneID] of this.zones.entries()) { logger.info(`Syncing zone: ${zoneName}`); const records = []; for await (const record of this.cloudflareClient!.dns.records.list({ zone_id: zoneID, })) { records.push(record); } // Only sync A records const aRecords = records.filter((r) => r.type === 'A'); for (const record of aRecords) { // Check if exists in database const existing = await this.getDNSRecord(record.name); if (!existing) { // Add to database await this.database.query( 'INSERT INTO dns_records (domain, type, value, cloudflare_id, zone_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)', [record.name, record.type, record.content, record.id, zoneID, Date.now(), Date.now()] ); logger.info(`Synced DNS record: ${record.name}`); } } } logger.success('DNS records synced from Cloudflare'); } catch (error) { logger.error(`Failed to sync DNS records: ${error.message}`); throw error; } } /** * Check if domain DNS is properly configured */ async checkDNS(domain: string): Promise { try { logger.info(`Checking DNS for ${domain}...`); // Use dig or nslookup to check DNS resolution const command = new Deno.Command('dig', { args: ['+short', domain], stdout: 'piped', stderr: 'piped', }); const { code, stdout } = await command.output(); if (code !== 0) { logger.warn(`DNS check failed for ${domain}`); return false; } const ip = new TextDecoder().decode(stdout).trim(); if (ip === this.serverIP) { logger.success(`DNS correctly points to ${ip}`); return true; } else { logger.warn(`DNS points to ${ip}, expected ${this.serverIP}`); return false; } } catch (error) { logger.error(`Failed to check DNS for ${domain}: ${error.message}`); return false; } } }