Merge branch 'canary' into feat/add-network-management

This commit is contained in:
Mauricio Siu
2026-07-21 19:01:28 -06:00
582 changed files with 98035 additions and 10066 deletions
+4
View File
@@ -120,6 +120,10 @@ export const getDokployUrl = async () => {
const TRUSTED_ORIGINS_CACHE_TTL_MS = 30 * 60_000;
let trustedOriginsCache: { data: string[]; expiresAt: number } | null = null;
export const invalidateTrustedOriginsCache = () => {
trustedOriginsCache = null;
};
export const getTrustedOrigins = async () => {
const runQuery = async () => {
const rows = await db
+72 -6
View File
@@ -1,5 +1,6 @@
import { db } from "@dokploy/server/db";
import { ai } from "@dokploy/server/db/schema";
import { ai, organization } from "@dokploy/server/db/schema";
import { aiCustomProviderSchema } from "@dokploy/server/db/schema/ai";
import { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider";
import { TRPCError } from "@trpc/server";
import { generateText, Output } from "ai";
@@ -24,7 +25,7 @@ interface DockerOutput {
dockerCompose: string;
envVariables: Array<{ name: string; value: string }>;
domains: Array<{ host: string; port: number; serviceName: string }>;
configFiles?: Array<{ content: string; filePath: string }>;
configFiles?: Array<{ content: string; filePath: string }> | null;
}
export const getAiSettingsByOrganizationId = async (organizationId: string) => {
@@ -48,9 +49,74 @@ export const getAiSettingById = async (aiId: string) => {
return aiSetting;
};
type AiCustomProvider = z.infer<typeof aiCustomProviderSchema>;
const parseOrgMetadata = (metadata: string | null) => {
try {
const parsed = JSON.parse(metadata || "{}");
return typeof parsed === "object" && parsed !== null
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
};
export const getCustomAiProviders = async (organizationId: string) => {
const org = await db.query.organization.findFirst({
where: eq(organization.id, organizationId),
});
const metadata = parseOrgMetadata(org?.metadata ?? null);
const result = z
.array(aiCustomProviderSchema)
.safeParse(metadata.aiProviders);
return result.success ? result.data : [];
};
export const saveCustomAiProviders = async (
organizationId: string,
providers: AiCustomProvider[],
) => {
const org = await db.query.organization.findFirst({
where: eq(organization.id, organizationId),
});
if (!org) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Organization not found",
});
}
const metadata = parseOrgMetadata(org.metadata);
metadata.aiProviders = providers;
await db
.update(organization)
.set({ metadata: JSON.stringify(metadata) })
.where(eq(organization.id, organizationId));
return providers;
};
const normalizeApiUrl = (url: string) => url.trim().replace(/\/+$/, "");
export const saveAiSettings = async (organizationId: string, settings: any) => {
const aiId = settings.aiId;
if (settings.apiUrl) {
const customProviders = await getCustomAiProviders(organizationId);
if (customProviders.length > 0) {
const isAllowed = customProviders.some(
(provider) =>
normalizeApiUrl(provider.apiUrl) === normalizeApiUrl(settings.apiUrl),
);
if (!isAllowed) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"This API URL is not in your organization's allowed AI providers",
});
}
}
}
return db
.insert(ai)
.values({
@@ -136,7 +202,7 @@ export const suggestVariants = async ({
filePath: z.string(),
}),
)
.optional(),
.nullable(),
}),
),
});
@@ -198,12 +264,12 @@ export const suggestVariants = async ({
1. ALWAYS use 'image:' field, NEVER use 'build:' field
2. NEVER use 'build: .' or any build directive - we don't have local Dockerfiles
3. Use images from Docker Hub or other public registries (e.g., docker.io, ghcr.io, quay.io)
4. For dependencies (databases, redis, etc.), use official images (e.g., postgres:16, redis:7, etc.)
4. For dependencies (databases, redis, etc.), use official images (e.g., postgres:16, redis:8, etc.)
5. Always specify image tags - avoid using 'latest' tag, use specific versions when possible
6. Examples of correct image usage:
- image: sendingtk/chatwoot:develop
- image: postgres:16-alpine
- image: redis:7-alpine
- image: redis:8-alpine
7. Examples of INCORRECT usage (DO NOT USE):
- build: .
- build: ./app
@@ -229,7 +295,7 @@ export const suggestVariants = async ({
Domain Rules - For each service that needs to be exposed to the internet:
1. Define a domain with:
- host: {service-name}-{random-3-chars-hex}-${ip ? ip.replaceAll(".", "-") : ""}.traefik.me
- host: {service-name}-{random-3-chars-hex}-${ip ? ip.replaceAll(".", "-") : ""}.sslip.io
- port: the internal port the service runs on
- serviceName: the name of the service in the docker-compose
2. Make sure the service is properly configured to work with the specified port
+22 -13
View File
@@ -95,27 +95,36 @@ export const findApplicationById = async (applicationId: string) => {
const application = await db.query.applications.findFirst({
where: eq(applications.applicationId, applicationId),
with: {
environment: {
with: {
project: true,
},
},
environment: { with: { project: true } },
domains: true,
deployments: true,
mounts: true,
redirects: true,
security: true,
ports: true,
registry: true,
gitlab: true,
github: true,
bitbucket: true,
gitea: true,
gitlab: {
columns: { secret: false, accessToken: false, refreshToken: false },
},
github: {
columns: {
githubClientSecret: false,
githubPrivateKey: false,
githubWebhookSecret: false,
},
},
bitbucket: { columns: { appPassword: false, apiToken: false } },
gitea: {
columns: {
clientSecret: false,
accessToken: false,
refreshToken: false,
},
},
server: true,
previewDeployments: true,
buildRegistry: true,
rollbackRegistry: true,
registry: { columns: { password: false } },
buildRegistry: { columns: { password: false } },
rollbackRegistry: { columns: { password: false } },
},
});
if (!application) {
+12 -2
View File
@@ -34,7 +34,12 @@ export const findBackupById = async (backupId: string) => {
mariadb: true,
mongo: true,
libsql: true,
destination: true,
destination: {
columns: {
accessKey: false,
secretAccessKey: false,
},
},
compose: true,
},
});
@@ -83,7 +88,12 @@ export const findBackupsByDbId = async (
mariadb: true,
mongo: true,
libsql: true,
destination: true,
destination: {
columns: {
accessKey: false,
secretAccessKey: false,
},
},
},
});
return result || [];
+6 -5
View File
@@ -9,6 +9,7 @@ import {
import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { quote } from "shell-quote";
import { stringify } from "yaml";
import type { z } from "zod";
import { encodeBase64 } from "../utils/docker/utils";
@@ -63,7 +64,7 @@ export const removeCertificateById = async (certificateId: string) => {
const certDir = path.join(CERTIFICATES_PATH, certificate.certificatePath);
if (certificate.serverId) {
await execAsyncRemote(certificate.serverId, `rm -rf ${certDir}`);
await execAsyncRemote(certificate.serverId, `rm -rf ${quote([certDir])}`);
} else {
await removeDirectoryIfExistsContent(certDir);
}
@@ -108,10 +109,10 @@ const createCertificateFiles = async (certificate: Certificate) => {
const certificateData = encodeBase64(certificate.certificateData);
const privateKey = encodeBase64(certificate.privateKey);
const command = `
mkdir -p ${certDir};
echo "${certificateData}" | base64 -d > "${crtPath}";
echo "${privateKey}" | base64 -d > "${keyPath}";
echo "${yamlConfig}" > "${configFile}";
mkdir -p ${quote([certDir])};
echo "${certificateData}" | base64 -d > ${quote([crtPath])};
echo "${privateKey}" | base64 -d > ${quote([keyPath])};
echo "${yamlConfig}" > ${quote([configFile])};
`;
await execAsyncRemote(certificate.serverId, command);
+8 -2
View File
@@ -33,6 +33,7 @@ import { cloneGitlabRepository } from "@dokploy/server/utils/providers/gitlab";
import { getCreateComposeFileCommand } from "@dokploy/server/utils/providers/raw";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { quote } from "shell-quote";
import type { z } from "zod";
import { encodeBase64 } from "../utils/docker/utils";
import { getDokployUrl } from "./admin";
@@ -131,7 +132,12 @@ export const findComposeById = async (composeId: string) => {
server: true,
backups: {
with: {
destination: true,
destination: {
columns: {
accessKey: false,
secretAccessKey: false,
},
},
deployments: true,
},
},
@@ -467,7 +473,7 @@ export const startCompose = async (composeId: string) => {
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
const path =
compose.sourceType === "raw" ? "docker-compose.yml" : compose.composePath;
const baseCommand = `env -i PATH="$PATH" docker compose -p ${compose.appName} -f ${path} up -d`;
const baseCommand = `env -i PATH="$PATH" docker compose -p ${quote([compose.appName])} -f ${quote([path])} up -d`;
if (compose.composeType === "docker-compose") {
if (compose.serverId) {
await execAsyncRemote(
@@ -84,6 +84,7 @@ export const findDeploymentById = async (deploymentId: string) => {
where: eq(deployments.deploymentId, deploymentId),
with: {
application: true,
compose: true,
schedule: true,
},
});
+59 -14
View File
@@ -2,6 +2,7 @@ import {
execAsync,
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
import { quote } from "shell-quote";
export const getContainers = async (serverId?: string | null) => {
try {
@@ -417,21 +418,58 @@ export const getContainerLogs = async (
}
};
export const containerRestart = async (containerId: string) => {
try {
const { stdout, stderr } = await execAsync(
`docker container restart ${containerId}`,
);
export const containerRestart = async (
containerId: string,
serverId?: string,
) => {
const command = `docker container restart ${containerId}`;
const { stderr } = serverId
? await execAsyncRemote(serverId, command)
: await execAsync(command);
if (stderr) {
console.error(`Error: ${stderr}`);
return;
}
if (stderr) {
console.error(`Error: ${stderr}`);
throw new Error(stderr);
}
};
const config = JSON.parse(stdout);
export const containerStart = async (
containerId: string,
serverId?: string,
) => {
const command = `docker container start ${containerId}`;
const { stderr } = serverId
? await execAsyncRemote(serverId, command)
: await execAsync(command);
return config;
} catch {}
if (stderr) {
console.error(`Error: ${stderr}`);
throw new Error(stderr);
}
};
export const containerStop = async (containerId: string, serverId?: string) => {
const command = `docker container stop ${containerId}`;
const { stderr } = serverId
? await execAsyncRemote(serverId, command)
: await execAsync(command);
if (stderr) {
console.error(`Error: ${stderr}`);
throw new Error(stderr);
}
};
export const containerKill = async (containerId: string, serverId?: string) => {
const command = `docker container kill ${containerId}`;
const { stderr } = serverId
? await execAsyncRemote(serverId, command)
: await execAsync(command);
if (stderr) {
console.error(`Error: ${stderr}`);
throw new Error(stderr);
}
};
export const containerRemove = async (
@@ -482,7 +520,7 @@ export const getSwarmNodes = async (serverId?: string) => {
export const getNodeInfo = async (nodeId: string, serverId?: string) => {
try {
const command = `docker node inspect ${nodeId} --format '{{json .}}'`;
const command = `docker node inspect ${quote([nodeId])} --format '{{json .}}'`;
let stdout = "";
let stderr = "";
if (serverId) {
@@ -618,6 +656,8 @@ export const getAllContainerStats = async (serverId?: string) => {
}
};
const destinationPathRegex = /^[a-zA-Z0-9.\-_/]+$/;
export const uploadFileToContainer = async (
containerId: string,
fileBuffer: Buffer,
@@ -630,7 +670,12 @@ export const uploadFileToContainer = async (
throw new Error("Invalid container ID");
}
// Ensure destination path starts with /
if (!destinationPathRegex.test(destinationPath)) {
throw new Error(
"Invalid destination path: shell metacharacters are not allowed",
);
}
const normalizedPath = destinationPath.startsWith("/")
? destinationPath
: `/${destinationPath}`;
@@ -308,6 +308,48 @@ export const duplicateEnvironment = async (
return newEnvironment;
};
interface EnvironmentWithServices {
applications: { applicationId: string }[];
compose: { composeId: string }[];
libsql: { libsqlId: string }[];
mariadb: { mariadbId: string }[];
mongo: { mongoId: string }[];
mysql: { mysqlId: string }[];
postgres: { postgresId: string }[];
redis: { redisId: string }[];
}
export const filterEnvironmentServices = <T extends EnvironmentWithServices>(
environment: T,
accessedServices: string[],
): T => ({
...environment,
applications: environment.applications.filter((app) =>
accessedServices.includes(app.applicationId),
),
compose: environment.compose.filter((comp) =>
accessedServices.includes(comp.composeId),
),
libsql: environment.libsql.filter((db) =>
accessedServices.includes(db.libsqlId),
),
mariadb: environment.mariadb.filter((db) =>
accessedServices.includes(db.mariadbId),
),
mongo: environment.mongo.filter((db) =>
accessedServices.includes(db.mongoId),
),
mysql: environment.mysql.filter((db) =>
accessedServices.includes(db.mysqlId),
),
postgres: environment.postgres.filter((db) =>
accessedServices.includes(db.postgresId),
),
redis: environment.redis.filter((db) =>
accessedServices.includes(db.redisId),
),
});
export const createProductionEnvironment = async (projectId: string) => {
const newEnvironment = await db
.insert(environments)
@@ -43,6 +43,38 @@ export const updateGitProvider = async (
.then((response) => response[0]);
};
// Returns true if the user can edit the git source configuration of an existing
// deploy that is connected to the given provider.
// Owner/admin: always yes.
// Member: only if they own the provider or it's shared with the org.
// Being in accessedGitProviders only grants permission to connect NEW deploys,
// not to modify the git config of an existing deploy owned by someone else.
export const canEditDeployGitSource = async (
gitProviderId: string,
session: { userId: string; activeOrganizationId: string },
): Promise<boolean> => {
const { userId, activeOrganizationId } = session;
const memberRecord = await db.query.member.findFirst({
where: and(
eq(member.userId, userId),
eq(member.organizationId, activeOrganizationId),
),
columns: { role: true },
});
if (memberRecord?.role === "owner") return true;
const provider = await db.query.gitProvider.findFirst({
where: eq(gitProvider.gitProviderId, gitProviderId),
columns: { userId: true, sharedWithOrganization: true },
});
if (!provider) return false;
return provider.userId === userId || provider.sharedWithOrganization;
};
export const getAccessibleGitProviderIds = async (session: {
userId: string;
activeOrganizationId: string;
@@ -87,3 +119,30 @@ export const getAccessibleGitProviderIds = async (session: {
}
return result;
};
/**
* Authorizes read access to a specific git provider for the current session.
* Throws if the provider belongs to a different organization (cross-org IDOR)
* or if the caller is not entitled to it within the active organization.
* Must be called before returning any git-provider record that carries secrets
* (OAuth tokens, app private keys, webhook secrets).
*/
export const assertGitProviderAccess = async (
session: { userId: string; activeOrganizationId: string },
provider: { gitProviderId: string; organizationId: string },
) => {
if (provider.organizationId !== session.activeOrganizationId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Git provider not found",
});
}
const accessibleIds = await getAccessibleGitProviderIds(session);
if (!accessibleIds.has(provider.gitProviderId)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have access to this git provider",
});
}
};
+8 -2
View File
@@ -11,6 +11,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm";
import { quote } from "shell-quote";
import type { z } from "zod";
import { validUniqueServerAppName } from "./project";
@@ -63,7 +64,12 @@ export const findLibsqlById = async (libsqlId: string) => {
server: true,
backups: {
with: {
destination: true,
destination: {
columns: {
accessKey: false,
secretAccessKey: false,
},
},
deployments: true,
},
},
@@ -135,7 +141,7 @@ export const deployLibsql = async (
if (libsql.serverId) {
await execAsyncRemote(
libsql.serverId,
`docker pull ${libsql.dockerImage}`,
`docker pull ${quote([libsql.dockerImage])}`,
onData,
);
} else {
+8 -2
View File
@@ -11,6 +11,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm";
import { quote } from "shell-quote";
import type { z } from "zod";
import { validUniqueServerAppName } from "./project";
@@ -68,7 +69,12 @@ export const findMariadbById = async (mariadbId: string) => {
server: true,
backups: {
with: {
destination: true,
destination: {
columns: {
accessKey: false,
secretAccessKey: false,
},
},
deployments: true,
},
},
@@ -140,7 +146,7 @@ export const deployMariadb = async (
if (mariadb.serverId) {
await execAsyncRemote(
mariadb.serverId,
`docker pull ${mariadb.dockerImage}`,
`docker pull ${quote([mariadb.dockerImage])}`,
onData,
);
} else {
+8 -2
View File
@@ -12,6 +12,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm";
import { quote } from "shell-quote";
import type { z } from "zod";
import { validUniqueServerAppName } from "./project";
@@ -63,7 +64,12 @@ export const findMongoById = async (mongoId: string) => {
server: true,
backups: {
with: {
destination: true,
destination: {
columns: {
accessKey: false,
secretAccessKey: false,
},
},
deployments: true,
},
},
@@ -155,7 +161,7 @@ export const deployMongo = async (
if (mongo.serverId) {
await execAsyncRemote(
mongo.serverId,
`docker pull ${mongo.dockerImage}`,
`docker pull ${quote([mongo.dockerImage])}`,
onData,
);
} else {
+3 -2
View File
@@ -18,6 +18,7 @@ import {
} from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq, type SQL, sql } from "drizzle-orm";
import { quote } from "shell-quote";
import type { z } from "zod";
export type Mount = typeof mounts.$inferSelect;
@@ -317,7 +318,7 @@ export const updateFileMount = async (mountId: string) => {
try {
const serverId = await getServerId(mount);
const encodedContent = encodeBase64(mount.content || "");
const command = `echo "${encodedContent}" | base64 -d > ${fullPath}`;
const command = `echo "${encodedContent}" | base64 -d > ${quote([fullPath])}`;
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
@@ -337,7 +338,7 @@ export const deleteFileMount = async (mountId: string) => {
try {
const serverId = await getServerId(mount);
if (serverId) {
const command = `rm -rf ${fullPath}`;
const command = `rm -rf ${quote([fullPath])}`;
await execAsyncRemote(serverId, command);
} else {
await removeFileOrDirectory(fullPath);
+8 -2
View File
@@ -11,6 +11,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm";
import { quote } from "shell-quote";
import type { z } from "zod";
import { validUniqueServerAppName } from "./project";
@@ -66,7 +67,12 @@ export const findMySqlById = async (mysqlId: string) => {
server: true,
backups: {
with: {
destination: true,
destination: {
columns: {
accessKey: false,
secretAccessKey: false,
},
},
deployments: true,
},
},
@@ -138,7 +144,7 @@ export const deployMySql = async (
if (mysql.serverId) {
await execAsyncRemote(
mysql.serverId,
`docker pull ${mysql.dockerImage}`,
`docker pull ${quote([mysql.dockerImage])}`,
onData,
);
} else {
+3 -2
View File
@@ -1,6 +1,7 @@
import { join } from "node:path";
import { paths } from "@dokploy/server/constants";
import { TRPCError } from "@trpc/server";
import { quote } from "shell-quote";
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
import { cloneBitbucketRepository } from "../utils/providers/bitbucket";
import { cloneGitRepository } from "../utils/providers/git";
@@ -85,7 +86,7 @@ export const readPatchRepoDirectory = async (
serverId?: string | null,
): Promise<DirectoryEntry[]> => {
// Use git ls-tree to get tracked files only
const command = `cd "${repoPath}" && git ls-tree -r --name-only HEAD`;
const command = `cd ${quote([repoPath])} && git ls-tree -r --name-only HEAD`;
let stdout: string;
try {
@@ -168,7 +169,7 @@ export const readPatchRepoFile = async (
const repoPath = join(PATCH_REPOS_PATH, type, application.appName);
const fullPath = join(repoPath, filePath);
const command = `cat "${fullPath}"`;
const command = `cat ${quote([fullPath])}`;
if (serverId) {
const result = await execAsyncRemote(serverId, command);
+7 -2
View File
@@ -80,9 +80,10 @@ export const checkPermission = async (
const { id: userId } = ctx.user;
const { activeOrganizationId: organizationId } = ctx.session;
const memberRecord = await findMemberByUserId(userId, organizationId);
const isStaticRole = memberRecord.role in staticRoles;
if (isStaticRole) {
const isPrivilegedStaticRole =
memberRecord.role === "owner" || memberRecord.role === "admin";
if (isPrivilegedStaticRole) {
const allEnterprise = Object.keys(permissions).every((r) =>
enterpriseOnlyResources.has(r),
);
@@ -164,9 +165,13 @@ const getLegacyOverrides = (
},
sshKeys: {
read: !!memberRecord.canAccessToSSHKeys,
create: !!memberRecord.canAccessToSSHKeys,
delete: !!memberRecord.canAccessToSSHKeys,
},
gitProviders: {
read: !!memberRecord.canAccessToGitProviders,
create: !!memberRecord.canAccessToGitProviders,
delete: !!memberRecord.canAccessToGitProviders,
},
};
};
+8 -2
View File
@@ -11,6 +11,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm";
import { quote } from "shell-quote";
import type { z } from "zod";
import { validUniqueServerAppName } from "./project";
@@ -76,7 +77,12 @@ export const findPostgresById = async (postgresId: string) => {
server: true,
backups: {
with: {
destination: true,
destination: {
columns: {
accessKey: false,
secretAccessKey: false,
},
},
deployments: true,
},
},
@@ -150,7 +156,7 @@ export const deployPostgres = async (
if (postgres.serverId) {
await execAsyncRemote(
postgres.serverId,
`docker pull ${postgres.dockerImage}`,
`docker pull ${quote([postgres.dockerImage])}`,
onData,
);
} else {
@@ -30,13 +30,9 @@ export const findPreviewDeploymentById = async (
with: {
domain: true,
application: {
with: {
server: true,
environment: {
with: {
project: true,
},
},
columns: {
applicationId: true,
serverId: true,
},
},
},
@@ -140,7 +136,7 @@ export const createPreviewDeployment = async (
where: eq(organization.id, application.environment.project.organizationId),
});
const generateDomain = await generateWildcardDomain(
application.previewWildcard || "*.traefik.me",
application.previewWildcard || "*.sslip.io",
appName,
application.server?.ipAddress || "",
org?.ownerId || "",
@@ -242,7 +238,7 @@ const generateWildcardDomain = async (
throw new Error('The base domain must start with "*."');
}
const hash = `${appName}`;
if (baseDomain.includes("traefik.me")) {
if (baseDomain.includes("sslip.io")) {
let ip = "";
if (process.env.NODE_ENV === "development") {
@@ -0,0 +1,382 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { db } from "@dokploy/server/db";
import {
forwardAuthSettings,
server,
ssoProvider,
} from "@dokploy/server/db/schema";
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
import {
deriveBaseDomain,
deriveCookieSecret,
type ForwardAuthOidcConfig,
forwardAuthCallbackUrl,
isForwardAuthRunning,
removeForwardAuth,
setupForwardAuth,
} from "@dokploy/server/setup/forward-auth-setup";
import { manageDomain } from "@dokploy/server/utils/traefik/domain";
import {
manageForwardAuthDomain,
removeForwardAuthDomain,
removeForwardAuthMiddleware,
} from "@dokploy/server/utils/traefik/forward-auth";
import { TRPCError } from "@trpc/server";
import { and, asc, desc, eq, isNotNull, isNull } from "drizzle-orm";
import { findApplicationById } from "../application";
import { findDomainById, updateDomainById } from "../domain";
const resolveOidcConfig = (provider: {
issuer: string;
oidcConfig: string | null;
}): ForwardAuthOidcConfig => {
if (!provider.oidcConfig) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Forward-auth requires an OIDC provider — SAML is not supported.",
});
}
let parsed: any;
try {
parsed = JSON.parse(provider.oidcConfig);
} catch {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to parse the SSO provider OIDC configuration",
});
}
if (!parsed?.clientId || !parsed?.clientSecret) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "SSO provider OIDC config is missing clientId/clientSecret",
});
}
return {
clientId: parsed.clientId,
clientSecret: parsed.clientSecret,
issuer: provider.issuer,
scopes: parsed.scopes,
skipDiscovery: parsed.skipDiscovery,
};
};
const findProviderForOrg = async (
providerId: string,
organizationId: string,
) => {
const provider = await db.query.ssoProvider.findFirst({
where: and(
eq(ssoProvider.providerId, providerId),
eq(ssoProvider.organizationId, organizationId),
),
columns: { providerId: true, issuer: true, oidcConfig: true },
});
if (!provider) {
throw new TRPCError({
code: "NOT_FOUND",
message: "SSO provider not found",
});
}
return provider;
};
export const listSsoProvidersForOrg = async (organizationId: string) => {
return db.query.ssoProvider.findMany({
where: and(
eq(ssoProvider.organizationId, organizationId),
isNotNull(ssoProvider.oidcConfig),
),
columns: { providerId: true, issuer: true, domain: true },
orderBy: [asc(ssoProvider.createdAt)],
});
};
export const getDomainSsoStatus = async (
ctx: { session: { activeOrganizationId: string } },
domainId: string,
) => {
const domain = await findDomainById(domainId);
if (domain.applicationId) {
await checkServicePermissionAndAccess(ctx as any, domain.applicationId, {
domain: ["read"],
});
}
return { enabled: !!domain.forwardAuthEnabled };
};
const settingsWhere = (serverId: string | null) =>
serverId
? eq(forwardAuthSettings.serverId, serverId)
: isNull(forwardAuthSettings.serverId);
export const getForwardAuthSettings = async (serverId: string | null) => {
return db.query.forwardAuthSettings.findFirst({
where: settingsWhere(serverId),
});
};
export const setForwardAuthSettings = async (input: {
organizationId: string;
serverId: string | null;
authDomain: string;
https: boolean;
certificateType: "none" | "letsencrypt" | "custom";
customCertResolver?: string | null;
}) => {
const baseDomain = deriveBaseDomain(input.authDomain);
const existing = await getForwardAuthSettings(input.serverId);
const values = {
authDomain: input.authDomain,
baseDomain,
https: input.https,
certificateType: input.certificateType,
customCertResolver: input.customCertResolver ?? null,
};
if (existing) {
await db
.update(forwardAuthSettings)
.set(values)
.where(settingsWhere(input.serverId));
} else {
await db.insert(forwardAuthSettings).values({
...values,
serverId: input.serverId,
});
}
await manageForwardAuthDomain(input.serverId, {
authDomain: input.authDomain,
https: input.https,
certificateType: input.certificateType,
customCertResolver: input.customCertResolver,
});
if (existing?.providerId) {
const proxyRunning = await isForwardAuthRunning(
input.serverId ?? undefined,
);
if (proxyRunning) {
await deployForwardAuthOnServer({
serverId: input.serverId ?? undefined,
providerId: existing.providerId,
organizationId: input.organizationId,
});
}
}
return { callbackUrl: forwardAuthCallbackUrl(input.authDomain, input.https) };
};
export const removeForwardAuthSettings = async (serverId: string | null) => {
const existing = await getForwardAuthSettings(serverId);
if (!existing) return { ok: true } as const;
await removeForwardAuthDomain(serverId);
await db.delete(forwardAuthSettings).where(settingsWhere(serverId));
return { ok: true } as const;
};
export const deployForwardAuthOnServer = async (input: {
serverId?: string;
providerId: string;
organizationId: string;
}) => {
const settings = await getForwardAuthSettings(input.serverId ?? null);
if (!settings) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message:
"Set the authentication domain for this server before deploying the proxy.",
});
}
const provider = await findProviderForOrg(
input.providerId,
input.organizationId,
);
const oidc = resolveOidcConfig(provider);
await setupForwardAuth({
serverId: input.serverId,
oidc,
cookieSecret: deriveCookieSecret(
`${input.serverId ?? "host"}:${settings.baseDomain}`,
),
authDomain: settings.authDomain,
baseDomain: settings.baseDomain,
authDomainHttps: settings.https,
});
if (settings.providerId !== input.providerId) {
await db
.update(forwardAuthSettings)
.set({ providerId: input.providerId })
.where(settingsWhere(input.serverId ?? null));
}
return { ok: true } as const;
};
const FORWARD_AUTH_CHECK_TIMEOUT_MS = 4000;
const proxyStatus = async (
serverId: string | null,
): Promise<"running" | "stopped" | "unknown"> => {
try {
const running = await Promise.race([
isForwardAuthRunning(serverId ?? undefined),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("timeout")),
FORWARD_AUTH_CHECK_TIMEOUT_MS,
),
),
]);
return running ? "running" : "stopped";
} catch {
return "unknown";
}
};
export const getForwardAuthServerStatus = async (organizationId: string) => {
const servers = await db.query.server.findMany({
where: and(
eq(server.organizationId, organizationId),
isNotNull(server.sshKeyId),
eq(server.serverType, "deploy"),
),
columns: { serverId: true, name: true, ipAddress: true },
orderBy: [desc(server.createdAt)],
});
const targets: {
serverId: string | null;
name: string;
ipAddress: string | null;
}[] = [
...(IS_CLOUD
? []
: [
{
serverId: null,
name: "Dokploy Server (local)",
ipAddress: null,
},
]),
...servers.map((s) => ({
serverId: s.serverId,
name: s.name,
ipAddress: s.ipAddress,
})),
];
return Promise.all(
targets.map(async (t) => {
const settings = await getForwardAuthSettings(t.serverId);
return {
...t,
status: await proxyStatus(t.serverId),
authDomain: settings?.authDomain ?? null,
https: settings?.https ?? true,
certificateType: settings?.certificateType ?? "none",
customCertResolver: settings?.customCertResolver ?? null,
callbackUrl: settings
? forwardAuthCallbackUrl(settings.authDomain, settings.https)
: null,
};
}),
);
};
export const removeForwardAuthProxy = async (serverId: string | null) => {
await removeForwardAuth(serverId ?? undefined);
await db
.update(forwardAuthSettings)
.set({ providerId: null })
.where(settingsWhere(serverId));
return { ok: true } as const;
};
const resolveApplicationDomain = async (domainId: string) => {
const domain = await findDomainById(domainId);
if (!domain.applicationId) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"SSO forward-auth is currently only supported on application domains",
});
}
const application = await findApplicationById(domain.applicationId);
return { domain, application };
};
export const assertApplicationDomainAccess = async (
ctx: { session: { activeOrganizationId: string } },
domainId: string,
action: "create" | "delete",
) => {
const domain = await findDomainById(domainId);
if (!domain.applicationId) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"SSO forward-auth is currently only supported on application domains",
});
}
await checkServicePermissionAndAccess(ctx as any, domain.applicationId, {
domain: [action],
});
return domain;
};
export const enableForwardAuthOnDomain = async (input: {
domainId: string;
}) => {
const { application } = await resolveApplicationDomain(input.domainId);
const serverId = application.serverId ?? undefined;
const settings = await getForwardAuthSettings(serverId ?? null);
if (!settings?.providerId) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message:
"Deploy the authentication proxy for this server in SSO settings first.",
});
}
const proxyRunning = await isForwardAuthRunning(serverId);
if (!proxyRunning) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message:
"The authentication proxy is not deployed on this server. Deploy it in SSO settings first.",
});
}
await updateDomainById(input.domainId, { forwardAuthEnabled: true });
const domain = await findDomainById(input.domainId);
await manageDomain(application, domain);
return { ok: true } as const;
};
export const disableForwardAuthOnDomain = async (input: {
domainId: string;
}) => {
const { application, domain } = await resolveApplicationDomain(
input.domainId,
);
const uniqueConfigKey = domain.uniqueConfigKey;
await updateDomainById(input.domainId, { forwardAuthEnabled: false });
const updated = await findDomainById(input.domainId);
await manageDomain(application, updated);
await removeForwardAuthMiddleware(application, uniqueConfigKey);
return { ok: true } as const;
};
@@ -0,0 +1,58 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { db } from "@dokploy/server/db";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
export interface PublicWhitelabelingConfig {
appName: string | null;
appDescription: string | null;
logoUrl: string | null;
loginLogoUrl: string | null;
faviconUrl: string | null;
customCss: string | null;
metaTitle: string | null;
errorPageTitle: string | null;
errorPageDescription: string | null;
footerText: string | null;
}
// Self-hosted is single-tenant; gate on the oldest organization's license,
// mirroring resolveLocalConcurrency in server/queues/concurrency.ts. Used only
// for unauthenticated requests (no active organization in session).
const hasAnyValidLicense = async (): Promise<boolean> => {
const org = await db.query.organization.findFirst({
columns: { id: true },
orderBy: (organization, { asc }) => [asc(organization.createdAt)],
});
return org ? await hasValidLicense(org.id) : false;
};
/**
* Public whitelabeling config for unauthenticated contexts (login page, SSR
* document shell). No active organization/session is available here.
*/
export const getPublicWhitelabelingConfig =
async (): Promise<PublicWhitelabelingConfig | null> => {
if (IS_CLOUD) {
return null;
}
if (!(await hasAnyValidLicense())) {
return null;
}
const settings = await getWebServerSettings();
const config = settings?.whitelabelingConfig;
if (!config) return null;
return {
appName: config.appName,
appDescription: config.appDescription,
logoUrl: config.logoUrl,
loginLogoUrl: config.loginLogoUrl,
faviconUrl: config.faviconUrl,
customCss: config.customCss,
metaTitle: config.metaTitle,
errorPageTitle: config.errorPageTitle,
errorPageDescription: config.errorPageDescription,
footerText: config.footerText,
};
};
+2 -1
View File
@@ -10,6 +10,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { quote } from "shell-quote";
import type { z } from "zod";
import { validUniqueServerAppName } from "./project";
@@ -110,7 +111,7 @@ export const deployRedis = async (
if (redis.serverId) {
await execAsyncRemote(
redis.serverId,
`docker pull ${redis.dockerImage}`,
`docker pull ${quote([redis.dockerImage])}`,
onData,
);
} else {
+46 -10
View File
@@ -27,6 +27,16 @@ export function safeDockerLoginCommand(
return `printf %s ${escapedPassword} | docker login ${escapedRegistry} -u ${escapedUser} --password-stdin`;
}
function sanitizeRegistryError(
error: unknown,
password: string | null | undefined,
): string {
const message =
error instanceof Error ? error.message : "Error with registry login";
if (!password) return message;
return message.split(password).join("***");
}
export const createRegistry = async (
input: z.infer<typeof apiCreateRegistry>,
organizationId: string,
@@ -59,10 +69,15 @@ export const createRegistry = async (
input.username,
input.password,
);
if (input.serverId && input.serverId !== "none") {
await execAsyncRemote(input.serverId, loginCommand);
} else if (newRegistry.registryType === "cloud") {
await execAsync(loginCommand);
try {
if (input.serverId && input.serverId !== "none") {
await execAsyncRemote(input.serverId, loginCommand);
} else if (newRegistry.registryType === "cloud") {
await execAsync(loginCommand);
}
} catch (error) {
const sanitized = sanitizeRegistryError(error, input.password);
throw new TRPCError({ code: "BAD_REQUEST", message: sanitized });
}
return newRegistry;
@@ -85,7 +100,7 @@ export const removeRegistry = async (registryId: string) => {
}
if (!IS_CLOUD) {
await execAsync(`docker logout ${response.registryUrl}`);
await execAsync(`docker logout ${shEscape(response.registryUrl)}`);
}
return response;
@@ -129,16 +144,24 @@ export const updateRegistry = async (
});
}
if (registryData?.serverId && registryData?.serverId !== "none") {
await execAsyncRemote(registryData.serverId, loginCommand);
} else if (response?.registryType === "cloud") {
await execAsync(loginCommand);
try {
if (registryData?.serverId && registryData?.serverId !== "none") {
await execAsyncRemote(registryData.serverId, loginCommand);
} else if (response?.registryType === "cloud") {
await execAsync(loginCommand);
}
} catch (execError) {
throw new Error(sanitizeRegistryError(execError, response?.password));
}
return response;
} catch (error) {
const message =
error instanceof Error ? error.message : "Error updating this registry";
error instanceof TRPCError
? error.message
: error instanceof Error
? error.message
: "Error updating this registry";
throw new TRPCError({
code: "BAD_REQUEST",
message,
@@ -162,6 +185,19 @@ export const findRegistryById = async (registryId: string) => {
return registryResponse;
};
export const findRegistryByIdWithCredentials = async (registryId: string) => {
const registryResponse = await db.query.registry.findFirst({
where: eq(registry.registryId, registryId),
});
if (!registryResponse) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Registry not found",
});
}
return registryResponse;
};
export const findAllRegistryByOrganizationId = async (
organizationId: string,
) => {
+37 -16
View File
@@ -7,7 +7,6 @@ import {
deployments as deploymentsSchema,
rollbacks,
} from "../db/schema";
import type { ApplicationNested } from "../utils/builders";
import { getRegistryTag } from "../utils/cluster/upload";
import {
calculateResources,
@@ -23,7 +22,11 @@ import { findDeploymentById } from "./deployment";
import type { Mount } from "./mount";
import type { Port } from "./port";
import type { Project } from "./project";
import { type Registry, safeDockerLoginCommand } from "./registry";
import {
findRegistryByIdWithCredentials,
type Registry,
safeDockerLoginCommand,
} from "./registry";
export const createRollback = async (
input: z.infer<typeof createRollbackSchema>,
@@ -56,11 +59,29 @@ export const createRollback = async (
...rest
} = await findApplicationById(deployment.applicationId);
const registry = rest.registryId
? await findRegistryByIdWithCredentials(rest.registryId)
: rest.registry;
const buildRegistry = rest.buildRegistryId
? await findRegistryByIdWithCredentials(rest.buildRegistryId)
: rest.buildRegistry;
const rollbackRegistry = rest.rollbackRegistryId
? await findRegistryByIdWithCredentials(rest.rollbackRegistryId)
: rest.rollbackRegistry;
const fullContextWithCredentials = {
...rest,
registry,
buildRegistry,
rollbackRegistry,
};
await tx
.update(rollbacks)
.set({
image: tagImage,
fullContext: rest,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fullContext: fullContextWithCredentials as any,
})
.where(eq(rollbacks.rollbackId, rollback.rollbackId));
@@ -162,7 +183,6 @@ export const rollback = async (rollbackId: string) => {
if (!result.fullContext) {
throw new Error("Rollback context not found");
}
// Use the full context for rollback
await rollbackApplication(
application.appName,
result.image || "",
@@ -198,24 +218,25 @@ const rollbackApplication = async (
};
mounts: Mount[];
ports: Port[];
rollbackRegistry?: Registry;
rollbackRegistry?: Registry | null;
},
) => {
if (!fullContext) {
throw new Error("Full context is required for rollback");
}
const rollbackRegistry = fullContext.rollbackRegistry ?? undefined;
// Ensure Docker daemon is authenticated with the rollback registry
// before updating the swarm service. The authconfig in CreateServiceOptions
// alone is not sufficient — Docker Swarm also relies on the daemon's
// cached credentials (~/.docker/config.json) to distribute auth to nodes.
if (fullContext.rollbackRegistry) {
await dockerLoginForRegistry(fullContext.rollbackRegistry, serverId);
if (rollbackRegistry) {
await dockerLoginForRegistry(rollbackRegistry, serverId);
}
const docker = await getRemoteDocker(serverId);
// Use the same configuration as mechanizeDockerContainer
const {
env,
mounts,
@@ -246,7 +267,9 @@ const rollbackApplication = async (
UpdateConfig,
Networks,
Ulimits,
} = generateConfigContainer(fullContext as ApplicationNested);
} = generateConfigContainer(
fullContext as Parameters<typeof generateConfigContainer>[0],
);
const bindsMount = generateBindMounts(mounts);
const envVariables = prepareEnvironmentVariables(
@@ -254,18 +277,16 @@ const rollbackApplication = async (
fullContext.environment.project.env,
);
// Build the full registry image path if rollbackRegistry is available
// e.g., "appName:v5" -> "siumauricio/appName:v5" or "registry.com/prefix/appName:v5"
let rollbackImage = image;
if (fullContext.rollbackRegistry) {
rollbackImage = getRegistryTag(fullContext.rollbackRegistry, image);
if (rollbackRegistry) {
rollbackImage = getRegistryTag(rollbackRegistry, image);
}
const settings: CreateServiceOptions = {
authconfig: {
password: fullContext.rollbackRegistry?.password || "",
username: fullContext.rollbackRegistry?.username || "",
serveraddress: fullContext.rollbackRegistry?.registryUrl || "",
password: rollbackRegistry?.password || "",
username: rollbackRegistry?.username || "",
serveraddress: rollbackRegistry?.registryUrl || "",
},
Name: appName,
TaskTemplate: {
+46 -1
View File
@@ -2,7 +2,7 @@ import path from "node:path";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import type { z } from "zod";
import { paths } from "../constants";
import { IS_CLOUD, paths } from "../constants";
import { db } from "../db";
import type {
createScheduleSchema,
@@ -11,9 +11,51 @@ import type {
import { type Schedule, schedules } from "../db/schema/schedule";
import { encodeBase64 } from "../utils/docker/utils";
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
import { findMemberByUserId } from "./permission";
import { findServerById } from "./server";
export type ScheduleExtended = Awaited<ReturnType<typeof findScheduleById>>;
// Host-level schedules (server / dokploy-server) run their script as root on the
// host and must stay restricted to owners/admins, regardless of whether the
// request is also tied to a service. Attaching an accessible applicationId must
// not downgrade this to a service-access check.
export const assertHostScheduleAccess = async (
ctx: { user: { id: string }; session: { activeOrganizationId: string } },
scheduleType: Schedule["scheduleType"] | null | undefined,
serverId: string | null | undefined,
) => {
if (scheduleType !== "server" && scheduleType !== "dokploy-server") return;
if (scheduleType === "dokploy-server" && IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Host-level schedules are not available in the cloud version.",
});
}
const member = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (member.role !== "owner" && member.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only owners and admins can manage server-level schedules.",
});
}
if (scheduleType === "server" && serverId) {
const targetServer = await findServerById(serverId);
if (targetServer.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
};
export const createSchedule = async (
input: z.infer<typeof createScheduleSchema>,
) => {
@@ -85,6 +127,9 @@ export const findScheduleOrganizationId = async (scheduleId: string) => {
if (schedule?.server) {
return schedule?.server?.organization?.id;
}
if (schedule?.organizationId) {
return schedule.organizationId;
}
return null;
};
+21
View File
@@ -53,6 +53,27 @@ export const findServerById = async (serverId: string) => {
return currentServer;
};
/**
* Removes the SSH private key material from a server record before it is sent
* to a client. `findServerById` eagerly loads the `sshKey` relation (needed for
* server-side SSH operations), but the private key must never leave the server:
* no client feature consumes it, and returning it exposed it to any member with
* only `server:read`. Server-side callers keep using `findServerById` directly.
*/
export const redactServerSshKey = <
T extends { sshKey?: { privateKey: string } | null },
>(
serverRecord: T,
): T => {
if (!serverRecord.sshKey) {
return serverRecord;
}
return {
...serverRecord,
sshKey: { ...serverRecord.sshKey, privateKey: "" },
};
};
export const findServersByUserId = async (userId: string) => {
const orgs = await db.query.organization.findMany({
where: eq(organization.ownerId, userId),
+1 -1
View File
@@ -430,7 +430,7 @@ export const createOrganizationUserWithCredentials = async ({
.insert(user)
.values({
email: normalizedEmail,
emailVerified: false,
emailVerified: true,
updatedAt: now,
})
.returning({
@@ -84,7 +84,12 @@ export const findVolumeBackupById = async (volumeBackupId: string) => {
},
},
},
destination: true,
destination: {
columns: {
accessKey: false,
secretAccessKey: false,
},
},
},
});