From 7b9cfb68585d341ae8d84c6f25f3fb5d6c925024 Mon Sep 17 00:00:00 2001 From: AyrisAI Date: Thu, 20 Aug 2026 05:43:40 +0300 Subject: [PATCH] feat(api): automate custom domain setup via Cloudflare + Coolify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding a custom domain now: - creates a Cloudflare Custom Hostname (SSL for SaaS) automatically, returning the ownership + SSL validation TXT records to show the restaurant owner alongside the CNAME instruction - adds the domain to the Coolify app's fqdn and triggers a restart (Coolify only regenerates Traefik labels on deploy, not on a bare fqdn PATCH — coollabsio/coolify#6281) - /verify now checks Cloudflare's actual ssl.status instead of doing a DNS CNAME lookup, which is structurally blind on proxied hostnames Tested end-to-end against the real Cloudflare zone and Coolify app (create, verify data, then clean up) before this push — see docs/PROGRESS.md. Requires CLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_ID, COOLIFY_API_TOKEN, COOLIFY_BASE_URL, COOLIFY_WEB_APP_UUID in the API's environment. Without them, domain add/verify falls back to DB-only bookkeeping. --- apps/api/src/env.ts | 5 ++ apps/api/src/lib/cloudflare.ts | 60 +++++++++++++++++ apps/api/src/lib/coolify.ts | 48 ++++++++++++++ apps/api/src/routes/domains.ts | 114 ++++++++++++++++----------------- 4 files changed, 169 insertions(+), 58 deletions(-) create mode 100644 apps/api/src/lib/cloudflare.ts create mode 100644 apps/api/src/lib/coolify.ts diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 58776ce..74cce95 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -16,6 +16,11 @@ const envSchema = z.object({ PUBLIC_WEB_URL: z.string().default(process.env.PUBLIC_WEB_URL ?? "http://localhost:3000"), GEMINI_API_KEY: z.string().optional(), OPENAI_API_KEY: z.string().optional(), + CLOUDFLARE_API_TOKEN: z.string().optional(), + CLOUDFLARE_ZONE_ID: z.string().optional(), + COOLIFY_API_TOKEN: z.string().optional(), + COOLIFY_BASE_URL: z.string().optional(), + COOLIFY_WEB_APP_UUID: z.string().optional(), }); export const env = envSchema.parse(process.env); diff --git a/apps/api/src/lib/cloudflare.ts b/apps/api/src/lib/cloudflare.ts new file mode 100644 index 0000000..dd55cb1 --- /dev/null +++ b/apps/api/src/lib/cloudflare.ts @@ -0,0 +1,60 @@ +import { env } from "../env.js"; + +const CF_API = "https://api.cloudflare.com/client/v4"; + +export interface CloudflareCustomHostname { + id: string; + hostname: string; + status: string; + ownership_verification?: { type: string; name: string; value: string }; + ssl: { + status: string; + method: string; + type: string; + validation_records?: { txt_name: string; txt_value: string }[]; + }; +} + +async function cfFetch(path: string, options: RequestInit = {}): Promise { + if (!env.CLOUDFLARE_API_TOKEN || !env.CLOUDFLARE_ZONE_ID) { + throw new Error("Cloudflare not configured (CLOUDFLARE_API_TOKEN / CLOUDFLARE_ZONE_ID missing)"); + } + + const res = await fetch(`${CF_API}${path}`, { + ...options, + headers: { + Authorization: `Bearer ${env.CLOUDFLARE_API_TOKEN}`, + "Content-Type": "application/json", + ...(options.headers ?? {}), + }, + }); + + const json = (await res.json()) as { success: boolean; result: T; errors: { message: string }[] }; + if (!json.success) { + throw new Error(json.errors?.[0]?.message ?? "Cloudflare API error"); + } + return json.result; +} + +// One-time DCV (domain ownership) TXT + a per-hostname SSL cert via +// Cloudflare for SaaS. The hostname's own CNAME (routing) is set by the +// customer independently — see domains.ts's cnameTarget instruction. +export async function createCustomHostname(hostname: string): Promise { + return cfFetch(`/zones/${env.CLOUDFLARE_ZONE_ID}/custom_hostnames`, { + method: "POST", + body: JSON.stringify({ hostname, ssl: { method: "txt", type: "dv" } }), + }); +} + +export async function getCustomHostnameByHostname( + hostname: string, +): Promise { + const results = await cfFetch( + `/zones/${env.CLOUDFLARE_ZONE_ID}/custom_hostnames?hostname=${encodeURIComponent(hostname)}`, + ); + return results?.[0] ?? null; +} + +export async function deleteCustomHostname(id: string): Promise { + await cfFetch(`/zones/${env.CLOUDFLARE_ZONE_ID}/custom_hostnames/${id}`, { method: "DELETE" }); +} diff --git a/apps/api/src/lib/coolify.ts b/apps/api/src/lib/coolify.ts new file mode 100644 index 0000000..75c8f57 --- /dev/null +++ b/apps/api/src/lib/coolify.ts @@ -0,0 +1,48 @@ +import { env } from "../env.js"; + +async function coolifyFetch(path: string, options: RequestInit = {}): Promise { + if (!env.COOLIFY_API_TOKEN || !env.COOLIFY_BASE_URL) { + throw new Error("Coolify not configured (COOLIFY_API_TOKEN / COOLIFY_BASE_URL missing)"); + } + + const res = await fetch(`${env.COOLIFY_BASE_URL}${path}`, { + ...options, + headers: { + Authorization: `Bearer ${env.COOLIFY_API_TOKEN}`, + "Content-Type": "application/json", + ...(options.headers ?? {}), + }, + }); + + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`Coolify API ${res.status}: ${body.slice(0, 200)}`); + } + + return res.json() as Promise; +} + +// Coolify only regenerates an app's Traefik labels from its fqdn field on +// deploy — patching fqdn alone leaves routing unchanged, so every domain +// add is followed by a restart (coollabsio/coolify#6281). +export async function addDomainToWebApp(hostname: string): Promise { + const appUuid = env.COOLIFY_WEB_APP_UUID; + if (!appUuid) throw new Error("COOLIFY_WEB_APP_UUID not configured"); + + const app = await coolifyFetch<{ fqdn: string | null }>(`/api/v1/applications/${appUuid}`); + const existing = (app.fqdn ?? "") + .split(",") + .map((d) => d.trim()) + .filter(Boolean); + + const newUrl = `https://${hostname}`; + if (existing.includes(newUrl)) return; + + const updated = [...existing, newUrl].join(","); + await coolifyFetch(`/api/v1/applications/${appUuid}`, { + method: "PATCH", + body: JSON.stringify({ domains: updated }), + }); + + await coolifyFetch(`/api/v1/applications/${appUuid}/restart`, { method: "POST" }); +} diff --git a/apps/api/src/routes/domains.ts b/apps/api/src/routes/domains.ts index e23ffb0..8a271a2 100644 --- a/apps/api/src/routes/domains.ts +++ b/apps/api/src/routes/domains.ts @@ -1,9 +1,10 @@ -import dns from "node:dns/promises"; import type { FastifyPluginAsync } from "fastify"; import { z } from "zod"; import { requireAuth, requireRestaurantMember } from "../lib/auth.js"; import { env } from "../env.js"; import { supabase } from "../lib/supabase.js"; +import { addDomainToWebApp } from "../lib/coolify.js"; +import { createCustomHostname, getCustomHostnameByHostname } from "../lib/cloudflare.js"; const addDomainSchema = z.object({ hostname: z @@ -13,14 +14,6 @@ const addDomainSchema = z.object({ .transform((val) => val.toLowerCase().trim().replace(/^https?:\/\//, "").replace(/\/.*$/, "")), }); -const resolver = new dns.Resolver(); -// Use public authoritative DNS servers to check live global records -try { - resolver.setServers(["8.8.8.8", "1.1.1.1"]); -} catch { - // Fallback to default -} - export const domainsRoutes: FastifyPluginAsync = async (app) => { // Get all domains for a restaurant app.get("/restaurants/:id/domains", async (req, reply) => { @@ -54,7 +47,8 @@ export const domainsRoutes: FastifyPluginAsync = async (app) => { }); }); - // Add a custom domain + // Add a custom domain — registers it with Cloudflare (SSL for SaaS) and + // the origin (Coolify) so it's routable/certifiable, not just recorded. app.post("/restaurants/:id/domains", async (req, reply) => { const userId = await requireAuth(req, reply); if (!userId || !supabase) return; @@ -70,7 +64,6 @@ export const domainsRoutes: FastifyPluginAsync = async (app) => { const { hostname } = parsed.data; - // Check if domain is already registered const { data: existing } = await supabase .from("domains") .select("id, restaurant_id") @@ -101,19 +94,44 @@ export const domainsRoutes: FastifyPluginAsync = async (app) => { } const cnameTarget = `cname.${env.ROOT_DOMAIN}`; + const warnings: string[] = []; + let dcvTxt: { name: string; value: string } | null = null; + let sslTxt: { name: string; value: string }[] = []; + + try { + const hostnameRecord = await createCustomHostname(hostname); + if (hostnameRecord.ownership_verification) { + dcvTxt = { name: hostnameRecord.ownership_verification.name, value: hostnameRecord.ownership_verification.value }; + } + sslTxt = (hostnameRecord.ssl.validation_records ?? []).map((r) => ({ name: r.txt_name, value: r.txt_value })); + } catch (err) { + req.log.warn({ err, hostname }, "Cloudflare custom hostname creation failed"); + warnings.push("Cloudflare kaydı oluşturulamadı, manuel kurulum gerekebilir."); + } + + try { + await addDomainToWebApp(hostname); + } catch (err) { + req.log.warn({ err, hostname }, "Coolify domain add failed"); + warnings.push("Sunucu routing'i otomatik eklenemedi, manuel kurulum gerekebilir."); + } return reply.status(201).send({ domain: newDomain, cnameTarget, instructions: { - type: "CNAME", - host: hostname, - target: cnameTarget, + cname: { type: "CNAME", host: hostname, target: cnameTarget }, + ownershipTxt: dcvTxt, + sslTxt, }, + warnings, }); }); - // REAL DNS Verification (Checks real CNAME records across 8.8.8.8 and 1.1.1.1) + // Verification checks Cloudflare's own Custom Hostname SSL status — a + // plain DNS lookup can't see a proxied domain's real CNAME target, so + // that approach always failed for hostnames set up correctly (see + // docs/PROGRESS.md, 2026-08-20). app.post("/restaurants/:id/domains/:domainId/verify", async (req, reply) => { const userId = await requireAuth(req, reply); if (!userId || !supabase) return; @@ -133,41 +151,19 @@ export const domainsRoutes: FastifyPluginAsync = async (app) => { return reply.code(404).send({ message: "domain not found" }); } - const expectedTarget = `cname.${env.ROOT_DOMAIN}`; - let isVerified = false; - let dnsErrorDetails = ""; - + let cfHostname: Awaited> = null; try { - // Real DNS lookup for CNAME - const cnames = await resolver.resolveCname(domain.hostname); - req.log.info({ hostname: domain.hostname, foundCnames: cnames }, "Real DNS CNAME check"); - - isVerified = cnames.some( - (c) => - c.toLowerCase().includes(env.ROOT_DOMAIN.toLowerCase()) || - c.toLowerCase().includes(`cname.${env.ROOT_DOMAIN}`.toLowerCase()) || - c.toLowerCase().includes("menul.io"), - ); - - if (!isVerified) { - dnsErrorDetails = `Bulunan CNAME: ${cnames.join(", ")} (Beklenen: ${expectedTarget})`; - } - } catch (err: any) { - req.log.warn({ hostname: domain.hostname, err: err?.message || err }, "DNS CNAME lookup failed"); - if (err?.code === "ENOTFOUND" || err?.code === "ENODATA") { - dnsErrorDetails = "DNS kaydı bulunamadı. Henüz CNAME yönlendirmesi yapılmamış veya DNS yayılımı (propagation) tamamlanmamış."; - } else { - dnsErrorDetails = err?.message || "DNS sorgusu başarısız oldu."; - } + cfHostname = await getCustomHostnameByHostname(domain.hostname); + } catch (err) { + req.log.warn({ err, hostname: domain.hostname }, "Cloudflare status check failed"); } + const isVerified = cfHostname?.status === "active" && cfHostname?.ssl?.status === "active"; + if (isVerified) { const { data: updated } = await supabase .from("domains") - .update({ - status: "verified", - verified_at: new Date().toISOString(), - }) + .update({ status: "verified", verified_at: new Date().toISOString() }) .eq("id", domainId) .select() .single(); @@ -175,29 +171,31 @@ export const domainsRoutes: FastifyPluginAsync = async (app) => { return reply.send({ verified: true, domain: updated, - message: `Tebrikler! ${domain.hostname} alan adının CNAME kaydı doğrulandı ve menünüze bağlandı. 🎉`, + message: `Tebrikler! ${domain.hostname} alan adı doğrulandı ve menünüze bağlandı. 🎉`, }); } - // A DNS CNAME lookup structurally cannot see the real target once the - // record is proxied (Cloudflare hides it behind its own edge IPs) — so a - // failed re-check here is inconclusive, not proof the domain broke. - // Never downgrade a domain that was already verified; only a first-time - // check is allowed to land on "pending". + // Never downgrade an already-verified domain on an inconclusive re-check. if (domain.status !== "verified") { - await supabase - .from("domains") - .update({ - status: "pending", - verified_at: null, - }) - .eq("id", domainId); + await supabase.from("domains").update({ status: "pending", verified_at: null }).eq("id", domainId); } return reply.send({ verified: domain.status === "verified", domain: domain.status === "verified" ? domain : { ...domain, status: "pending", verified_at: null }, - message: `Doğrulanamadı: ${dnsErrorDetails}\n\nLütfen alan adı yönetim panelinizden (Cloudflare, GoDaddy, Natro vb.) ${domain.hostname} için CNAME kaydını ${expectedTarget} adresine yönlendirin.`, + cloudflareStatus: cfHostname + ? { + hostnameStatus: cfHostname.status, + sslStatus: cfHostname.ssl?.status, + ownershipTxt: cfHostname.ownership_verification + ? { name: cfHostname.ownership_verification.name, value: cfHostname.ownership_verification.value } + : null, + sslTxt: (cfHostname.ssl?.validation_records ?? []).map((r) => ({ name: r.txt_name, value: r.txt_value })), + } + : null, + message: cfHostname + ? `Henüz aktif değil (hostname: ${cfHostname.status}, sertifika: ${cfHostname.ssl?.status}). DNS kayıtlarının işlenmesi birkaç dakika sürebilir.` + : `${domain.hostname} Cloudflare'de bulunamadı. Domain eklerken bir hata oluşmuş olabilir, tekrar eklemeyi deneyin.`, }); });