4413045f-d05e-4d7a-a70d-81d6be253c85

This commit is contained in:
Z User committed 2026-10-28 16:37:26 +00:00
1 parent 762f3d7373
commit c6adc67479
18 files changed
+5263 -1

No files matched your search

+392
View File
@@ -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,
})
}
+271
View File
@@ -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())
}
+3
View File
@@ -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',