Monorepo for Aesthetic.Computer
aesthetic.computer
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}