- Field name: hostName → host (verified via direct API test) - Added certificateType: 'letsencrypt' for automatic HTTPS cert provisioning - Updated createDomain in dokploy.ts + custom-domain route - HTTPS is enabled by default (https: true) - Let's Encrypt auto-provisions certificate for the subdomain
291 lines
9.7 KiB
TypeScript
291 lines
9.7 KiB
TypeScript
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 })
|
|
}
|
|
}
|