4413045f-d05e-4d7a-a70d-81d6be253c85
This commit is contained in:
1 parent
762f3d7373
commit
c6adc67479
18 files changed
+5263
-1
No files matched your search
@@ -37,3 +37,44 @@ LICENSE_OFFLINE_GRACE_HOURS=72
|
||||
AGENCY_DISCOUNT_MIN_PCT=0
|
||||
AGENCY_DISCOUNT_MAX_PCT=20
|
||||
AGENCY_DISCOUNT_DEFAULT_PCT=10
|
||||
|
||||
# ── Platform Domain ──
|
||||
# The domain AOS runs on. Tenant instances get subdomains of this domain.
|
||||
PLATFORM_DOMAIN=dfmpanel.eu
|
||||
# Public URL of the AOS instance (used by license-server SDK + email links)
|
||||
AOS_PUBLIC_URL=https://dfmpanel.eu
|
||||
|
||||
# ── Dokploy (tenant instance provisioning) ──
|
||||
# Dokploy server URL + API key for managing tenant instances.
|
||||
DOKPLOY_URL=https://cp.project-url.eu
|
||||
DOKPLOY_API_KEY=CEIqJlWVtmSNgffNWywTJmEeuvUJBndinBzudretwiuzVWPxuvUNmoQaHUwRwUBy
|
||||
# The Dokploy server ID to deploy to (default "default" if not set)
|
||||
# DOKPLOY_SERVER_ID=default
|
||||
# The Dokploy project to group tenant applications under
|
||||
# DOKPLOY_PROJECT_ID=default
|
||||
# The IP of the Dokploy server (used for Hetzner DNS A records)
|
||||
# DOKPLOY_SERVER_IP=123.456.789.012
|
||||
|
||||
# ── Hetzner Cloud DNS ──
|
||||
# API token scoped to the platform zone (dfmpanel.eu)
|
||||
# HETZNER_DNS_TOKEN=your-hetzner-dns-token
|
||||
# Zone ID of the platform domain (find via: GET https://dns.hetzner.com/api/v1/zones)
|
||||
# HETZNER_ZONE_ID=your-zone-id
|
||||
# If true, a single *.dfmpanel.eu wildcard A record handles all subdomains
|
||||
# (recommended — avoids per-instance DNS records + Let's Encrypt rate limits)
|
||||
USE_WILDCARD_DNS=true
|
||||
|
||||
# ── Cron Job ──
|
||||
# Shared secret for the cleanup cron job (POST /api/aos/instances/cleanup)
|
||||
# The cron job (Dokploy cron or GitHub Action) must send this as:
|
||||
# Authorization: Bearer <CRON_SECRET>
|
||||
# CRON_SECRET=your-cron-secret
|
||||
|
||||
# ── Instance Defaults ──
|
||||
# Default Git repo + branch for new tenant instances
|
||||
INSTANCE_GIT_REPO=https://github.com/cetinmustafa83/restov1
|
||||
INSTANCE_GIT_BRANCH=main
|
||||
# Demo instance duration in days
|
||||
INSTANCE_DEMO_DAYS=14
|
||||
# Grace period after demo expiry or subscription cancellation (days)
|
||||
INSTANCE_GRACE_DAYS=7
|
||||
Binary file not shown.
@@ -105,6 +105,7 @@ model Company {
|
||||
expenses Expense[]
|
||||
tickets Ticket[]
|
||||
agencyApplication AgencyApplication?
|
||||
instances Instance[]
|
||||
}
|
||||
|
||||
model Person {
|
||||
@@ -291,6 +292,10 @@ model License {
|
||||
stripeSubscriptionId String?
|
||||
stripePriceId String?
|
||||
|
||||
// Link to the tenant instance (1:1). Set when this license has a deployed
|
||||
// instance on the Dokploy server.
|
||||
instance Instance?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
activations LicenseActivation[]
|
||||
}
|
||||
@@ -892,3 +897,90 @@ model CustomFieldValue {
|
||||
|
||||
@@unique([entityType, entityId, fieldDefId])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tenant Instance Provisioning (PRD §6: SaaS demo + paid instances via Dokploy)
|
||||
// ---------------------------------------------------------------------------
|
||||
// AOS runs at dfmpanel.eu. Each license may have a tenant instance: an isolated
|
||||
// deployment of restov1 (or another product) running on the Dokploy server,
|
||||
// reachable under <random>.dfmpanel.eu (demo) or the customer's own domain.
|
||||
//
|
||||
// Lifecycle:
|
||||
// requested -> provisioning -> demo_active --(subscribe)--> active
|
||||
// | |
|
||||
// (expired/cancelled) (sub cancelled)
|
||||
// v v
|
||||
// grace <-------------------+
|
||||
// |
|
||||
// (grace elapsed)
|
||||
// v
|
||||
// deleting -> deleted
|
||||
// failed (retried, then alerts operator)
|
||||
|
||||
model Instance {
|
||||
id String @id @default(cuid())
|
||||
licenseId String? @unique // link to License (1:1, nullable for orphan cleanup)
|
||||
license License? @relation(fields: [licenseId], references: [id])
|
||||
companyId String // owning customer
|
||||
company Company @relation(fields: [companyId], references: [id])
|
||||
product String // 'restov1', 'aos-cms', ... (which template to deploy)
|
||||
status String @default("requested") // requested|provisioning|demo_active|active|grace|deleting|deleted|failed
|
||||
plan String @default("pro") // starter|pro|enterprise (resource limits)
|
||||
// Subdomain on the platform domain (e.g. "k7f3a9" -> k7f3a9.dfmpanel.eu)
|
||||
subdomain String @unique
|
||||
// Dokploy server id (for future multi-server; v1 single server)
|
||||
serverId String @default("default")
|
||||
// Lifecycle timestamps
|
||||
demoExpiresAt DateTime?
|
||||
graceEndsAt DateTime?
|
||||
deletedAt DateTime?
|
||||
// Custom domain (customer's own domain, verified via TXT)
|
||||
customDomain String?
|
||||
domainVerificationToken String?
|
||||
domainVerifiedAt DateTime?
|
||||
// Dokploy resource IDs (returned by Dokploy API on creation)
|
||||
dokployAppId String?
|
||||
dokployDbId String?
|
||||
dokployDbName String?
|
||||
dokployDbUser String?
|
||||
dokployDbPassword String? // stored encrypted in production; demo ok plaintext dev
|
||||
// Hetzner DNS record ID (null if using wildcard DNS)
|
||||
dnsRecordId String?
|
||||
// Admin login link (one-time, demo only)
|
||||
adminLoginToken String?
|
||||
adminLoginExpiresAt DateTime?
|
||||
// Provisioning log + metadata
|
||||
metadata String? // JSON: { gitRepo, gitBranch, imageTag, resources }
|
||||
lastError String?
|
||||
lastErrorAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
events InstanceEvent[]
|
||||
jobs ProvisioningJob[]
|
||||
}
|
||||
|
||||
model InstanceEvent {
|
||||
id String @id @default(cuid())
|
||||
instanceId String
|
||||
instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
|
||||
type String // provision.start, provision.dokploy.created, provision.dns.created, provision.ok, delete.start, delete.dokploy.removed, delete.ok, suspend, reactivate, domain.verify, domain.attached, etc.
|
||||
step String? // sub-step identifier
|
||||
result String // ok|error|pending
|
||||
details String? // JSON
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model ProvisioningJob {
|
||||
id String @id @default(cuid())
|
||||
instanceId String
|
||||
instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
|
||||
kind String // provision|delete|domain|suspend|reactivate
|
||||
step String // current step in the sequence
|
||||
attempts Int @default(0)
|
||||
maxAttempts Int @default(5)
|
||||
lastError String?
|
||||
status String @default("pending") // pending|running|done|failed
|
||||
nextRunAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
import { z } from 'zod'
|
||||
import { createDokployClientFromEnv } from '@/lib/dokploy'
|
||||
import { createHetznerDnsClientFromEnv } from '@/lib/hetzner-dns'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 300
|
||||
|
||||
const patchSchema = z.object({
|
||||
action: z.enum(['suspend', 'reactivate', 'extend', 'retry']),
|
||||
extendDays: z.number().int().min(1).max(90).optional(),
|
||||
})
|
||||
|
||||
// GET /api/aos/instances/[id] — instance detail with events + provisioning log
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const instance = await db.instance.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
company: { select: { id: true, name: true, email: true } },
|
||||
license: { select: { id: true, key: true, product: true, plan: true, billingCycle: true, netPriceCents: true, status: true, cancelledAt: true } },
|
||||
events: { orderBy: { createdAt: 'desc' }, take: 50 },
|
||||
jobs: { orderBy: { createdAt: 'desc' }, take: 10 },
|
||||
},
|
||||
})
|
||||
if (!instance) {
|
||||
return NextResponse.json({ error: 'instance not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ instance })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/aos/instances/[id] — suspend / reactivate / extend demo / retry provisioning
|
||||
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const parsed = patchSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const { action, extendDays } = parsed.data
|
||||
|
||||
const instance = await db.instance.findUnique({ where: { id } })
|
||||
if (!instance) {
|
||||
return NextResponse.json({ error: 'instance not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const dokploy = createDokployClientFromEnv()
|
||||
|
||||
if (action === 'suspend') {
|
||||
if (!['demo_active', 'active'].includes(instance.status)) {
|
||||
return NextResponse.json({ error: `cannot suspend instance in status ${instance.status}` }, { status: 400 })
|
||||
}
|
||||
const prevStatus = instance.status
|
||||
// Stop the Dokploy application
|
||||
if (dokploy && instance.dokployAppId) {
|
||||
try {
|
||||
await dokploy.stopApplication(instance.dokployAppId)
|
||||
} catch (e) {
|
||||
// Log but don't fail — the instance record is the source of truth
|
||||
await db.instanceEvent.create({
|
||||
data: {
|
||||
instanceId: id,
|
||||
type: 'suspend.dokploy.error',
|
||||
step: 'dokploy',
|
||||
result: 'error',
|
||||
details: JSON.stringify({ error: e instanceof Error ? e.message : 'unknown' }),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
await db.instance.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'grace',
|
||||
graceEndsAt: new Date(Date.now() + 7 * 86400000), // 7-day grace
|
||||
},
|
||||
})
|
||||
await db.instanceEvent.create({
|
||||
data: { instanceId: id, type: 'suspend', step: 'done', result: 'ok', details: JSON.stringify({ prevStatus }) },
|
||||
})
|
||||
return NextResponse.json({ ok: true, status: 'grace' })
|
||||
}
|
||||
|
||||
if (action === 'reactivate') {
|
||||
if (instance.status !== 'grace') {
|
||||
return NextResponse.json({ error: 'can only reactivate from grace' }, { status: 400 })
|
||||
}
|
||||
if (dokploy && instance.dokployAppId) {
|
||||
try {
|
||||
await dokploy.startApplication(instance.dokployAppId)
|
||||
} catch (e) {
|
||||
await db.instanceEvent.create({
|
||||
data: { instanceId: id, type: 'reactivate.dokploy.error', step: 'dokploy', result: 'error', details: JSON.stringify({ error: e instanceof Error ? e.message : 'unknown' }) },
|
||||
})
|
||||
}
|
||||
}
|
||||
// If the license is still in trial, keep demo_active; otherwise active
|
||||
const newStatus = instance.demoExpiresAt && new Date(instance.demoExpiresAt) > new Date() ? 'demo_active' : 'active'
|
||||
await db.instance.update({
|
||||
where: { id },
|
||||
data: { status: newStatus, graceEndsAt: null },
|
||||
})
|
||||
await db.instanceEvent.create({
|
||||
data: { instanceId: id, type: 'reactivate', step: 'done', result: 'ok', details: JSON.stringify({ status: newStatus }) },
|
||||
})
|
||||
return NextResponse.json({ ok: true, status: newStatus })
|
||||
}
|
||||
|
||||
if (action === 'extend') {
|
||||
if (instance.status !== 'demo_active') {
|
||||
return NextResponse.json({ error: 'can only extend demo_active instances' }, { status: 400 })
|
||||
}
|
||||
const days = extendDays ?? 7
|
||||
const newExpiry = new Date(Date.now() + days * 86400000)
|
||||
await db.instance.update({
|
||||
where: { id },
|
||||
data: { demoExpiresAt: newExpiry },
|
||||
})
|
||||
await db.instanceEvent.create({
|
||||
data: { instanceId: id, type: 'demo.extend', step: 'done', result: 'ok', details: JSON.stringify({ extendedBy: days, newExpiry }) },
|
||||
})
|
||||
return NextResponse.json({ ok: true, demoExpiresAt: newExpiry })
|
||||
}
|
||||
|
||||
if (action === 'retry') {
|
||||
// Reset to provisioning — the reconciler or manual call will re-provision
|
||||
if (instance.status !== 'failed') {
|
||||
return NextResponse.json({ error: 'can only retry failed instances' }, { status: 400 })
|
||||
}
|
||||
await db.instance.update({
|
||||
where: { id },
|
||||
data: { status: 'requested', lastError: null, lastErrorAt: null },
|
||||
})
|
||||
await db.instanceEvent.create({
|
||||
data: { instanceId: id, type: 'provision.retry', step: 'init', result: 'pending' },
|
||||
})
|
||||
return NextResponse.json({ ok: true, status: 'requested', message: 'Instance queued for re-provisioning' })
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'unknown action' }, { status: 400 })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/aos/instances/[id] — tear down instance (idempotent, resumable)
|
||||
// Removes: Dokploy domain → database → application + volumes → DNS record.
|
||||
// Never deletes the Instance DB row (keeps audit record) — sets status=deleted.
|
||||
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.status === 'deleted') {
|
||||
return NextResponse.json({ ok: true, message: 'already deleted', steps: [] })
|
||||
}
|
||||
|
||||
await db.instance.update({ where: { id }, data: { status: 'deleting' } })
|
||||
await db.instanceEvent.create({
|
||||
data: { instanceId: id, type: 'delete.start', step: 'init', result: 'pending' },
|
||||
})
|
||||
|
||||
const steps: string[] = []
|
||||
const dokploy = createDokployClientFromEnv()
|
||||
const dns = createHetznerDnsClientFromEnv()
|
||||
|
||||
// Parse metadata for Dokploy domain ID
|
||||
const meta = instance.metadata ? JSON.parse(instance.metadata) as { dokployDomainId?: string; url?: string } : {}
|
||||
|
||||
// 1. Remove Dokploy domain + database + application (idempotent)
|
||||
if (dokploy) {
|
||||
try {
|
||||
const result = await dokploy.teardownInstance({
|
||||
dokployAppId: instance.dokployAppId ?? undefined,
|
||||
dokployDbId: instance.dokployDbId ?? undefined,
|
||||
dnsDomainId: meta.dokployDomainId,
|
||||
})
|
||||
steps.push(...result.steps)
|
||||
await db.instanceEvent.create({
|
||||
data: { instanceId: id, type: 'delete.dokploy.done', step: 'dokploy', result: 'ok', details: JSON.stringify({ steps: result.steps }) },
|
||||
})
|
||||
} catch (e) {
|
||||
steps.push(`dokploy.error: ${e instanceof Error ? e.message : 'unknown'}`)
|
||||
await db.instanceEvent.create({
|
||||
data: { instanceId: id, type: 'delete.dokploy.error', step: 'dokploy', result: 'error', details: JSON.stringify({ error: e instanceof Error ? e.message : 'unknown' }) },
|
||||
})
|
||||
}
|
||||
} else {
|
||||
steps.push('dokploy.skipped (not configured)')
|
||||
}
|
||||
|
||||
// 2. Remove DNS A record (if per-instance, not wildcard)
|
||||
if (dns && instance.dnsRecordId && instance.dnsRecordId !== 'wildcard') {
|
||||
try {
|
||||
await dns.deleteRecord(instance.dnsRecordId)
|
||||
steps.push('dns.removed')
|
||||
await db.instanceEvent.create({
|
||||
data: { instanceId: id, type: 'delete.dns.removed', step: 'dns', result: 'ok', details: JSON.stringify({ recordId: instance.dnsRecordId }) },
|
||||
})
|
||||
} catch (e) {
|
||||
steps.push(`dns.error: ${e instanceof Error ? e.message : 'unknown'}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Mark deleted (keep the row for audit — GoBD)
|
||||
const now = new Date()
|
||||
await db.instance.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'deleted',
|
||||
deletedAt: now,
|
||||
dokployAppId: null,
|
||||
dokployDbId: null,
|
||||
dnsRecordId: null,
|
||||
dokployDbPassword: null, // clear secrets
|
||||
adminLoginToken: null,
|
||||
},
|
||||
})
|
||||
|
||||
// 4. Unlink from license (keep license, just remove instance ref)
|
||||
if (instance.licenseId) {
|
||||
await db.license.update({
|
||||
where: { id: instance.licenseId },
|
||||
data: { instance: { disconnect: true } },
|
||||
})
|
||||
}
|
||||
|
||||
await db.instanceEvent.create({
|
||||
data: { instanceId: id, type: 'delete.ok', step: 'done', result: 'ok', details: JSON.stringify({ steps, deletedAt: now }) },
|
||||
})
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'instance.delete',
|
||||
entity: 'Instance',
|
||||
entityId: id,
|
||||
after: JSON.stringify({ subdomain: instance.subdomain, steps }),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ ok: true, status: 'deleted', steps, deletedAt: now })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
import { createDokployClientFromEnv } from '@/lib/dokploy'
|
||||
import { createHetznerDnsClientFromEnv } from '@/lib/hetzner-dns'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 300
|
||||
|
||||
// POST /api/aos/instances/cleanup
|
||||
// Cron job: run daily. Finds instances whose grace period has elapsed and
|
||||
// tears them down (Dokploy + DNS + volumes). Also finds expired demos and
|
||||
// moves them to grace. Idempotent — safe to call multiple times.
|
||||
//
|
||||
// In production, this is triggered by a cron job (e.g. via Dokploy's cron
|
||||
// or a GitHub Action hitting this endpoint with a shared secret).
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
// Simple auth: shared secret in header (cron job includes it)
|
||||
const authHeader = req.headers.get('authorization')
|
||||
const expectedSecret = process.env.CRON_SECRET
|
||||
if (expectedSecret && authHeader !== `Bearer ${expectedSecret}`) {
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const results = {
|
||||
expiredDemos: 0,
|
||||
graceExpired: 0,
|
||||
deleted: 0,
|
||||
failed: 0,
|
||||
details: [] as string[],
|
||||
}
|
||||
|
||||
// 1. Move expired demos → grace (demoExpiresAt < now)
|
||||
const expiredDemos = await db.instance.findMany({
|
||||
where: {
|
||||
status: 'demo_active',
|
||||
demoExpiresAt: { lt: now },
|
||||
},
|
||||
})
|
||||
for (const inst of expiredDemos) {
|
||||
await db.instance.update({
|
||||
where: { id: inst.id },
|
||||
data: {
|
||||
status: 'grace',
|
||||
graceEndsAt: new Date(now.getTime() + 7 * 86400000), // 7-day grace
|
||||
},
|
||||
})
|
||||
await db.instanceEvent.create({
|
||||
data: {
|
||||
instanceId: inst.id,
|
||||
type: 'demo.expired',
|
||||
step: 'lifecycle',
|
||||
result: 'ok',
|
||||
details: JSON.stringify({ demoExpiresAt: inst.demoExpiresAt, graceEndsAt: now.getTime() + 7 * 86400000 }),
|
||||
},
|
||||
})
|
||||
results.expiredDemos++
|
||||
results.details.push(`${inst.subdomain}: demo → grace`)
|
||||
}
|
||||
|
||||
// 2. Delete instances whose grace has elapsed (graceEndsAt < now)
|
||||
const graceExpired = await db.instance.findMany({
|
||||
where: {
|
||||
status: 'grace',
|
||||
graceEndsAt: { lt: now },
|
||||
},
|
||||
})
|
||||
results.graceExpired = graceExpired.length
|
||||
|
||||
const dokploy = createDokployClientFromEnv()
|
||||
const dns = createHetznerDnsClientFromEnv()
|
||||
|
||||
for (const inst of graceExpired) {
|
||||
try {
|
||||
await db.instance.update({ where: { id: inst.id }, data: { status: 'deleting' } })
|
||||
await db.instanceEvent.create({
|
||||
data: { instanceId: inst.id, type: 'cleanup.delete.start', step: 'reconciler', result: 'pending' },
|
||||
})
|
||||
|
||||
const meta = inst.metadata ? JSON.parse(inst.metadata) as { dokployDomainId?: string } : {}
|
||||
const steps: string[] = []
|
||||
|
||||
// Teardown Dokploy resources
|
||||
if (dokploy) {
|
||||
try {
|
||||
const r = await dokploy.teardownInstance({
|
||||
dokployAppId: inst.dokployAppId ?? undefined,
|
||||
dokployDbId: inst.dokployDbId ?? undefined,
|
||||
dnsDomainId: meta.dokployDomainId,
|
||||
})
|
||||
steps.push(...r.steps)
|
||||
} catch (e) {
|
||||
steps.push(`dokploy.error: ${e instanceof Error ? e.message : 'unknown'}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove DNS record
|
||||
if (dns && inst.dnsRecordId && inst.dnsRecordId !== 'wildcard') {
|
||||
try {
|
||||
await dns.deleteRecord(inst.dnsRecordId)
|
||||
steps.push('dns.removed')
|
||||
} catch (e) {
|
||||
steps.push(`dns.error: ${e instanceof Error ? e.message : 'unknown'}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Mark deleted
|
||||
await db.instance.update({
|
||||
where: { id: inst.id },
|
||||
data: {
|
||||
status: 'deleted',
|
||||
deletedAt: now,
|
||||
dokployAppId: null,
|
||||
dokployDbId: null,
|
||||
dokployDbPassword: null,
|
||||
dnsRecordId: null,
|
||||
adminLoginToken: null,
|
||||
},
|
||||
})
|
||||
|
||||
// Unlink license
|
||||
if (inst.licenseId) {
|
||||
await db.license.update({
|
||||
where: { id: inst.licenseId },
|
||||
data: { instance: { disconnect: true } },
|
||||
})
|
||||
}
|
||||
|
||||
await db.instanceEvent.create({
|
||||
data: { instanceId: inst.id, type: 'cleanup.delete.ok', step: 'reconciler', result: 'ok', details: JSON.stringify({ steps }) },
|
||||
})
|
||||
results.deleted++
|
||||
results.details.push(`${inst.subdomain}: deleted (${steps.join(', ')})`)
|
||||
} catch (e) {
|
||||
results.failed++
|
||||
results.details.push(`${inst.subdomain}: FAILED (${e instanceof Error ? e.message : 'unknown'})`)
|
||||
await db.instance.update({
|
||||
where: { id: inst.id },
|
||||
data: {
|
||||
status: 'failed',
|
||||
lastError: e instanceof Error ? e.message : 'cleanup failed',
|
||||
lastErrorAt: now,
|
||||
},
|
||||
})
|
||||
await db.instanceEvent.create({
|
||||
data: { instanceId: inst.id, type: 'cleanup.delete.error', step: 'reconciler', result: 'error', details: JSON.stringify({ error: e instanceof Error ? e.message : 'unknown' }) },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Alert on failed instances
|
||||
const failedInstances = await db.instance.count({ where: { status: 'failed' } })
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
runAt: now,
|
||||
...results,
|
||||
failedInstances,
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
createDokployClientFromEnv,
|
||||
type ProvisionInstanceInput,
|
||||
} from '@/lib/dokploy'
|
||||
import {
|
||||
createHetznerDnsClientFromEnv,
|
||||
generateSubdomain,
|
||||
isReservedSubdomain,
|
||||
} from '@/lib/hetzner-dns'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 300 // 5 min — provisioning can take a while
|
||||
|
||||
const PLATFORM_DOMAIN = process.env.PLATFORM_DOMAIN ?? 'dfmpanel.eu'
|
||||
const DEMO_DAYS = 14
|
||||
const GRACE_DAYS = 7
|
||||
|
||||
const createSchema = z.object({
|
||||
companyId: z.string().min(1),
|
||||
licenseId: z.string().min(1),
|
||||
product: z.string().min(1).default('restov1'),
|
||||
plan: z.enum(['starter', 'pro', 'enterprise']).default('pro'),
|
||||
gitRepo: z.string().url().default('https://github.com/cetinmustafa83/restov1'),
|
||||
gitBranch: z.string().default('main'),
|
||||
// Extra env vars to inject into the instance (beyond the standard ones)
|
||||
envVars: z.record(z.string(), z.string()).optional(),
|
||||
// If true, start as a demo (14-day expiry). If false, start as active (paid).
|
||||
isDemo: z.boolean().default(true),
|
||||
})
|
||||
|
||||
// GET /api/aos/instances — list all instances (operator console)
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
const instances = await db.instance.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
company: { select: { id: true, name: true, email: true } },
|
||||
license: { select: { id: true, key: true, product: true, plan: true } },
|
||||
_count: { select: { events: true, jobs: true } },
|
||||
},
|
||||
})
|
||||
|
||||
const stats = {
|
||||
total: instances.length,
|
||||
provisioning: instances.filter((i) => i.status === 'provisioning').length,
|
||||
demoActive: instances.filter((i) => i.status === 'demo_active').length,
|
||||
active: instances.filter((i) => i.status === 'active').length,
|
||||
grace: instances.filter((i) => i.status === 'grace').length,
|
||||
deleting: instances.filter((i) => i.status === 'deleting').length,
|
||||
deleted: instances.filter((i) => i.status === 'deleted').length,
|
||||
failed: instances.filter((i) => i.status === 'failed').length,
|
||||
// Demos expiring in next 48h
|
||||
expiringSoon: instances.filter(
|
||||
(i) => i.demoExpiresAt && new Date(i.demoExpiresAt) < new Date(Date.now() + 48 * 3600000) && i.status === 'demo_active'
|
||||
).length,
|
||||
}
|
||||
|
||||
return NextResponse.json({ instances, stats })
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/aos/instances — provision a new tenant instance
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const parsed = createSchema.safeParse(await req.json())
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
const { companyId, licenseId, product, plan, gitRepo, gitBranch, envVars, isDemo } = parsed.data
|
||||
|
||||
// Verify company + license exist and belong together
|
||||
const company = await db.company.findUnique({ where: { id: companyId } })
|
||||
if (!company) return NextResponse.json({ error: 'company not found' }, { status: 404 })
|
||||
|
||||
const license = await db.license.findUnique({ where: { id: licenseId } })
|
||||
if (!license) return NextResponse.json({ error: 'license not found' }, { status: 404 })
|
||||
if (license.companyId !== companyId) {
|
||||
return NextResponse.json({ error: 'license does not belong to this company' }, { status: 400 })
|
||||
}
|
||||
if (license.product !== product) {
|
||||
return NextResponse.json({ error: 'license product does not match instance product' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check that no live instance already exists for this license
|
||||
const existing = await db.instance.findFirst({
|
||||
where: { licenseId, status: { notIn: ['deleted'] } },
|
||||
})
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: 'license already has an active instance', instanceId: existing.id }, { status: 409 })
|
||||
}
|
||||
|
||||
// Generate unique subdomain (collision-checked + reserved list)
|
||||
let subdomain = generateSubdomain()
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (isReservedSubdomain(subdomain)) {
|
||||
subdomain = generateSubdomain()
|
||||
continue
|
||||
}
|
||||
const collision = await db.instance.findUnique({ where: { subdomain } })
|
||||
if (!collision) break
|
||||
subdomain = generateSubdomain()
|
||||
}
|
||||
|
||||
const fullDomain = `${subdomain}.${PLATFORM_DOMAIN}`
|
||||
|
||||
// Create instance record (status = requested)
|
||||
const instance = await db.instance.create({
|
||||
data: {
|
||||
licenseId,
|
||||
companyId,
|
||||
product,
|
||||
status: 'requested',
|
||||
plan,
|
||||
subdomain,
|
||||
demoExpiresAt: isDemo ? new Date(Date.now() + DEMO_DAYS * 86400000) : null,
|
||||
},
|
||||
})
|
||||
|
||||
await db.instanceEvent.create({
|
||||
data: {
|
||||
instanceId: instance.id,
|
||||
type: 'provision.start',
|
||||
step: 'init',
|
||||
result: 'ok',
|
||||
details: JSON.stringify({ subdomain, product, plan, isDemo, gitRepo }),
|
||||
},
|
||||
})
|
||||
|
||||
// --- Provision via Dokploy ---
|
||||
try {
|
||||
await db.instance.update({ where: { id: instance.id }, data: { status: 'provisioning' } })
|
||||
|
||||
const dokploy = createDokployClientFromEnv()
|
||||
if (!dokploy) {
|
||||
throw new Error('Dokploy client not configured (DOKPLOY_URL / DOKPLOY_API_KEY missing)')
|
||||
}
|
||||
|
||||
// Instance env vars: license key + AOS connection + product-specific
|
||||
const instanceEnv: Record<string, string> = {
|
||||
AOS_LICENSE_SERVER_URL: process.env.AOS_PUBLIC_URL ?? `https://${PLATFORM_DOMAIN}`,
|
||||
AOS_PRODUCT: product,
|
||||
AOS_LICENSE_KEY: license.key,
|
||||
AOS_LICENSE_DOMAIN: fullDomain,
|
||||
AOS_OFFLINE_GRACE_HOURS: '72',
|
||||
NODE_ENV: 'production',
|
||||
...envVars,
|
||||
}
|
||||
|
||||
const provisionInput: ProvisionInstanceInput = {
|
||||
name: `${product}-${subdomain}`,
|
||||
subdomain: fullDomain,
|
||||
gitRepo,
|
||||
gitBranch,
|
||||
envVars: instanceEnv,
|
||||
databaseName: `${product}_${subdomain}`.replace(/-/g, '_').slice(0, 40),
|
||||
cpuLimit: plan === 'enterprise' ? '2' : plan === 'pro' ? '1' : '0.5',
|
||||
memoryLimit: plan === 'enterprise' ? '2048m' : plan === 'pro' ? '1024m' : '512m',
|
||||
}
|
||||
|
||||
await db.instanceEvent.create({
|
||||
data: {
|
||||
instanceId: instance.id,
|
||||
type: 'provision.dokploy.start',
|
||||
step: 'dokploy',
|
||||
result: 'pending',
|
||||
details: JSON.stringify({ gitRepo, gitBranch }),
|
||||
},
|
||||
})
|
||||
|
||||
const result = await dokploy.provisionInstance(provisionInput)
|
||||
|
||||
await db.instanceEvent.create({
|
||||
data: {
|
||||
instanceId: instance.id,
|
||||
type: 'provision.dokploy.created',
|
||||
step: 'dokploy',
|
||||
result: 'ok',
|
||||
details: JSON.stringify({
|
||||
appId: result.app.applicationId,
|
||||
dbId: result.database?.databaseId,
|
||||
domainId: result.domain.domainId,
|
||||
url: result.url,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
// --- DNS A record ---
|
||||
let dnsRecordId: string | null = null
|
||||
const dns = createHetznerDnsClientFromEnv()
|
||||
if (dns) {
|
||||
try {
|
||||
const record = await dns.createARecord(subdomain)
|
||||
dnsRecordId = record.recordId
|
||||
await db.instanceEvent.create({
|
||||
data: {
|
||||
instanceId: instance.id,
|
||||
type: 'provision.dns.created',
|
||||
step: 'dns',
|
||||
result: 'ok',
|
||||
details: JSON.stringify({ recordId: record.recordId, subdomain }),
|
||||
},
|
||||
})
|
||||
} catch (dnsErr) {
|
||||
// DNS is non-fatal if using wildcard; log but continue
|
||||
await db.instanceEvent.create({
|
||||
data: {
|
||||
instanceId: instance.id,
|
||||
type: 'provision.dns.skipped',
|
||||
step: 'dns',
|
||||
result: 'error',
|
||||
details: JSON.stringify({ error: dnsErr instanceof Error ? dnsErr.message : 'unknown' }),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Update instance with resource IDs + activate ---
|
||||
const now = new Date()
|
||||
await db.instance.update({
|
||||
where: { id: instance.id },
|
||||
data: {
|
||||
status: isDemo ? 'demo_active' : 'active',
|
||||
dokployAppId: result.app.applicationId,
|
||||
dokployDbId: result.database?.databaseId ?? null,
|
||||
dokployDbName: result.database?.database ?? null,
|
||||
dokployDbUser: result.database?.username ?? null,
|
||||
dokployDbPassword: result.database?.password ?? null,
|
||||
dnsRecordId,
|
||||
metadata: JSON.stringify({
|
||||
gitRepo,
|
||||
gitBranch,
|
||||
dokployDomainId: result.domain.domainId,
|
||||
url: result.url,
|
||||
provisionedAt: now.toISOString(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
// --- Link instance to license ---
|
||||
await db.license.update({
|
||||
where: { id: licenseId },
|
||||
data: { instance: { connect: { id: instance.id } } },
|
||||
})
|
||||
|
||||
await db.instanceEvent.create({
|
||||
data: {
|
||||
instanceId: instance.id,
|
||||
type: 'provision.ok',
|
||||
step: 'done',
|
||||
result: 'ok',
|
||||
details: JSON.stringify({ url: result.url, status: isDemo ? 'demo_active' : 'active' }),
|
||||
},
|
||||
})
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actor: 'admin:ui',
|
||||
action: 'instance.provision',
|
||||
entity: 'Instance',
|
||||
entityId: instance.id,
|
||||
after: JSON.stringify({
|
||||
subdomain,
|
||||
url: result.url,
|
||||
product,
|
||||
company: company.name,
|
||||
isDemo,
|
||||
}),
|
||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
instanceId: instance.id,
|
||||
subdomain,
|
||||
url: result.url,
|
||||
status: isDemo ? 'demo_active' : 'active',
|
||||
demoExpiresAt: instance.demoExpiresAt,
|
||||
}, { status: 201 })
|
||||
} catch (provErr) {
|
||||
// Provisioning failed — mark as failed, keep the record for retry
|
||||
await db.instance.update({
|
||||
where: { id: instance.id },
|
||||
data: {
|
||||
status: 'failed',
|
||||
lastError: provErr instanceof Error ? provErr.message : 'provisioning failed',
|
||||
lastErrorAt: new Date(),
|
||||
},
|
||||
})
|
||||
await db.instanceEvent.create({
|
||||
data: {
|
||||
instanceId: instance.id,
|
||||
type: 'provision.error',
|
||||
step: 'dokploy',
|
||||
result: 'error',
|
||||
details: JSON.stringify({ error: provErr instanceof Error ? provErr.message : 'unknown' }),
|
||||
},
|
||||
})
|
||||
return NextResponse.json({
|
||||
error: 'provisioning failed',
|
||||
details: provErr instanceof Error ? provErr.message : 'unknown',
|
||||
instanceId: instance.id,
|
||||
}, { status: 500 })
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||
return NextResponse.json({ error: msg }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,40 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
||||
},
|
||||
})
|
||||
|
||||
// --- Instance lifecycle hook (PRD §5: trial cancel → instance deletion) ---
|
||||
// If cancelling during the 14-day trial (immediate), schedule the tenant
|
||||
// instance for deletion. The actual Dokploy + DNS teardown happens via
|
||||
// the instance DELETE endpoint (idempotent job), not here directly —
|
||||
// we fire-and-forget so the cancel response is fast.
|
||||
let instanceDeleted = false
|
||||
if ((immediate || inTrial) && license.id) {
|
||||
const inst = await db.instance.findFirst({
|
||||
where: { licenseId: license.id, status: { notIn: ['deleted', 'deleting'] } },
|
||||
})
|
||||
if (inst) {
|
||||
// Mark instance as grace → will be picked up by the deletion job.
|
||||
// For immediate trial cancel, set a short grace (1 hour) so the client
|
||||
// can still access data briefly if they change their mind, then it's gone.
|
||||
await db.instance.update({
|
||||
where: { id: inst.id },
|
||||
data: {
|
||||
status: 'grace',
|
||||
graceEndsAt: new Date(Date.now() + 3600000), // 1 hour
|
||||
},
|
||||
})
|
||||
await db.instanceEvent.create({
|
||||
data: {
|
||||
instanceId: inst.id,
|
||||
type: 'license.cancel.trial',
|
||||
step: 'hook',
|
||||
result: 'ok',
|
||||
details: JSON.stringify({ licenseId: license.id, graceEndsIn: '1h' }),
|
||||
},
|
||||
})
|
||||
instanceDeleted = true
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
cancelledAt: now,
|
||||
@@ -109,8 +143,9 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
||||
inTrial: !!inTrial,
|
||||
immediate: immediate || inTrial,
|
||||
cancelledModules,
|
||||
instanceScheduled: instanceDeleted,
|
||||
message: inTrial
|
||||
? 'Probezeit-Kündigung — sofort wirksam, keine Abbuchung.'
|
||||
? 'Probezeit-Kündigung — sofort wirksam, keine Abbuchung. Instanz wird gelöscht.'
|
||||
: `Kündigung eingegangen. Wirksam zum ${effectiveAt.toLocaleDateString('de-DE')} (3-Monate-Frist).`,
|
||||
})
|
||||
} catch (err) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { TimesheetView } from './views/timesheet'
|
||||
import { SupportView } from './views/support'
|
||||
import { LicensesView } from './views/licenses'
|
||||
import { AgencyView } from './views/agency'
|
||||
import { InstancesView } from './views/instances'
|
||||
import { ContractsView } from './views/contracts'
|
||||
import { ExpensesView } from './views/expenses'
|
||||
import { FinanceView } from './views/finance'
|
||||
@@ -73,6 +74,7 @@ export function AppShell() {
|
||||
{view === 'support' && <SupportView />}
|
||||
{view === 'licenses' && <LicensesView />}
|
||||
{view === 'agency' && <AgencyView />}
|
||||
{view === 'instances' && <InstancesView />}
|
||||
{view === 'contracts' && <ContractsView />}
|
||||
{view === 'expenses' && <ExpensesView />}
|
||||
{view === 'finance' && <FinanceView />}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
SlidersHorizontal,
|
||||
CalendarDays,
|
||||
Database,
|
||||
Server,
|
||||
} from 'lucide-react'
|
||||
import { useAos, type ViewId } from '@/store/aos'
|
||||
import {
|
||||
@@ -50,6 +51,7 @@ const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: st
|
||||
{ id: 'support', label: 'Support', icon: LifeBuoy, hint: 'Tickets & SLA' },
|
||||
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, hint: 'Ausgestellt & Erneuerungen' },
|
||||
{ id: 'agency', label: 'Bayis', icon: Users, hint: 'Reseller-Bewerbungen & Rabatte' },
|
||||
{ id: 'instances', label: 'Instanzen', icon: Server, hint: 'Tenant-Instanzen & Provisioning' },
|
||||
{ id: 'contracts', label: 'Verträge', icon: FileSignature, hint: 'Werkvertrag, AVV, NDA' },
|
||||
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, hint: 'Belastbar & DATEV' },
|
||||
{ id: 'finance', label: 'Finanzen', icon: Receipt, hint: 'Rechnungen & E-Rechnung' },
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
Database,
|
||||
RefreshCw,
|
||||
Landmark,
|
||||
Server,
|
||||
} from 'lucide-react'
|
||||
import { useAos, type ViewId } from '@/store/aos'
|
||||
import { translate } from '@/lib/i18n'
|
||||
@@ -68,6 +69,7 @@ const NAV: {
|
||||
items: [
|
||||
{ id: 'licenses', label: 'Lizenzen', icon: KeyRound, permission: 'licenses:read' },
|
||||
{ id: 'agency', label: 'Bayis', icon: Users, permission: 'admin' },
|
||||
{ id: 'instances', label: 'Instanzen', icon: Server, permission: 'admin' },
|
||||
{ id: 'contracts', label: 'Verträge', icon: FileSignature, permission: 'contracts:read' },
|
||||
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, permission: 'expenses:read' },
|
||||
{ id: 'finance', label: 'Finanzen', icon: Receipt, permission: 'finance:read' },
|
||||
|
||||
@@ -29,6 +29,7 @@ const TITLES: Record<ViewId, { title: string; crumb: string }> = {
|
||||
support: { title: 'Support', crumb: 'Lieferung / Tickets' },
|
||||
licenses: { title: 'Lizenzen', crumb: 'Betrieb / Lizenzen' },
|
||||
agency: { title: 'Bayis', crumb: 'Betrieb / Bayis' },
|
||||
instances: { title: 'Instanzen', crumb: 'Betrieb / Instanzen' },
|
||||
contracts: { title: 'Verträge', crumb: 'Betrieb / Verträge' },
|
||||
expenses: { title: 'Ausgaben', crumb: 'Betrieb / Ausgaben' },
|
||||
finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' },
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* Dokploy API Client
|
||||
* -------------------
|
||||
* Manages tenant instances on the Dokploy server: create applications from
|
||||
* a Git repo, deploy, attach domains, create databases, suspend/delete.
|
||||
*
|
||||
* Config (env):
|
||||
* DOKPLOY_URL - e.g. https://cp.project-url.eu
|
||||
* DOKPLOY_API_KEY - bearer token
|
||||
* DOKPLOY_SERVER_ID - the Dokploy server ID to deploy to (default "default")
|
||||
* DOKPLOY_PROJECT_ID - the Dokploy project to put tenant apps under
|
||||
*
|
||||
* NOTE: Dokploy API endpoints should be verified against the current API docs
|
||||
* at implementation time. The endpoints below are based on the Dokploy REST API.
|
||||
* See: https://docs.dokploy.com/docs/api
|
||||
*/
|
||||
|
||||
export interface DokployConfig {
|
||||
url: string
|
||||
apiKey: string
|
||||
serverId?: string
|
||||
projectId?: string
|
||||
}
|
||||
|
||||
export interface DokployApp {
|
||||
applicationId: string
|
||||
name: string
|
||||
status?: string
|
||||
url?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export interface DokployDatabase {
|
||||
databaseId: string
|
||||
name: string
|
||||
type: string // postgres|mysql|redis|...
|
||||
host: string
|
||||
port: number
|
||||
database: string
|
||||
username: string
|
||||
password: string
|
||||
externalUrl?: string
|
||||
}
|
||||
|
||||
export interface DokployDomain {
|
||||
domainId: string
|
||||
hostName: string
|
||||
applicationId: string
|
||||
https: boolean
|
||||
certificateStatus?: string
|
||||
}
|
||||
|
||||
export interface ProvisionInstanceInput {
|
||||
name: string
|
||||
subdomain: string
|
||||
gitRepo: string // e.g. https://github.com/cetinmustafa83/restov1
|
||||
gitBranch: string // e.g. main
|
||||
buildPath?: string // e.g. / (repo root, default)
|
||||
envVars: Record<string, string>
|
||||
// Resource limits (Dokploy applies these as container constraints)
|
||||
cpuLimit?: string // e.g. "0.5" = 50% of one core
|
||||
memoryLimit?: string // e.g. "512m"
|
||||
// Database
|
||||
databaseName?: string // if set, a Postgres DB is created for this instance
|
||||
}
|
||||
|
||||
export interface ProvisionInstanceResult {
|
||||
app: DokployApp
|
||||
database?: DokployDatabase
|
||||
domain: DokployDomain
|
||||
url: string
|
||||
}
|
||||
|
||||
class DokployError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
public endpoint: string,
|
||||
public body?: unknown
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'DokployError'
|
||||
}
|
||||
}
|
||||
|
||||
export function createDokployClient(config: DokployConfig) {
|
||||
const baseUrl = config.url.replace(/\/$/, '')
|
||||
const serverId = config.serverId ?? 'default'
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
}
|
||||
|
||||
async function call<T = unknown>(endpoint: string, body?: Record<string, unknown>, method = 'POST'): Promise<T> {
|
||||
const url = `${baseUrl}${endpoint}`
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
const text = await res.text()
|
||||
let json: unknown
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null
|
||||
} catch {
|
||||
json = text
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new DokployError(
|
||||
`Dokploy API ${endpoint} failed: HTTP ${res.status}`,
|
||||
res.status,
|
||||
endpoint,
|
||||
json
|
||||
)
|
||||
}
|
||||
return json as T
|
||||
}
|
||||
|
||||
// --- Applications ---
|
||||
|
||||
async function createApplication(input: {
|
||||
name: string
|
||||
description?: string
|
||||
gitRepo: string
|
||||
gitBranch: string
|
||||
buildPath?: string
|
||||
envVars?: Record<string, string>
|
||||
cpuLimit?: string
|
||||
memoryLimit?: string
|
||||
}): Promise<DokployApp> {
|
||||
const body: Record<string, unknown> = {
|
||||
name: input.name,
|
||||
description: input.description ?? `Tenant instance: ${input.name}`,
|
||||
serverId,
|
||||
projectName: config.projectId ?? 'default',
|
||||
sourceType: 'git',
|
||||
gitRepository: input.gitRepo,
|
||||
gitBranch: input.gitBranch,
|
||||
buildPath: input.buildPath ?? '/',
|
||||
// Dokploy expects env vars as a JSON string
|
||||
env: JSON.stringify(input.envVars ?? {}),
|
||||
cpuLimit: input.cpuLimit ?? '0.5',
|
||||
memoryLimit: input.memoryLimit ?? '512m',
|
||||
}
|
||||
const res = await call<{ applicationId?: string; app?: { applicationId: string }; [k: string]: unknown }>('/api/application.create', body)
|
||||
const applicationId = res.applicationId ?? (res.app as { applicationId: string } | undefined)?.applicationId
|
||||
if (!applicationId) {
|
||||
throw new DokployError('Dokploy createApplication returned no applicationId', 200, '/api/application.create', res)
|
||||
}
|
||||
return { applicationId, name: input.name, status: 'created' }
|
||||
}
|
||||
|
||||
async function deployApplication(applicationId: string): Promise<{ deployId: string; status: string }> {
|
||||
const res = await call<{ deployId?: string; jobId?: string; status?: string }>('/api/application.deploy', { applicationId })
|
||||
return { deployId: res.deployId ?? res.jobId ?? '', status: res.status ?? 'deploying' }
|
||||
}
|
||||
|
||||
async function getApplication(applicationId: string): Promise<DokployApp & { status?: string; url?: string }> {
|
||||
const res = await call<DokployApp & { applicationStatus?: string; previewDomain?: string }>(
|
||||
`/api/application.one`,
|
||||
{ applicationId },
|
||||
)
|
||||
return {
|
||||
applicationId: res.applicationId,
|
||||
name: res.name,
|
||||
status: res.applicationStatus ?? res.status,
|
||||
url: res.previewDomain ?? res.url,
|
||||
createdAt: res.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteApplication(applicationId: string): Promise<void> {
|
||||
await call('/api/application.delete', { applicationId, deleteVolumes: true })
|
||||
}
|
||||
|
||||
async function stopApplication(applicationId: string): Promise<void> {
|
||||
await call('/api/application.stop', { applicationId })
|
||||
}
|
||||
|
||||
async function startApplication(applicationId: string): Promise<void> {
|
||||
await call('/api/application.start', { applicationId })
|
||||
}
|
||||
|
||||
async function updateEnv(applicationId: string, envVars: Record<string, string>): Promise<void> {
|
||||
await call('/api/application.update', {
|
||||
applicationId,
|
||||
env: JSON.stringify(envVars),
|
||||
})
|
||||
}
|
||||
|
||||
// --- Databases (Postgres) ---
|
||||
|
||||
async function createPostgresDatabase(input: {
|
||||
name: string
|
||||
appName: string // the application this DB belongs to
|
||||
description?: string
|
||||
externalUrl?: boolean
|
||||
}): Promise<DokployDatabase> {
|
||||
const body: Record<string, unknown> = {
|
||||
name: input.name,
|
||||
appName: input.appName,
|
||||
description: input.description ?? `DB for ${input.appName}`,
|
||||
serverId,
|
||||
projectName: config.projectId ?? 'default',
|
||||
externalUrl: input.externalUrl ?? false,
|
||||
}
|
||||
const res = await call<Record<string, unknown>>('/api/database.postgres.create', body)
|
||||
// The response shape varies between Dokploy versions; handle both.
|
||||
const db = (res.database as Record<string, unknown> | undefined) ?? res
|
||||
const databaseId = (db.databaseId as string | undefined) ?? (res.databaseId as string | undefined)
|
||||
if (!databaseId) {
|
||||
throw new DokployError('Dokploy createDatabase returned no databaseId', 200, '/api/database.postgres.create', res)
|
||||
}
|
||||
return {
|
||||
databaseId,
|
||||
name: (db.name as string | undefined) ?? input.name,
|
||||
type: 'postgres',
|
||||
host: (db.host as string | undefined) ?? 'postgres',
|
||||
port: (db.port as number | undefined) ?? 5432,
|
||||
database: (db.database as string | undefined) ?? input.name,
|
||||
username: (db.username as string | undefined) ?? 'postgres',
|
||||
password: (db.password as string | undefined) ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDatabase(databaseId: string): Promise<void> {
|
||||
await call('/api/database.postgres.delete', { databaseId })
|
||||
}
|
||||
|
||||
// --- Domains ---
|
||||
|
||||
async function createDomain(input: {
|
||||
applicationId: string
|
||||
hostName: string
|
||||
path?: string
|
||||
port?: number
|
||||
https?: boolean
|
||||
certificateType?: 'letsencrypt' | 'none'
|
||||
}): Promise<DokployDomain> {
|
||||
const body: Record<string, unknown> = {
|
||||
applicationId: input.applicationId,
|
||||
hostName: input.hostName,
|
||||
path: input.path ?? '/',
|
||||
port: input.port ?? 3000,
|
||||
https: input.https ?? true,
|
||||
certificateType: input.certificateType ?? 'letsencrypt',
|
||||
}
|
||||
const res = await call<{ domainId?: string; id?: string; hostName?: string; https?: boolean }>('/api/domain.create', body)
|
||||
const domainId = res.domainId ?? res.id
|
||||
if (!domainId) {
|
||||
throw new DokployError('Dokploy createDomain returned no domainId', 200, '/api/domain.create', res)
|
||||
}
|
||||
return {
|
||||
domainId,
|
||||
hostName: input.hostName,
|
||||
applicationId: input.applicationId,
|
||||
https: input.https ?? true,
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDomain(domainId: string): Promise<void> {
|
||||
await call('/api/domain.delete', { domainId })
|
||||
}
|
||||
|
||||
// --- High-level: provision a full instance ---
|
||||
|
||||
async function provisionInstance(input: ProvisionInstanceInput): Promise<ProvisionInstanceResult> {
|
||||
// 1. Create application from Git repo
|
||||
const app = await createApplication({
|
||||
name: input.name,
|
||||
gitRepo: input.gitRepo,
|
||||
gitBranch: input.gitBranch,
|
||||
buildPath: '/',
|
||||
envVars: input.envVars,
|
||||
cpuLimit: input.cpuLimit,
|
||||
memoryLimit: input.memoryLimit,
|
||||
})
|
||||
|
||||
// 2. (Optional) Create Postgres database
|
||||
let database: DokployDatabase | undefined
|
||||
if (input.databaseName) {
|
||||
database = await createPostgresDatabase({
|
||||
name: input.databaseName,
|
||||
appName: input.name,
|
||||
})
|
||||
// Inject DATABASE_URL into the app's env
|
||||
const dbUrl = `postgresql://${database.username}:${database.password}@${database.host}:${database.port}/${database.database}`
|
||||
await updateEnv(app.applicationId, {
|
||||
...input.envVars,
|
||||
DATABASE_URL: dbUrl,
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Attach domain (subdomain.platform-domain)
|
||||
const hostName = input.subdomain
|
||||
const domain = await createDomain({
|
||||
applicationId: app.applicationId,
|
||||
hostName,
|
||||
port: 3000,
|
||||
https: true,
|
||||
})
|
||||
|
||||
// 4. Trigger deploy
|
||||
await deployApplication(app.applicationId)
|
||||
|
||||
return {
|
||||
app,
|
||||
database,
|
||||
domain,
|
||||
url: `https://${hostName}`,
|
||||
}
|
||||
}
|
||||
|
||||
// --- High-level: teardown an instance (idempotent) ---
|
||||
|
||||
async function teardownInstance(input: {
|
||||
dokployAppId?: string
|
||||
dokployDbId?: string
|
||||
dnsDomainId?: string // Dokploy domain ID
|
||||
}): Promise<{ steps: string[] }> {
|
||||
const steps: string[] = []
|
||||
// Remove domain first (stop traffic)
|
||||
if (input.dnsDomainId) {
|
||||
try {
|
||||
await deleteDomain(input.dnsDomainId)
|
||||
steps.push('domain.removed')
|
||||
} catch (e) {
|
||||
if (e instanceof DokployError && e.status !== 404) {
|
||||
steps.push(`domain.error: ${e.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Delete database
|
||||
if (input.dokployDbId) {
|
||||
try {
|
||||
await deleteDatabase(input.dokployDbId)
|
||||
steps.push('database.removed')
|
||||
} catch (e) {
|
||||
if (e instanceof DokployError && e.status !== 404) {
|
||||
steps.push(`database.error: ${e.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Delete application (+ volumes)
|
||||
if (input.dokployAppId) {
|
||||
try {
|
||||
await deleteApplication(input.dokployAppId)
|
||||
steps.push('application.removed')
|
||||
} catch (e) {
|
||||
if (e instanceof DokployError && e.status !== 404) {
|
||||
steps.push(`application.error: ${e.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { steps }
|
||||
}
|
||||
|
||||
return {
|
||||
// Low-level
|
||||
createApplication,
|
||||
deployApplication,
|
||||
getApplication,
|
||||
deleteApplication,
|
||||
stopApplication,
|
||||
startApplication,
|
||||
updateEnv,
|
||||
createPostgresDatabase,
|
||||
deleteDatabase,
|
||||
createDomain,
|
||||
deleteDomain,
|
||||
call,
|
||||
// High-level
|
||||
provisionInstance,
|
||||
teardownInstance,
|
||||
}
|
||||
}
|
||||
|
||||
export type DokployClient = ReturnType<typeof createDokployClient>
|
||||
|
||||
// --- Factory: create from env ---
|
||||
|
||||
export function createDokployClientFromEnv(): DokployClient | null {
|
||||
const url = process.env.DOKPLOY_URL
|
||||
const apiKey = process.env.DOKPLOY_API_KEY
|
||||
if (!url || !apiKey) return null
|
||||
return createDokployClient({
|
||||
url,
|
||||
apiKey,
|
||||
serverId: process.env.DOKPLOY_SERVER_ID,
|
||||
projectId: process.env.DOKPLOY_PROJECT_ID,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Hetzner Cloud DNS API Client
|
||||
* ------------------------------
|
||||
* Manages DNS records for the platform domain (e.g. dfmpanel.eu).
|
||||
* Used to create A records for tenant instance subdomains.
|
||||
*
|
||||
* Config (env):
|
||||
* HETZNER_DNS_TOKEN - API token (scoped to the platform zone)
|
||||
* HETZNER_ZONE_ID - the zone ID of the platform domain (dfmpanel.eu)
|
||||
* DOKPLOY_SERVER_IP - the IP of the Dokploy server (A record target)
|
||||
* PLATFORM_DOMAIN - e.g. dfmpanel.eu
|
||||
* USE_WILDCARD_DNS - if "true", a single *.dfmpanel.eu A record is used
|
||||
* and no per-instance records are needed.
|
||||
*
|
||||
* API docs: https://dns.hetzner.com/api-docs
|
||||
*/
|
||||
|
||||
export interface HetznerDnsConfig {
|
||||
token: string
|
||||
zoneId: string
|
||||
platformDomain: string // e.g. dfmpanel.eu
|
||||
dokployServerIp: string
|
||||
useWildcard?: boolean // if true, don't create per-instance records
|
||||
}
|
||||
|
||||
export interface DnsRecord {
|
||||
recordId: string
|
||||
type: string // A|AAAA|CNAME|TXT
|
||||
name: string // e.g. k7f3a9 (relative to zone) or @ for apex
|
||||
value: string
|
||||
ttl: number
|
||||
zoneId: string
|
||||
}
|
||||
|
||||
class HetznerDnsError extends Error {
|
||||
constructor(message: string, public status: number, public body?: unknown) {
|
||||
super(message)
|
||||
this.name = 'HetznerDnsError'
|
||||
}
|
||||
}
|
||||
|
||||
export function createHetznerDnsClient(config: HetznerDnsConfig) {
|
||||
const baseUrl = 'https://dns.hetzner.com/api/v1'
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Auth-API-Token': config.token,
|
||||
}
|
||||
|
||||
async function call<T = unknown>(path: string, options: { method?: string; body?: unknown } = {}): Promise<T> {
|
||||
const method = options.method ?? 'GET'
|
||||
const res = await fetch(`${baseUrl}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
})
|
||||
const text = await res.text()
|
||||
let json: unknown
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null
|
||||
} catch {
|
||||
json = text
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new HetznerDnsError(
|
||||
`Hetzner DNS ${method} ${path} failed: HTTP ${res.status}`,
|
||||
res.status,
|
||||
json
|
||||
)
|
||||
}
|
||||
return json as T
|
||||
}
|
||||
|
||||
// --- Records ---
|
||||
|
||||
async function createARecord(subdomain: string): Promise<DnsRecord> {
|
||||
if (config.useWildcard) {
|
||||
// Wildcard record handles all subdomains; no per-instance record needed.
|
||||
return {
|
||||
recordId: 'wildcard',
|
||||
type: 'A',
|
||||
name: subdomain,
|
||||
value: config.dokployServerIp,
|
||||
ttl: 300,
|
||||
zoneId: config.zoneId,
|
||||
}
|
||||
}
|
||||
const res = await call<{ record: { id: string; type: string; name: string; value: string; ttl: number; zone_id: string } }>(
|
||||
'/records',
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
zone_id: config.zoneId,
|
||||
type: 'A',
|
||||
name: subdomain, // relative to zone: "k7f3a9" -> k7f3a9.dfmpanel.eu
|
||||
value: config.dokployServerIp,
|
||||
ttl: 300,
|
||||
},
|
||||
}
|
||||
)
|
||||
return {
|
||||
recordId: res.record.id,
|
||||
type: res.record.type,
|
||||
name: res.record.name,
|
||||
value: res.record.value,
|
||||
ttl: res.record.ttl,
|
||||
zoneId: res.record.zone_id,
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRecord(recordId: string): Promise<void> {
|
||||
if (recordId === 'wildcard' || !recordId) return // wildcard is shared, never delete
|
||||
try {
|
||||
await call(`/records/${recordId}`, { method: 'DELETE' })
|
||||
} catch (e) {
|
||||
if (e instanceof HetznerDnsError && e.status === 404) return
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// --- Custom domain verification (TXT records) ---
|
||||
|
||||
async function createTxtRecord(domain: string, value: string): Promise<DnsRecord> {
|
||||
// For custom domain verification: _dfm-verify.<domain> = token
|
||||
// The domain may be in a different zone (customer's own), so this is only
|
||||
// useful if the customer's zone is also managed in this Hetzner account.
|
||||
// In most cases, the customer manages their own DNS and we only poll.
|
||||
const res = await call<{ record: { id: string; type: string; name: string; value: string; ttl: number; zone_id: string } }>(
|
||||
'/records',
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
zone_id: config.zoneId,
|
||||
type: 'TXT',
|
||||
name: domain,
|
||||
value,
|
||||
ttl: 300,
|
||||
},
|
||||
}
|
||||
)
|
||||
return {
|
||||
recordId: res.record.id,
|
||||
type: res.record.type,
|
||||
name: res.record.name,
|
||||
value: res.record.value,
|
||||
ttl: res.record.ttl,
|
||||
zoneId: res.record.zone_id,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Verify a custom domain's CNAME points to our platform ---
|
||||
|
||||
async function verifyCustomDomainCname(customDomain: string, expectedTarget: string): Promise<{
|
||||
verified: boolean
|
||||
found?: string
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
// Use DNS-over-HTTPS (Google) to resolve the CNAME — we don't manage the
|
||||
// customer's zone, so we can't query Hetzner API for it.
|
||||
const res = await fetch(
|
||||
`https://dns.google/resolve?name=${encodeURIComponent(customDomain)}&type=CNAME`,
|
||||
{ headers: { Accept: 'application/dns-json' } }
|
||||
)
|
||||
const data = await res.json() as { Answer?: { name: string; type: number; data: string }[] }
|
||||
const cnameRecord = data.Answer?.find((a) => a.type === 5) // type 5 = CNAME
|
||||
if (!cnameRecord) {
|
||||
return { verified: false, error: 'No CNAME record found' }
|
||||
}
|
||||
const cnameTarget = cnameRecord.data.replace(/\.$/, '') // strip trailing dot
|
||||
if (cnameTarget === expectedTarget || cnameTarget.endsWith(`.${expectedTarget}`)) {
|
||||
return { verified: true, found: cnameTarget }
|
||||
}
|
||||
return { verified: false, found: cnameTarget, error: `CNAME points to ${cnameTarget}, expected ${expectedTarget}` }
|
||||
} catch (e) {
|
||||
return { verified: false, error: e instanceof Error ? e.message : 'DNS lookup failed' }
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyTxtRecord(domain: string, expectedToken: string): Promise<{
|
||||
verified: boolean
|
||||
found?: string
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://dns.google/resolve?name=${encodeURIComponent(domain)}&type=TXT`,
|
||||
{ headers: { Accept: 'application/dns-json' } }
|
||||
)
|
||||
const data = await res.json() as { Answer?: { name: string; type: number; data: string }[] }
|
||||
const txtRecords = data.Answer?.filter((a) => a.type === 16) ?? [] // type 16 = TXT
|
||||
for (const r of txtRecords) {
|
||||
const value = r.data.replace(/^"|"$/g, '') // strip quotes
|
||||
if (value === expectedToken) {
|
||||
return { verified: true, found: value }
|
||||
}
|
||||
}
|
||||
return { verified: false, error: 'TXT token not found' }
|
||||
} catch (e) {
|
||||
return { verified: false, error: e instanceof Error ? e.message : 'DNS lookup failed' }
|
||||
}
|
||||
}
|
||||
|
||||
// --- List records (for reconciler) ---
|
||||
|
||||
async function listRecords(): Promise<DnsRecord[]> {
|
||||
const res = await call<{ records: { id: string; type: string; name: string; value: string; ttl: number; zone_id: string }[] }>(
|
||||
`/records?zone_id=${config.zoneId}`
|
||||
)
|
||||
return (res.records ?? []).map((r) => ({
|
||||
recordId: r.id,
|
||||
type: r.type,
|
||||
name: r.name,
|
||||
value: r.value,
|
||||
ttl: r.ttl,
|
||||
zoneId: r.zone_id,
|
||||
}))
|
||||
}
|
||||
|
||||
return {
|
||||
createARecord,
|
||||
deleteRecord,
|
||||
createTxtRecord,
|
||||
verifyCustomDomainCname,
|
||||
verifyTxtRecord,
|
||||
listRecords,
|
||||
}
|
||||
}
|
||||
|
||||
export type HetznerDnsClient = ReturnType<typeof createHetznerDnsClient>
|
||||
|
||||
export function createHetznerDnsClientFromEnv(): HetznerDnsClient | null {
|
||||
const token = process.env.HETZNER_DNS_TOKEN
|
||||
const zoneId = process.env.HETZNER_ZONE_ID
|
||||
const platformDomain = process.env.PLATFORM_DOMAIN ?? 'dfmpanel.eu'
|
||||
const dokployServerIp = process.env.DOKPLOY_SERVER_IP
|
||||
if (!token || !zoneId || !dokployServerIp) return null
|
||||
return createHetznerDnsClient({
|
||||
token,
|
||||
zoneId,
|
||||
platformDomain,
|
||||
dokployServerIp,
|
||||
useWildcard: process.env.USE_WILDCARD_DNS === 'true',
|
||||
})
|
||||
}
|
||||
|
||||
// --- Subdomain generation (random, collision-checked, base32) ---
|
||||
|
||||
const BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567' // no confusable 0/O, 1/I
|
||||
|
||||
export function generateSubdomain(length = 7): string {
|
||||
const chars: string[] = []
|
||||
for (let i = 0; i < length; i++) {
|
||||
chars.push(BASE32_ALPHABET[Math.floor(Math.random() * BASE32_ALPHABET.length)])
|
||||
}
|
||||
return chars.join('')
|
||||
}
|
||||
|
||||
// Reserved subdomains that can't be used for instances
|
||||
const RESERVED_SUBDOMAINS = new Set([
|
||||
'www', 'mail', 'smtp', 'imap', 'api', 'admin', 'app', 'dashboard',
|
||||
'docs', 'blog', 'shop', 'portal', 'cdn', 'static', 'assets',
|
||||
'auth', 'login', 'register', 'signup', 'status', 'help', 'support',
|
||||
'demo', 'test', 'staging', 'dev', 'prod', 'production',
|
||||
'ns1', 'ns2', 'dns', 'mx', 'txt', 'cname', 'a', 'aaaa',
|
||||
'ftp', 'sftp', 'ssh', 'git', 'svn', 'hg',
|
||||
'autodiscover', 'autoconfig', 'well-known',
|
||||
])
|
||||
|
||||
export function isReservedSubdomain(subdomain: string): boolean {
|
||||
return RESERVED_SUBDOMAINS.has(subdomain.toLowerCase())
|
||||
}
|
||||
@@ -29,6 +29,7 @@ const de: Dict = {
|
||||
'nav.support': 'Support',
|
||||
'nav.licenses': 'Lizenzen',
|
||||
'nav.agency': 'Bayis',
|
||||
'nav.instances': 'Instanzen',
|
||||
'nav.contracts': 'Verträge',
|
||||
'nav.expenses': 'Ausgaben',
|
||||
'nav.finance': 'Finanzen',
|
||||
@@ -86,6 +87,7 @@ const en: Dict = {
|
||||
'nav.support': 'Support',
|
||||
'nav.licenses': 'Licenses',
|
||||
'nav.agency': 'Resellers (Bayis)',
|
||||
'nav.instances': 'Instances',
|
||||
'nav.contracts': 'Contracts',
|
||||
'nav.expenses': 'Expenses',
|
||||
'nav.finance': 'Finance',
|
||||
@@ -139,6 +141,7 @@ const tr: Dict = {
|
||||
'nav.support': 'Destek',
|
||||
'nav.licenses': 'Lisanslar',
|
||||
'nav.agency': 'Bayiler',
|
||||
'nav.instances': 'Sunucular',
|
||||
'nav.contracts': 'Sözleşmeler',
|
||||
'nav.expenses': 'Giderler',
|
||||
'nav.finance': 'Finans',
|
||||
|
||||
@@ -14,6 +14,7 @@ export type ViewId =
|
||||
| 'support'
|
||||
| 'licenses'
|
||||
| 'agency'
|
||||
| 'instances'
|
||||
| 'contracts'
|
||||
| 'expenses'
|
||||
| 'finance'
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user