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, host: instance.customDomain, port: 3000, https: true, certificateType: 'letsencrypt', }) } 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 }) } }