From 07b274aa27c5f2a9a036df2bb08dbc9729a51d5c Mon Sep 17 00:00:00 2001 From: Andrew Gundersen Date: Wed, 25 Feb 2026 15:32:58 -0500 Subject: [PATCH 1/1] Initial commit Co-Authored-By: Claude Sonnet 4.6 --- .DS_Store | Bin 0 -> 6148 bytes .env.example | 6 +++++ Dockerfile | 14 ++++++++++++ package.json | 19 ++++++++++++++++ src/cloudflare.ts | 56 ++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 13 +++++++++++ src/routes.ts | 42 ++++++++++++++++++++++++++++++++++ src/types.ts | 26 +++++++++++++++++++++ tsconfig.json | 14 ++++++++++++ 9 files changed, 190 insertions(+) create mode 100644 .DS_Store create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 package.json create mode 100644 src/cloudflare.ts create mode 100644 src/index.ts create mode 100644 src/routes.ts create mode 100644 src/types.ts create mode 100644 tsconfig.json diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5b2df37934ec46c5ef6e5898e254e4d88acb012f GIT binary patch literal 6148 zcmeHKOG*Pl5UtXP18%a+vQHqy6LiA3a2?!-NrDO!CZghY4&xcTg4ggS`s$-(oLOWg zB2`fRs=Gej^I-ZzL_B|5Pl?7vR6qq;i~*71LD!K7p8#2F+|eU#uIgr0)dPk8;*hNU zl%8or6+O`2`kM+Kp=Aa=|4@O|i>_~`i&eATsn41p^|nn}R<+hEUN5fS?_b`eW-n@e zK-BKGH{0ICVFm-iKrj#t1Oo>!fHPa9I5Uhk7zhS}fo}$6e@IZl?ARLW)`3Q!0KgH3 zRbb1dS#pwNc5Dsdfv}|lEtUO=!IqBkM7!cTkl literal 0 HcmV?d00001 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a9ec250 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +PORT=3000 + +CLOUDFLARE_API_TOKEN=... +CLOUDFLARE_ACCOUNT_ID=... +CLOUDFLARE_ZONE_ID=... # zone ID for crimata.com +BASE_DOMAIN=crimata.com diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2cc6db8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --omit=dev +COPY --from=builder /app/dist ./dist +EXPOSE 3000 +CMD ["node", "dist/index.js"] diff --git a/package.json b/package.json new file mode 100644 index 0000000..490e416 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "crimata-dns", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "cloudflare": "^3.3.0", + "fastify": "^4.26.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "tsx": "^4.7.0", + "typescript": "^5.4.0" + } +} diff --git a/src/cloudflare.ts b/src/cloudflare.ts new file mode 100644 index 0000000..bafcdfb --- /dev/null +++ b/src/cloudflare.ts @@ -0,0 +1,56 @@ +import Cloudflare from "cloudflare" +import type { DomainCheckResult, DomainRecord } from "./types" + +const cf = new Cloudflare({ apiToken: process.env.CLOUDFLARE_API_TOKEN }) + +const ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID! +const ZONE_ID = process.env.CLOUDFLARE_ZONE_ID! // crimata.com zone +const BASE_DOMAIN = process.env.BASE_DOMAIN ?? "crimata.com" + +export async function checkDomain(domain: string): Promise { + const result = await cf.registrar.domains.get(domain, { account_id: ACCOUNT_ID }) + return { + domain, + available: !result.registered_at, + } +} + +export async function registerDomain(domain: string): Promise { + const result = await cf.registrar.domains.update(domain, { + account_id: ACCOUNT_ID, + auto_renew: true, + locked: false, + privacy: true, + }) + return result.id ?? domain +} + +export async function createDNSRecord(domain: string, target: string): Promise { + // Point the custom domain at the ALB/target + const record = await cf.dns.records.create({ + zone_id: ZONE_ID, + type: "CNAME", + name: domain, + content: target, + ttl: 1, // auto + proxied: true, + }) + return record.id! +} + +export async function createSubdomain(slug: string, target: string): Promise { + // Free subdomain: slug.crimata.com + const record = await cf.dns.records.create({ + zone_id: ZONE_ID, + type: "CNAME", + name: `${slug}.${BASE_DOMAIN}`, + content: target, + ttl: 1, + proxied: true, + }) + return record.id! +} + +export async function deleteDNSRecord(recordId: string): Promise { + await cf.dns.records.delete(recordId, { zone_id: ZONE_ID }) +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..8eb07e0 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,13 @@ +import Fastify from "fastify" +import { registerRoutes } from "./routes" + +const app = Fastify({ logger: true }) + +registerRoutes(app) + +app.listen({ port: Number(process.env.PORT ?? 3000), host: "0.0.0.0" }, (err) => { + if (err) { + app.log.error(err) + process.exit(1) + } +}) diff --git a/src/routes.ts b/src/routes.ts new file mode 100644 index 0000000..a949f88 --- /dev/null +++ b/src/routes.ts @@ -0,0 +1,42 @@ +import type { FastifyInstance } from "fastify" +import type { DomainCheckResult, ProvisionDomainRequest, ApiError } from "./types" +import * as cf from "./cloudflare" + +export async function registerRoutes(app: FastifyInstance) { + + // Check domain availability + app.get<{ Querystring: { domain: string } }>("/domains/check", async (req, reply) => { + const { domain } = req.query + if (!domain) return reply.status(400).send({ error: "domain is required" }) + + const result = await cf.checkDomain(domain) + return reply.send(result) + }) + + // Provision a domain (register + point DNS) and assign free subdomain + app.post<{ Body: ProvisionDomainRequest }>("/domains", async (req, reply) => { + const { domain, customerSlug, targetIP } = req.body + if (!customerSlug || !targetIP) { + return reply.status(400).send({ error: "customerSlug and targetIP are required" }) + } + + // Always create the free subdomain + await cf.createSubdomain(customerSlug, targetIP) + + // Register and point custom domain if provided + if (domain) { + await cf.registerDomain(domain) + await cf.createDNSRecord(domain, targetIP) + } + + return reply.status(201).send({ customerSlug, domain: domain ?? null, status: "provisioning" }) + }) + + // Delete DNS records for a customer + app.delete<{ Params: { recordId: string } }>("/domains/:recordId", async (req, reply) => { + const { recordId } = req.params + await cf.deleteDNSRecord(recordId) + return reply.status(204).send() + }) + +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..577917f --- /dev/null +++ b/src/types.ts @@ -0,0 +1,26 @@ +export interface DomainCheckResult { + domain: string + available: boolean + price?: number + currency?: string +} + +export interface ProvisionDomainRequest { + domain: string + customerSlug: string + targetIP: string // IP or hostname to point DNS at +} + +export interface DomainRecord { + domain: string + customerSlug: string + cloudflareZoneId: string + status: DomainStatus + createdAt: string +} + +export type DomainStatus = "provisioning" | "active" | "failed" | "released" + +export interface ApiError { + error: string +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8a4004a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src"] +} -- 2.43.0