mirror of
https://github.com/andwati/dokploy.git
synced 2026-10-22 13:02:20 +00:00
Merge branch 'canary' into feat/add-network-management
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 || [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
) => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user