feat(api): automate custom domain setup via Cloudflare + Coolify
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.
This commit is contained in:
@@ -16,6 +16,11 @@ const envSchema = z.object({
|
|||||||
PUBLIC_WEB_URL: z.string().default(process.env.PUBLIC_WEB_URL ?? "http://localhost:3000"),
|
PUBLIC_WEB_URL: z.string().default(process.env.PUBLIC_WEB_URL ?? "http://localhost:3000"),
|
||||||
GEMINI_API_KEY: z.string().optional(),
|
GEMINI_API_KEY: z.string().optional(),
|
||||||
OPENAI_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);
|
export const env = envSchema.parse(process.env);
|
||||||
|
|||||||
@@ -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<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
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<CloudflareCustomHostname> {
|
||||||
|
return cfFetch<CloudflareCustomHostname>(`/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<CloudflareCustomHostname | null> {
|
||||||
|
const results = await cfFetch<CloudflareCustomHostname[]>(
|
||||||
|
`/zones/${env.CLOUDFLARE_ZONE_ID}/custom_hostnames?hostname=${encodeURIComponent(hostname)}`,
|
||||||
|
);
|
||||||
|
return results?.[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCustomHostname(id: string): Promise<void> {
|
||||||
|
await cfFetch(`/zones/${env.CLOUDFLARE_ZONE_ID}/custom_hostnames/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { env } from "../env.js";
|
||||||
|
|
||||||
|
async function coolifyFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
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<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<void> {
|
||||||
|
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" });
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import dns from "node:dns/promises";
|
|
||||||
import type { FastifyPluginAsync } from "fastify";
|
import type { FastifyPluginAsync } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { requireAuth, requireRestaurantMember } from "../lib/auth.js";
|
import { requireAuth, requireRestaurantMember } from "../lib/auth.js";
|
||||||
import { env } from "../env.js";
|
import { env } from "../env.js";
|
||||||
import { supabase } from "../lib/supabase.js";
|
import { supabase } from "../lib/supabase.js";
|
||||||
|
import { addDomainToWebApp } from "../lib/coolify.js";
|
||||||
|
import { createCustomHostname, getCustomHostnameByHostname } from "../lib/cloudflare.js";
|
||||||
|
|
||||||
const addDomainSchema = z.object({
|
const addDomainSchema = z.object({
|
||||||
hostname: z
|
hostname: z
|
||||||
@@ -13,14 +14,6 @@ const addDomainSchema = z.object({
|
|||||||
.transform((val) => val.toLowerCase().trim().replace(/^https?:\/\//, "").replace(/\/.*$/, "")),
|
.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) => {
|
export const domainsRoutes: FastifyPluginAsync = async (app) => {
|
||||||
// Get all domains for a restaurant
|
// Get all domains for a restaurant
|
||||||
app.get("/restaurants/:id/domains", async (req, reply) => {
|
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) => {
|
app.post("/restaurants/:id/domains", async (req, reply) => {
|
||||||
const userId = await requireAuth(req, reply);
|
const userId = await requireAuth(req, reply);
|
||||||
if (!userId || !supabase) return;
|
if (!userId || !supabase) return;
|
||||||
@@ -70,7 +64,6 @@ export const domainsRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
|
|
||||||
const { hostname } = parsed.data;
|
const { hostname } = parsed.data;
|
||||||
|
|
||||||
// Check if domain is already registered
|
|
||||||
const { data: existing } = await supabase
|
const { data: existing } = await supabase
|
||||||
.from("domains")
|
.from("domains")
|
||||||
.select("id, restaurant_id")
|
.select("id, restaurant_id")
|
||||||
@@ -101,19 +94,44 @@ export const domainsRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cnameTarget = `cname.${env.ROOT_DOMAIN}`;
|
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({
|
return reply.status(201).send({
|
||||||
domain: newDomain,
|
domain: newDomain,
|
||||||
cnameTarget,
|
cnameTarget,
|
||||||
instructions: {
|
instructions: {
|
||||||
type: "CNAME",
|
cname: { type: "CNAME", host: hostname, target: cnameTarget },
|
||||||
host: hostname,
|
ownershipTxt: dcvTxt,
|
||||||
target: cnameTarget,
|
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) => {
|
app.post("/restaurants/:id/domains/:domainId/verify", async (req, reply) => {
|
||||||
const userId = await requireAuth(req, reply);
|
const userId = await requireAuth(req, reply);
|
||||||
if (!userId || !supabase) return;
|
if (!userId || !supabase) return;
|
||||||
@@ -133,41 +151,19 @@ export const domainsRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
return reply.code(404).send({ message: "domain not found" });
|
return reply.code(404).send({ message: "domain not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const expectedTarget = `cname.${env.ROOT_DOMAIN}`;
|
let cfHostname: Awaited<ReturnType<typeof getCustomHostnameByHostname>> = null;
|
||||||
let isVerified = false;
|
|
||||||
let dnsErrorDetails = "";
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Real DNS lookup for CNAME
|
cfHostname = await getCustomHostnameByHostname(domain.hostname);
|
||||||
const cnames = await resolver.resolveCname(domain.hostname);
|
} catch (err) {
|
||||||
req.log.info({ hostname: domain.hostname, foundCnames: cnames }, "Real DNS CNAME check");
|
req.log.warn({ err, hostname: domain.hostname }, "Cloudflare status check failed");
|
||||||
|
|
||||||
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.";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isVerified = cfHostname?.status === "active" && cfHostname?.ssl?.status === "active";
|
||||||
|
|
||||||
if (isVerified) {
|
if (isVerified) {
|
||||||
const { data: updated } = await supabase
|
const { data: updated } = await supabase
|
||||||
.from("domains")
|
.from("domains")
|
||||||
.update({
|
.update({ status: "verified", verified_at: new Date().toISOString() })
|
||||||
status: "verified",
|
|
||||||
verified_at: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
.eq("id", domainId)
|
.eq("id", domainId)
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
@@ -175,29 +171,31 @@ export const domainsRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
return reply.send({
|
return reply.send({
|
||||||
verified: true,
|
verified: true,
|
||||||
domain: updated,
|
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
|
// Never downgrade an already-verified domain on an inconclusive re-check.
|
||||||
// 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".
|
|
||||||
if (domain.status !== "verified") {
|
if (domain.status !== "verified") {
|
||||||
await supabase
|
await supabase.from("domains").update({ status: "pending", verified_at: null }).eq("id", domainId);
|
||||||
.from("domains")
|
|
||||||
.update({
|
|
||||||
status: "pending",
|
|
||||||
verified_at: null,
|
|
||||||
})
|
|
||||||
.eq("id", domainId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return reply.send({
|
return reply.send({
|
||||||
verified: domain.status === "verified",
|
verified: domain.status === "verified",
|
||||||
domain: domain.status === "verified" ? domain : { ...domain, status: "pending", verified_at: null },
|
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.`,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user