diff --git a/src/app/api/aos/instances/[id]/custom-domain/route.ts b/src/app/api/aos/instances/[id]/custom-domain/route.ts new file mode 100644 index 0000000..c456d2f --- /dev/null +++ b/src/app/api/aos/instances/[id]/custom-domain/route.ts @@ -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 }) + } +}