From 3550dde8e16c0ae6bc8be9ae347046f1a6d81320 Mon Sep 17 00:00:00 2001 From: Flavio Duque Date: Fri, 2 Oct 2026 16:09:07 -0300 Subject: [PATCH] feat(domains): auto-assign per-project subdomains when PANEL_BASE_DOMAIN is set With a wildcard DNS record (*.base) pointing at the proxy, every new instance gets api-. (API) and . (Studio, served through Kong's authenticated route) at creation time: domains are saved with their DNS check result, API_EXTERNAL_URL/SUPABASE_PUBLIC_URL are pointed at the HTTPS API domain, and the Traefik routing (file provider or Dokploy labels) is generated through the existing helpers. Without PANEL_BASE_DOMAIN nothing changes. Slugs that are not valid hostname labels are skipped and stay on manual domains. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 ++ docker-compose.yml | 1 + install.sh | 1 + src/lib/dns-target.ts | 14 ++++++++++++++ src/lib/project.ts | 13 ++++++++++++- tests/auto-subdomain.test.ts | 20 ++++++++++++++++++++ 6 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 tests/auto-subdomain.test.ts diff --git a/README.md b/README.md index 3c030c9..c0fd496 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,8 @@ Use o hostname central em modo **DNS only**, apontando diretamente ao servidor, Na instância, informe `api.cliente.com` e `studio.cliente.com`. O painel mostra os registros DNS e permite copiar o destino. Crie esses registros no provedor DNS e clique em **Salvar e implantar**. O Traefik identifica o domínio solicitado e encaminha para a instância correta; o CNAME sozinho não configura o roteamento nem os certificados. +Subdomínios automáticos: defina `PANEL_BASE_DOMAIN=supa.seudominio.com` e crie um registro curinga `*.supa.seudominio.com` apontando para o servidor. Cada nova instância recebe `api-.supa.seudominio.com` (API) e `.supa.seudominio.com` (Studio) já na criação; basta implantar. Sem a variável, nada muda e os domínios continuam manuais. + Se o IP do servidor mudar, atualize apenas o A/AAAA do hostname central. Se mudar o hostname central, os CNAMEs existentes precisam ser atualizados. Use subdomínios: ALIAS/ANAME no domínio raiz depende do provedor. Não crie CNAME apontando para si mesmo nem registros A/AAAA conflitantes no mesmo nome. O painel não altera automaticamente seu provedor DNS. Configuração de API e Studio com registros CNAME prontos para copiar diff --git a/docker-compose.yml b/docker-compose.yml index 1172bbe..fff3405 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,7 @@ services: SUPAPANEL_MODE: production PROXY_MODE: dokploy INSTANCE_DNS_TARGET: ${INSTANCE_DNS_TARGET:-} + PANEL_BASE_DOMAIN: ${PANEL_BASE_DOMAIN:-} PROXY_NETWORK: ${PROXY_NETWORK:-dokploy-network} PROXY_ENTRYPOINT: ${PROXY_ENTRYPOINT:-websecure} PROXY_CERT_RESOLVER: ${PROXY_CERT_RESOLVER:-letsencrypt} diff --git a/install.sh b/install.sh index f51e7d7..c916cf2 100644 --- a/install.sh +++ b/install.sh @@ -286,6 +286,7 @@ services: - SUPABASE_CORE_REPO_URL=${SUPABASE_CORE_REPO_URL} - APP_NAME=${APP_NAME:-SupaPanel} - APP_URL=${APP_URL:-http://localhost:3000} + - PANEL_BASE_DOMAIN=${PANEL_BASE_DOMAIN:-} volumes: - ${DATA_PATH}:${DATA_PATH} - /var/run/docker.sock:/var/run/docker.sock diff --git a/src/lib/dns-target.ts b/src/lib/dns-target.ts index cac295f..1cbb072 100644 --- a/src/lib/dns-target.ts +++ b/src/lib/dns-target.ts @@ -21,3 +21,17 @@ export async function getDnsTarget() { const value = (saved?.value || fallback).toLowerCase(); return validDnsTarget(value) ? value : null; } +// With a wildcard record (*.base) pointing at the proxy, new instances get +// api-. and . without manual DNS setup. +export function autoProjectDomains( + slug: string, + baseDomain = process.env.PANEL_BASE_DOMAIN, +) { + const base = baseDomain?.trim().toLowerCase(); + if (!base) return null; + const domain = `api-${slug}.${base}`; + const studioDomain = `${slug}.${base}`; + return validDnsTarget(domain) && validDnsTarget(studioDomain) + ? { domain, studioDomain } + : null; +} diff --git a/src/lib/project.ts b/src/lib/project.ts index cbd7b0c..1d8e217 100644 --- a/src/lib/project.ts +++ b/src/lib/project.ts @@ -3,7 +3,8 @@ import * as path from 'path' import { exec, execFile } from 'child_process' import { promisify } from 'util' import { prisma } from './db' -import { removeProjectTraefikConfig } from './traefik' +import { removeProjectTraefikConfig, generateProjectTraefikConfig, verifyDomainDNS } from './traefik' +import { autoProjectDomains } from './dns-target' import { corePath, projectsPath, secret, jwt, serializeEnv, prepareCompose, supabaseRef, isDokploy } from './runtime' @@ -149,6 +150,16 @@ export async function createProject(name: string, userId: string, description?: createdDir = path.join(getProjectsBasePath(), slug) await provisionProjectFiles(project) + const auto = autoProjectDomains(slug) + if (auto) { + const [domainVerified, studioDomainVerified] = await Promise.all([verifyDomainDNS(auto.domain), verifyDomainDNS(auto.studioDomain)]) + const routed = await prisma.project.update({ where: { id: project.id }, data: { ...auto, domainVerified, studioDomainVerified } }) + const env = await updateProjectEnvVars(project.id, { API_EXTERNAL_URL: `https://${auto.domain}/auth/v1`, SUPABASE_PUBLIC_URL: `https://${auto.domain}` }) + if (!env.success) throw new Error(env.error) + await generateProjectTraefikConfig({ projectSlug: slug, ...auto, kongPort: 8000, studioPort: 8000 }) + return { success: true, project: routed } + } + return { success: true, project } } catch (error) { if (createdId) await prisma.$transaction(async tx => { diff --git a/tests/auto-subdomain.test.ts b/tests/auto-subdomain.test.ts new file mode 100644 index 0000000..a50259d --- /dev/null +++ b/tests/auto-subdomain.test.ts @@ -0,0 +1,20 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { autoProjectDomains } from "../src/lib/dns-target"; + +test("PANEL_BASE_DOMAIN gives a new project api- and subdomains", () => { + assert.deepEqual(autoProjectDomains("shop-0a1b2c3d4e5f", "Supa.Example.com "), { + domain: "api-shop-0a1b2c3d4e5f.supa.example.com", + studioDomain: "shop-0a1b2c3d4e5f.supa.example.com", + }); +}); + +test("without PANEL_BASE_DOMAIN nothing is assigned (manual domains stay the only path)", () => { + assert.equal(autoProjectDomains("shop-0a1b2c3d4e5f", undefined), null); + assert.equal(autoProjectDomains("shop-0a1b2c3d4e5f", ""), null); +}); + +test("a slug that is not a valid hostname label is skipped instead of producing a bad domain", () => { + // createProject slugifies "!shop" to "-shop-"; a label cannot start with "-". + assert.equal(autoProjectDomains("-shop-0a1b2c3d4e5f", "supa.example.com"), null); +});