Initial commit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
07b274aa27
9 changed files with 190 additions and 0 deletions
BIN
.DS_Store
vendored
Normal file
BIN
.DS_Store
vendored
Normal file
Binary file not shown.
6
.env.example
Normal file
6
.env.example
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
PORT=3000
|
||||
|
||||
CLOUDFLARE_API_TOKEN=...
|
||||
CLOUDFLARE_ACCOUNT_ID=...
|
||||
CLOUDFLARE_ZONE_ID=... # zone ID for crimata.com
|
||||
BASE_DOMAIN=crimata.com
|
||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
|
|
@ -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"]
|
||||
19
package.json
Normal file
19
package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
56
src/cloudflare.ts
Normal file
56
src/cloudflare.ts
Normal file
|
|
@ -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 })
|
||||
}
|
||||
13
src/index.ts
Normal file
13
src/index.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
42
src/routes.ts
Normal file
42
src/routes.ts
Normal file
|
|
@ -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()
|
||||
})
|
||||
|
||||
}
|
||||
26
src/types.ts
Normal file
26
src/types.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Reference in a new issue