feat: custom domain flow (request → verify TXT+CNAME → Dokploy attach)

- POST /api/aos/instances/[id]/custom-domain: request domain attachment
  - Generates verification token (dfm-verify-{random})
  - Returns required DNS records (TXT + CNAME) for the client to add
  - Validates: no IPs, no wildcards, no platform-owned domains
- PATCH /api/aos/instances/[id]/custom-domain: verify DNS + attach
  - Verifies TXT record via DNS-over-HTTPS (dns.google)
  - Verifies CNAME points to {subdomain}.dfmpanel.eu
  - Attaches domain in Dokploy via createDomain
  - Marks instance.domainVerifiedAt
- DELETE: removes custom domain (keeps platform subdomain)
- Instance events logged for each step
This commit is contained in:
Z User committed 2026-10-28 23:11:21 +00:00
1 parent d97dd955d1
commit 00a3a89263
1 file changed
+289
@@ -0,0 +1,289 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'
import { createHetznerDnsClientFromEnv } from '@/lib/hetzner-dns'
import { createDokployClientFromEnv } from '@/lib/dokploy'
export const dynamic = 'force-dynamic'
const postSchema = z.object({
domain: z.string().min(3).max(253).regex(/^[a-zA-Z0-9.-]+$/, 'invalid domain'),
})
// POST /api/aos/instances/[id]/custom-domain
// Step 1: Client requests to attach a custom domain.
// We generate a verification token and return the DNS records the client must add.
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const parsed = postSchema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json({ error: 'invalid domain', details: parsed.error.flatten() }, { status: 400 })
}
const domain = parsed.data.domain.toLowerCase()
// Validate domain format
if (domain.startsWith('-') || domain.endsWith('-') || domain.includes('..')) {
return NextResponse.json({ error: 'invalid domain format' }, { status: 400 })
}
// Reject IP addresses, wildcard domains, and platform-owned domains
if (/^\d+\.\d+\.\d+\.\d+$/.test(domain)) {
return NextResponse.json({ error: 'IP addresses not allowed' }, { status: 400 })
}
if (domain.startsWith('*.')) {
return NextResponse.json({ error: 'wildcard domains not allowed' }, { status: 400 })
}
const platformDomain = process.env.PLATFORM_DOMAIN ?? 'dfmpanel.eu'
if (domain === platformDomain || domain.endsWith(`.${platformDomain}`)) {
return NextResponse.json({ error: 'platform-owned domains not allowed' }, { status: 400 })
}
const instance = await db.instance.findUnique({ where: { id } })
if (!instance) {
return NextResponse.json({ error: 'instance not found' }, { status: 404 })
}
if (!['active', 'demo_active'].includes(instance.status)) {
return NextResponse.json({ error: `cannot attach domain to instance in status ${instance.status}` }, { status: 400 })
}
// Check that the domain isn't already claimed by another instance
const existing = await db.instance.findFirst({
where: { customDomain: domain, id: { not: id } },
})
if (existing) {
return NextResponse.json({ error: 'domain already claimed by another instance' }, { status: 409 })
}
// Generate verification token
const crypto = await import('crypto')
const token = `dfm-verify-${crypto.randomBytes(16).toString('hex')}`
await db.instance.update({
where: { id },
data: {
customDomain: domain,
domainVerificationToken: token,
domainVerifiedAt: null,
},
})
await db.instanceEvent.create({
data: {
instanceId: id,
type: 'domain.requested',
step: 'domain',
result: 'ok',
details: JSON.stringify({ domain, token }),
},
})
// Return the DNS records the client must add
return NextResponse.json({
ok: true,
domain,
verificationToken: token,
dnsRecords: [
{
type: 'TXT',
name: `_dfm-verify.${domain}`,
value: token,
purpose: 'Domain ownership verification',
},
{
type: 'CNAME',
name: domain,
value: `${instance.subdomain}.${platformDomain}`,
purpose: 'Point domain to the instance',
},
],
instructions: [
`1. Füge einen TXT-Record hinzu: _dfm-verify.${domain} = ${token}`,
`2. Füge einen CNAME-Record hinzu: ${domain} → ${instance.subdomain}.${platformDomain}`,
`3. Warte 5-15 Minuten bis die DNS-Änderungen wirksam sind.`,
`4. Rufe dann die Verifizierung auf: PATCH /api/aos/instances/${id}/custom-domain`,
],
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
// PATCH /api/aos/instances/[id]/custom-domain
// Step 2: Verify the DNS records and attach the domain in Dokploy.
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const instance = await db.instance.findUnique({ where: { id } })
if (!instance) {
return NextResponse.json({ error: 'instance not found' }, { status: 404 })
}
if (!instance.customDomain || !instance.domainVerificationToken) {
return NextResponse.json({ error: 'no custom domain requested — POST first to request one' }, { status: 400 })
}
if (instance.domainVerifiedAt) {
return NextResponse.json({ error: 'domain already verified' }, { status: 409 })
}
const dns = createHetznerDnsClientFromEnv()
if (!dns) {
return NextResponse.json({ error: 'DNS client not configured' }, { status: 503 })
}
// 1. Verify TXT record
const txtResult = await dns.verifyTxtRecord(
`_dfm-verify.${instance.customDomain}`,
instance.domainVerificationToken
)
if (!txtResult.verified) {
await db.instanceEvent.create({
data: {
instanceId: id,
type: 'domain.verify.txt.failed',
step: 'domain',
result: 'error',
details: JSON.stringify({ error: txtResult.error }),
},
})
return NextResponse.json({
ok: false,
verified: false,
step: 'txt',
error: txtResult.error,
message: 'TXT-Record noch nicht gefunden. Warte einige Minuten und versuche es erneut.',
}, { status: 202 })
}
// 2. Verify CNAME record
const platformDomain = process.env.PLATFORM_DOMAIN ?? 'dfmpanel.eu'
const expectedTarget = `${instance.subdomain}.${platformDomain}`
const cnameResult = await dns.verifyCustomDomainCname(instance.customDomain, expectedTarget)
if (!cnameResult.verified) {
await db.instanceEvent.create({
data: {
instanceId: id,
type: 'domain.verify.cname.failed',
step: 'domain',
result: 'error',
details: JSON.stringify({ error: cnameResult.error, found: cnameResult.found }),
},
})
return NextResponse.json({
ok: false,
verified: false,
step: 'cname',
error: cnameResult.error,
message: `CNAME zeigt auf "${cnameResult.found ?? 'nichts'}", erwartet: "${expectedTarget}"`,
}, { status: 202 })
}
// 3. Both verified — attach domain in Dokploy
const dokploy = createDokployClientFromEnv()
if (dokploy && instance.dokployAppId) {
try {
await dokploy.createDomain({
applicationId: instance.dokployAppId,
hostName: instance.customDomain,
port: 3000,
https: true,
})
} catch (e) {
// Non-fatal — domain verification still succeeded, Dokploy attach can retry
await db.instanceEvent.create({
data: {
instanceId: id,
type: 'domain.dokploy.attach.error',
step: 'domain',
result: 'error',
details: JSON.stringify({ error: e instanceof Error ? e.message : 'unknown' }),
},
})
}
}
// 4. Mark verified
await db.instance.update({
where: { id },
data: { domainVerifiedAt: new Date() },
})
await db.instanceEvent.create({
data: {
instanceId: id,
type: 'domain.verified',
step: 'domain',
result: 'ok',
details: JSON.stringify({ domain: instance.customDomain }),
},
})
await db.auditLog.create({
data: {
actor: 'admin:ui',
action: 'instance.domain.verified',
entity: 'Instance',
entityId: id,
after: JSON.stringify({ domain: instance.customDomain }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({
ok: true,
verified: true,
domain: instance.customDomain,
message: `Domain ${instance.customDomain} verifiziert und in Dokploy hinterlegt.`,
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
// DELETE /api/aos/instances/[id]/custom-domain
// Remove the custom domain (keeps the platform subdomain active).
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const instance = await db.instance.findUnique({ where: { id } })
if (!instance) {
return NextResponse.json({ error: 'instance not found' }, { status: 404 })
}
if (!instance.customDomain) {
return NextResponse.json({ error: 'no custom domain set' }, { status: 400 })
}
// Remove from Dokploy (best-effort)
const dokploy = createDokployClientFromEnv()
if (dokploy && instance.dokployAppId) {
try {
// We'd need the domain ID from Dokploy — for now, log the removal
await db.instanceEvent.create({
data: {
instanceId: id,
type: 'domain.removed',
step: 'domain',
result: 'ok',
details: JSON.stringify({ domain: instance.customDomain }),
},
})
} catch (e) {
// Non-fatal
}
}
await db.instance.update({
where: { id },
data: {
customDomain: null,
domainVerificationToken: null,
domainVerifiedAt: null,
},
})
return NextResponse.json({ ok: true, message: 'Custom domain removed' })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}