Monorepo for Aesthetic.Computer aesthetic.computer
at main 414 lines 12 kB view raw
1/** 2 * Cloudflare DNS Management Module 3 * 4 * Provides utilities for managing DNS records on Cloudflare. 5 * Reads credentials from vault environment files. 6 */ 7 8import { readFileSync } from 'fs'; 9import { resolve } from 'path'; 10import { homedir } from 'os'; 11 12const CLOUDFLARE_BASE_URL = 'https://api.cloudflare.com/client/v4'; 13 14/** 15 * Load Cloudflare credentials from vault 16 */ 17function loadCloudflareCredentials() { 18 const vaultPaths = [ 19 resolve(homedir(), 'aesthetic-computer/aesthetic-computer-vault/.devcontainer/envs/devcontainer.env'), 20 resolve(homedir(), 'aesthetic-computer/aesthetic-computer-vault/grab/.env'), 21 ]; 22 23 for (const vaultPath of vaultPaths) { 24 try { 25 const envContent = readFileSync(vaultPath, 'utf-8'); 26 const env = {}; 27 28 envContent.split('\n').forEach((line) => { 29 const match = line.match(/^([^#=]+)=(.*)$/); 30 if (match) { 31 const key = match[1].trim(); 32 let value = match[2].trim(); 33 // Remove quotes if present 34 if ((value.startsWith('"') && value.endsWith('"')) || 35 (value.startsWith("'") && value.endsWith("'"))) { 36 value = value.slice(1, -1); 37 } 38 env[key] = value; 39 } 40 }); 41 42 if (env.CLOUDFLARE_EMAIL && env.CLOUDFLARE_API_KEY) { 43 return { 44 email: env.CLOUDFLARE_EMAIL, 45 apiKey: env.CLOUDFLARE_API_KEY, 46 accountId: env.CLOUDFLARE_ACCOUNT_ID, 47 }; 48 } 49 } catch (error) { 50 // Try next path 51 continue; 52 } 53 } 54 55 throw new Error('Could not load Cloudflare credentials from vault'); 56} 57 58/** 59 * Create authenticated headers for Cloudflare API 60 */ 61function createHeaders(credentials) { 62 return { 63 'X-Auth-Email': credentials.email, 64 'X-Auth-Key': credentials.apiKey, 65 'Content-Type': 'application/json', 66 }; 67} 68 69/** 70 * Fetch zone ID for a domain 71 */ 72async function getZoneId(domain, credentials) { 73 const headers = createHeaders(credentials); 74 const response = await fetch(`${CLOUDFLARE_BASE_URL}/zones?name=${domain}`, { headers }); 75 const data = await response.json(); 76 77 if (!data.success || !data.result || data.result.length === 0) { 78 throw new Error(`Zone not found for domain: ${domain}`); 79 } 80 81 return data.result[0].id; 82} 83 84/** 85 * Fetch existing DNS record 86 */ 87async function getDNSRecord(zoneId, recordType, recordName, credentials) { 88 const headers = createHeaders(credentials); 89 const response = await fetch( 90 `${CLOUDFLARE_BASE_URL}/zones/${zoneId}/dns_records?type=${recordType}&name=${recordName}`, 91 { headers } 92 ); 93 const data = await response.json(); 94 95 return data.result?.[0]; 96} 97 98/** 99 * Create a new DNS record 100 */ 101async function createDNSRecord(zoneId, recordData, credentials) { 102 const headers = createHeaders(credentials); 103 const response = await fetch( 104 `${CLOUDFLARE_BASE_URL}/zones/${zoneId}/dns_records`, 105 { 106 method: 'POST', 107 headers, 108 body: JSON.stringify(recordData) 109 } 110 ); 111 const data = await response.json(); 112 113 if (!data.success) { 114 throw new Error(`Failed to create DNS record: ${JSON.stringify(data.errors)}`); 115 } 116 117 return data.result; 118} 119 120/** 121 * Update an existing DNS record 122 */ 123async function updateDNSRecord(zoneId, recordId, recordData, credentials) { 124 const headers = createHeaders(credentials); 125 const response = await fetch( 126 `${CLOUDFLARE_BASE_URL}/zones/${zoneId}/dns_records/${recordId}`, 127 { 128 method: 'PUT', 129 headers, 130 body: JSON.stringify(recordData) 131 } 132 ); 133 const data = await response.json(); 134 135 if (!data.success) { 136 throw new Error(`Failed to update DNS record: ${JSON.stringify(data.errors)}`); 137 } 138 139 return data.result; 140} 141 142/** 143 * Create or update a TXT record 144 * 145 * @param {string} recordName - Full record name (e.g., "_lexicon.aesthetic.computer") 146 * @param {string} content - TXT record content 147 * @param {string} rootDomain - Root domain (e.g., "aesthetic.computer") 148 * @param {boolean} dryRun - If true, only check what would be done 149 * @returns {Promise<Object>} Result object with status and details 150 */ 151export async function createOrUpdateTXTRecord(recordName, content, rootDomain, dryRun = false) { 152 try { 153 const credentials = loadCloudflareCredentials(); 154 const zoneId = await getZoneId(rootDomain, credentials); 155 156 // Check for existing record 157 const existingRecord = await getDNSRecord(zoneId, 'TXT', recordName, credentials); 158 159 const recordData = { 160 type: 'TXT', 161 name: recordName, 162 content: content, 163 ttl: 3600, // 1 hour 164 }; 165 166 if (existingRecord) { 167 if (existingRecord.content === content) { 168 return { 169 action: 'none', 170 recordName, 171 content, 172 message: `TXT record already exists with correct value` 173 }; 174 } 175 176 if (dryRun) { 177 return { 178 action: 'update', 179 recordName, 180 oldContent: existingRecord.content, 181 newContent: content, 182 message: `Would update TXT record: ${existingRecord.content}${content}` 183 }; 184 } 185 186 await updateDNSRecord(zoneId, existingRecord.id, recordData, credentials); 187 return { 188 action: 'updated', 189 recordName, 190 content, 191 message: `Updated TXT record: ${recordName}${content}` 192 }; 193 } else { 194 if (dryRun) { 195 return { 196 action: 'create', 197 recordName, 198 content, 199 message: `Would create TXT record: ${recordName}${content}` 200 }; 201 } 202 203 await createDNSRecord(zoneId, recordData, credentials); 204 return { 205 action: 'created', 206 recordName, 207 content, 208 message: `Created TXT record: ${recordName}${content}` 209 }; 210 } 211 } catch (error) { 212 return { 213 action: 'error', 214 recordName, 215 error: error.message, 216 message: `Error managing DNS record: ${error.message}` 217 }; 218 } 219} 220 221/** 222 * Verify a TXT record exists and has the correct value 223 */ 224export async function verifyTXTRecord(recordName, expectedContent, rootDomain) { 225 try { 226 const credentials = loadCloudflareCredentials(); 227 const zoneId = await getZoneId(rootDomain, credentials); 228 const record = await getDNSRecord(zoneId, 'TXT', recordName, credentials); 229 230 if (!record) { 231 return { 232 exists: false, 233 message: `TXT record not found: ${recordName}` 234 }; 235 } 236 237 const matches = record.content === expectedContent; 238 return { 239 exists: true, 240 matches, 241 actualContent: record.content, 242 expectedContent, 243 message: matches 244 ? `TXT record verified: ${recordName}` 245 : `TXT record exists but content doesn't match` 246 }; 247 } catch (error) { 248 return { 249 exists: false, 250 error: error.message, 251 message: `Error verifying DNS record: ${error.message}` 252 }; 253 } 254} 255 256/** 257 * Create or update a CNAME record. 258 * 259 * @param {string} subdomain - Subdomain name (e.g., "bills" for bills.aesthetic.computer) 260 * @param {string} target - CNAME target (default: lith frontend host) 261 * @param {string} rootDomain - Root domain (default: "aesthetic.computer") 262 * @param {boolean} proxied - Whether to proxy through Cloudflare (default: true) 263 * @param {boolean} dryRun - If true, only check what would be done 264 * @returns {Promise<Object>} Result object with status and details 265 */ 266export async function createOrUpdateCNAME(subdomain, target = 'lith.aesthetic.computer', rootDomain = 'aesthetic.computer', proxied = true, dryRun = false) { 267 const recordName = `${subdomain}.${rootDomain}`; 268 269 try { 270 const credentials = loadCloudflareCredentials(); 271 const zoneId = await getZoneId(rootDomain, credentials); 272 273 // Check for existing record 274 const existingRecord = await getDNSRecord(zoneId, 'CNAME', recordName, credentials); 275 276 const recordData = { 277 type: 'CNAME', 278 name: recordName, 279 content: target, 280 ttl: 1, // Auto TTL when proxied 281 proxied: proxied, 282 }; 283 284 if (existingRecord) { 285 if (existingRecord.content === target && existingRecord.proxied === proxied) { 286 return { 287 action: 'none', 288 recordName, 289 target, 290 proxied, 291 message: `CNAME record already exists with correct value` 292 }; 293 } 294 295 if (dryRun) { 296 return { 297 action: 'update', 298 recordName, 299 oldTarget: existingRecord.content, 300 newTarget: target, 301 proxied, 302 message: `Would update CNAME: ${existingRecord.content}${target}` 303 }; 304 } 305 306 await updateDNSRecord(zoneId, existingRecord.id, recordData, credentials); 307 return { 308 action: 'updated', 309 recordName, 310 target, 311 proxied, 312 message: `Updated CNAME: ${recordName}${target}` 313 }; 314 } else { 315 if (dryRun) { 316 return { 317 action: 'create', 318 recordName, 319 target, 320 proxied, 321 message: `Would create CNAME: ${recordName}${target}` 322 }; 323 } 324 325 await createDNSRecord(zoneId, recordData, credentials); 326 return { 327 action: 'created', 328 recordName, 329 target, 330 proxied, 331 message: `Created CNAME: ${recordName}${target}` 332 }; 333 } 334 } catch (error) { 335 return { 336 action: 'error', 337 recordName, 338 error: error.message, 339 message: `Error managing CNAME record: ${error.message}` 340 }; 341 } 342} 343 344/** 345 * List all DNS records for a domain 346 */ 347export async function listDNSRecords(rootDomain = 'aesthetic.computer', type = null) { 348 try { 349 const credentials = loadCloudflareCredentials(); 350 const zoneId = await getZoneId(rootDomain, credentials); 351 const headers = createHeaders(credentials); 352 353 let url = `${CLOUDFLARE_BASE_URL}/zones/${zoneId}/dns_records`; 354 if (type) url += `?type=${type}`; 355 356 const response = await fetch(url, { headers }); 357 const data = await response.json(); 358 359 if (!data.success) { 360 throw new Error(`Failed to list DNS records: ${JSON.stringify(data.errors)}`); 361 } 362 363 return data.result; 364 } catch (error) { 365 throw new Error(`Error listing DNS records: ${error.message}`); 366 } 367} 368 369// CLI support 370if (import.meta.url === `file://${process.argv[1]}`) { 371 const [,, command, ...args] = process.argv; 372 373 async function main() { 374 switch (command) { 375 case 'add-subdomain': 376 case 'add-cname': { 377 const subdomain = args[0]; 378 const target = args[1] || 'lith.aesthetic.computer'; 379 if (!subdomain) { 380 console.error('Usage: node cloudflare-dns.mjs add-subdomain <subdomain> [target]'); 381 console.error('Example: node cloudflare-dns.mjs add-subdomain bills'); 382 process.exit(1); 383 } 384 console.log(`Adding CNAME: ${subdomain}.aesthetic.computer → ${target}`); 385 const result = await createOrUpdateCNAME(subdomain, target); 386 console.log(result.message); 387 break; 388 } 389 390 case 'list': { 391 const type = args[0]; // optional: CNAME, A, TXT, etc. 392 console.log(`Listing DNS records${type ? ` (type: ${type})` : ''}...`); 393 const records = await listDNSRecords('aesthetic.computer', type); 394 records.forEach(r => { 395 console.log(` ${r.type.padEnd(6)} ${r.name.padEnd(40)}${r.content} ${r.proxied ? '(proxied)' : ''}`); 396 }); 397 break; 398 } 399 400 default: 401 console.log('Cloudflare DNS Management'); 402 console.log(''); 403 console.log('Commands:'); 404 console.log(' add-subdomain <name> [target] Add a proxied CNAME (defaults to lith.aesthetic.computer)'); 405 console.log(' list [type] List all DNS records (optionally filter by type)'); 406 console.log(''); 407 console.log('Examples:'); 408 console.log(' node cloudflare-dns.mjs add-subdomain bills'); 409 console.log(' node cloudflare-dns.mjs list CNAME'); 410 } 411 } 412 413 main().catch(console.error); 414}