2025-10-28 13:05:42 +00:00
|
|
|
/**
|
|
|
|
|
* DNS Manager for Onebox
|
|
|
|
|
*
|
|
|
|
|
* Manages DNS records via Cloudflare API
|
|
|
|
|
*/
|
|
|
|
|
|
2025-11-18 00:36:23 +00:00
|
|
|
import Cloudflare from 'npm:cloudflare@5.2.0';
|
2025-11-18 00:03:24 +00:00
|
|
|
import { logger } from '../logging.ts';
|
|
|
|
|
import { OneboxDatabase } from './database.ts';
|
2025-10-28 13:05:42 +00:00
|
|
|
|
|
|
|
|
export class OneboxDnsManager {
|
|
|
|
|
private oneboxRef: any;
|
|
|
|
|
private database: OneboxDatabase;
|
2025-11-18 00:36:23 +00:00
|
|
|
private cloudflareClient: Cloudflare | null = null;
|
|
|
|
|
private zones: Map<string, string> = new Map(); // zone name -> zone ID
|
2025-10-28 13:05:42 +00:00
|
|
|
private serverIP: string | null = null;
|
|
|
|
|
|
|
|
|
|
constructor(oneboxRef: any) {
|
|
|
|
|
this.oneboxRef = oneboxRef;
|
|
|
|
|
this.database = oneboxRef.database;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Initialize DNS manager with Cloudflare credentials
|
|
|
|
|
*/
|
|
|
|
|
async init(): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
// Get Cloudflare credentials from settings
|
|
|
|
|
const apiKey = this.database.getSetting('cloudflareAPIKey');
|
|
|
|
|
const serverIP = this.database.getSetting('serverIP');
|
|
|
|
|
|
2025-11-18 00:36:23 +00:00
|
|
|
if (!apiKey) {
|
2025-10-28 13:05:42 +00:00
|
|
|
logger.warn('Cloudflare credentials not configured. DNS management will be disabled.');
|
|
|
|
|
logger.info('Configure with: onebox config set cloudflareAPIKey <key>');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.serverIP = serverIP;
|
|
|
|
|
|
|
|
|
|
// Initialize Cloudflare client
|
2025-11-18 00:36:23 +00:00
|
|
|
// 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);
|
|
|
|
|
}
|
2025-10-28 13:05:42 +00:00
|
|
|
|
2025-11-18 00:36:23 +00:00
|
|
|
if (zoneList.length === 0) {
|
|
|
|
|
logger.warn('No Cloudflare zones found for this account');
|
|
|
|
|
return;
|
2025-11-18 00:03:24 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-18 00:36:23 +00:00
|
|
|
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');
|
2025-10-28 13:05:42 +00:00
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`Failed to initialize DNS manager: ${error.message}`);
|
2025-11-18 00:03:24 +00:00
|
|
|
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.');
|
|
|
|
|
}
|
2025-10-28 13:05:42 +00:00
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if DNS manager is configured
|
|
|
|
|
*/
|
|
|
|
|
isConfigured(): boolean {
|
2025-11-18 00:36:23 +00:00
|
|
|
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;
|
2025-10-28 13:05:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Add a DNS record for a domain
|
|
|
|
|
*/
|
|
|
|
|
async addDNSRecord(domain: string, ip?: string): Promise<void> {
|
|
|
|
|
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 <ip>');
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-18 00:36:23 +00:00
|
|
|
// 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(', ')}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-28 13:05:42 +00:00
|
|
|
// 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
|
2025-11-18 00:36:23 +00:00
|
|
|
const response = await this.cloudflareClient!.dns.records.create({
|
|
|
|
|
zone_id: zoneID,
|
2025-10-28 13:05:42 +00:00
|
|
|
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(
|
2025-11-18 00:36:23 +00:00
|
|
|
'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()]
|
2025-10-28 13:05:42 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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<void> {
|
|
|
|
|
try {
|
|
|
|
|
if (!this.isConfigured()) {
|
|
|
|
|
throw new Error('DNS manager not configured');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.info(`Removing DNS record for ${domain}`);
|
|
|
|
|
|
|
|
|
|
// Get record from database
|
2025-11-18 00:36:23 +00:00
|
|
|
const rows = this.database.query('SELECT cloudflare_id, zone_id FROM dns_records WHERE domain = ?', [
|
2025-10-28 13:05:42 +00:00
|
|
|
domain,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
if (rows.length === 0) {
|
|
|
|
|
logger.warn(`DNS record not found for ${domain}`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-18 00:36:23 +00:00
|
|
|
const cloudflareID = String((rows[0] as any).cloudflare_id || rows[0][0]);
|
|
|
|
|
const zoneID = String((rows[0] as any).zone_id || rows[0][1]);
|
2025-10-28 13:05:42 +00:00
|
|
|
|
|
|
|
|
// Delete from Cloudflare
|
2025-11-18 00:36:23 +00:00
|
|
|
if (cloudflareID && zoneID) {
|
|
|
|
|
await this.cloudflareClient!.dns.records.delete(cloudflareID, {
|
|
|
|
|
zone_id: zoneID,
|
|
|
|
|
});
|
2025-10-28 13:05:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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<any> {
|
|
|
|
|
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) {
|
2025-11-18 00:36:23 +00:00
|
|
|
const row = rows[0] as any;
|
2025-10-28 13:05:42 +00:00
|
|
|
return {
|
2025-11-18 00:36:23 +00:00
|
|
|
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),
|
2025-10-28 13:05:42 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
|
2025-11-18 00:36:23 +00:00
|
|
|
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]),
|
2025-10-28 13:05:42 +00:00
|
|
|
}));
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(`Failed to list DNS records: ${error.message}`);
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Sync DNS records from Cloudflare
|
|
|
|
|
*/
|
|
|
|
|
async syncFromCloudflare(): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
if (!this.isConfigured()) {
|
|
|
|
|
throw new Error('DNS manager not configured');
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-18 00:36:23 +00:00
|
|
|
logger.info('Syncing DNS records from all Cloudflare zones...');
|
2025-10-28 13:05:42 +00:00
|
|
|
|
2025-11-18 00:36:23 +00:00
|
|
|
// Sync records from all zones
|
|
|
|
|
for (const [zoneName, zoneID] of this.zones.entries()) {
|
|
|
|
|
logger.info(`Syncing zone: ${zoneName}`);
|
2025-10-28 13:05:42 +00:00
|
|
|
|
2025-11-18 00:36:23 +00:00
|
|
|
const records = [];
|
|
|
|
|
for await (const record of this.cloudflareClient!.dns.records.list({
|
|
|
|
|
zone_id: zoneID,
|
|
|
|
|
})) {
|
|
|
|
|
records.push(record);
|
|
|
|
|
}
|
2025-10-28 13:05:42 +00:00
|
|
|
|
2025-11-18 00:36:23 +00:00
|
|
|
// Only sync A records
|
|
|
|
|
const aRecords = records.filter((r) => r.type === 'A');
|
2025-10-28 13:05:42 +00:00
|
|
|
|
2025-11-18 00:36:23 +00:00
|
|
|
for (const record of aRecords) {
|
|
|
|
|
// Check if exists in database
|
|
|
|
|
const existing = await this.getDNSRecord(record.name);
|
2025-10-28 13:05:42 +00:00
|
|
|
|
2025-11-18 00:36:23 +00:00
|
|
|
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}`);
|
|
|
|
|
}
|
2025-10-28 13:05:42 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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<boolean> {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|