]> andrewgundersen.com git - dns/commitdiff
Initial commit
authorAndrew Gundersen <adgundersen@gmail.com>
Wed, 25 Feb 2026 20:32:58 +0000 (15:32 -0500)
committerAndrew Gundersen <adgundersen@gmail.com>
Wed, 25 Feb 2026 20:32:58 +0000 (15:32 -0500)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
.DS_Store [new file with mode: 0644]
.env.example [new file with mode: 0644]
Dockerfile [new file with mode: 0644]
package.json [new file with mode: 0644]
src/cloudflare.ts [new file with mode: 0644]
src/index.ts [new file with mode: 0644]
src/routes.ts [new file with mode: 0644]
src/types.ts [new file with mode: 0644]
tsconfig.json [new file with mode: 0644]

diff --git a/.DS_Store b/.DS_Store
new file mode 100644 (file)
index 0000000..5b2df37
Binary files /dev/null and b/.DS_Store differ
diff --git a/.env.example b/.env.example
new file mode 100644 (file)
index 0000000..a9ec250
--- /dev/null
@@ -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 (file)
index 0000000..2cc6db8
--- /dev/null
@@ -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 (file)
index 0000000..490e416
--- /dev/null
@@ -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 (file)
index 0000000..bafcdfb
--- /dev/null
@@ -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<DomainCheckResult> {
+  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<string> {
+  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<string> {
+  // 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<string> {
+  // 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<void> {
+  await cf.dns.records.delete(recordId, { zone_id: ZONE_ID })
+}
diff --git a/src/index.ts b/src/index.ts
new file mode 100644 (file)
index 0000000..8eb07e0
--- /dev/null
@@ -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 (file)
index 0000000..a949f88
--- /dev/null
@@ -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 (file)
index 0000000..577917f
--- /dev/null
@@ -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 (file)
index 0000000..8a4004a
--- /dev/null
@@ -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"]
+}