Files
dokploy/apps/dokploy/server/api/routers/domain.ts
T
Vyacheslav Scherbinin ee802cd54c feat(domains): add enable/disable toggle for domains
Add an `enabled` flag (default true) so a domain can be switched off
without deleting it, preserving its configuration.

- schema: new `enabled` column + migration; added to apiUpdateDomain
- traefik (applications): guard manageDomain so a disabled domain never
  exposes a router from any caller (create/update/forward-auth/toggle);
  applies instantly through the file provider
- compose: skip disabled domains when generating docker labels, so the
  change applies on the next deployment
- tRPC: new domain.toggleEnable procedure (permission-checked + audited),
  returns requiresRedeploy for compose domains
- UI: switch on the domain card (host row, variant E) and a Status column
  in the table view; disabled cards are dimmed; reuses the compose
  redeploy hint for the toast

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:12:29 +07:00

258 lines
7.6 KiB
TypeScript

import {
createDomain,
findApplicationById,
findDomainById,
findDomainsByApplicationId,
findDomainsByComposeId,
findPreviewDeploymentById,
findServerById,
generateTraefikMeDomain,
getWebServerSettings,
manageDomain,
removeDomain,
removeDomainById,
updateDomainById,
validateDomain,
} from "@dokploy/server";
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import {
createTRPCRouter,
protectedProcedure,
withPermission,
} from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import {
apiCreateDomain,
apiFindCompose,
apiFindDomain,
apiFindOneApplication,
apiUpdateDomain,
} from "@/server/db/schema";
export const domainRouter = createTRPCRouter({
create: protectedProcedure
.input(apiCreateDomain)
.mutation(async ({ input, ctx }) => {
try {
if (input.domainType === "compose" && input.composeId) {
await checkServicePermissionAndAccess(ctx, input.composeId, {
domain: ["create"],
});
} else if (input.domainType === "application" && input.applicationId) {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
domain: ["create"],
});
}
const domain = await createDomain(input);
await audit(ctx, {
action: "create",
resourceType: "domain",
resourceId: domain.domainId,
resourceName: domain.host,
});
return domain;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? error.message
: "Error creating the domain",
cause: error,
});
}
}),
byApplicationId: protectedProcedure
.input(apiFindOneApplication)
.query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.applicationId, {
domain: ["read"],
});
return await findDomainsByApplicationId(input.applicationId);
}),
byComposeId: protectedProcedure
.input(apiFindCompose)
.query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
domain: ["read"],
});
return await findDomainsByComposeId(input.composeId);
}),
generateDomain: withPermission("domain", "create")
.input(z.object({ appName: z.string(), serverId: z.string().optional() }))
.mutation(async ({ input, ctx }) => {
return generateTraefikMeDomain(
input.appName,
ctx.user.ownerId,
input.serverId,
);
}),
canGenerateTraefikMeDomains: withPermission("domain", "read")
.input(z.object({ serverId: z.string() }))
.query(async ({ input }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
return server.ipAddress;
}
const settings = await getWebServerSettings();
return settings?.serverIp || "";
}),
update: protectedProcedure
.input(apiUpdateDomain)
.mutation(async ({ input, ctx }) => {
const currentDomain = await findDomainById(input.domainId);
const serviceId = currentDomain.applicationId || currentDomain.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
domain: ["create"],
});
} else if (currentDomain.previewDeploymentId) {
const preview = await findPreviewDeploymentById(
currentDomain.previewDeploymentId,
);
await checkServicePermissionAndAccess(ctx, preview.applicationId, {
domain: ["create"],
});
}
const result = await updateDomainById(input.domainId, input);
const domain = await findDomainById(input.domainId);
await audit(ctx, {
action: "update",
resourceType: "domain",
resourceId: domain.domainId,
resourceName: domain.host,
});
if (domain.applicationId) {
const application = await findApplicationById(domain.applicationId);
await manageDomain(application, domain);
} else if (domain.previewDeploymentId) {
const previewDeployment = await findPreviewDeploymentById(
domain.previewDeploymentId,
);
const application = await findApplicationById(
previewDeployment.applicationId,
);
application.appName = previewDeployment.appName;
await manageDomain(application, domain);
}
return result;
}),
toggleEnable: protectedProcedure
.input(apiFindDomain)
.mutation(async ({ input, ctx }) => {
const currentDomain = await findDomainById(input.domainId);
const serviceId = currentDomain.applicationId || currentDomain.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
domain: ["create"],
});
} else if (currentDomain.previewDeploymentId) {
const preview = await findPreviewDeploymentById(
currentDomain.previewDeploymentId,
);
await checkServicePermissionAndAccess(ctx, preview.applicationId, {
domain: ["create"],
});
}
const result = await updateDomainById(input.domainId, {
enabled: !currentDomain.enabled,
});
const domain = await findDomainById(input.domainId);
await audit(ctx, {
action: "update",
resourceType: "domain",
resourceId: domain.domainId,
resourceName: domain.host,
});
// Applications apply instantly through the traefik file provider:
// manageDomain creates the router when enabled and removes it when
// disabled. Compose domains are docker labels and only change on the
// next deployment, so we just persist the flag here.
if (domain.applicationId) {
const application = await findApplicationById(domain.applicationId);
await manageDomain(application, domain);
} else if (domain.previewDeploymentId) {
const previewDeployment = await findPreviewDeploymentById(
domain.previewDeploymentId,
);
const application = await findApplicationById(
previewDeployment.applicationId,
);
application.appName = previewDeployment.appName;
await manageDomain(application, domain);
}
return {
...result,
requiresRedeploy: domain.domainType === "compose",
};
}),
one: protectedProcedure.input(apiFindDomain).query(async ({ input, ctx }) => {
const domain = await findDomainById(input.domainId);
const serviceId = domain.applicationId || domain.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
domain: ["read"],
});
} else if (domain.previewDeploymentId) {
const preview = await findPreviewDeploymentById(
domain.previewDeploymentId,
);
await checkServicePermissionAndAccess(ctx, preview.applicationId, {
domain: ["read"],
});
}
return domain;
}),
delete: protectedProcedure
.input(apiFindDomain)
.mutation(async ({ input, ctx }) => {
const domain = await findDomainById(input.domainId);
const serviceId = domain.applicationId || domain.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
domain: ["delete"],
});
} else if (domain.previewDeploymentId) {
const preview = await findPreviewDeploymentById(
domain.previewDeploymentId,
);
await checkServicePermissionAndAccess(ctx, preview.applicationId, {
domain: ["delete"],
});
}
const result = await removeDomainById(input.domainId);
await audit(ctx, {
action: "delete",
resourceType: "domain",
resourceId: domain.domainId,
resourceName: domain.host,
});
if (domain.applicationId) {
const application = await findApplicationById(domain.applicationId);
await removeDomain(application, domain.uniqueConfigKey);
}
return result;
}),
validateDomain: withPermission("domain", "read")
.input(
z.object({
domain: z.string(),
serverIp: z.string().optional(),
}),
)
.mutation(async ({ input }) => {
return validateDomain(input.domain, input.serverIp);
}),
});