diff --git a/apps/dokploy/__test__/wss/authorize.test.ts b/apps/dokploy/__test__/wss/authorize.test.ts index 63fe1b9ac..aafcccec4 100644 --- a/apps/dokploy/__test__/wss/authorize.test.ts +++ b/apps/dokploy/__test__/wss/authorize.test.ts @@ -15,9 +15,12 @@ vi.mock("@dokploy/server", () => ({ getAccessibleServerIds: mockGetAccessibleServerIds, })); +vi.mock("@dokploy/server/db", () => ({ db: {} })); + import { canAccessDockerOverWss, canAccessTerminalOverWss, + isServiceContainerName, } from "@/server/wss/authorize"; const USER = { id: "user-1" }; @@ -78,6 +81,42 @@ describe("canAccessDockerOverWss", () => { }); }); +describe("isServiceContainerName", () => { + it("matches an application or database only by its exact appName", () => { + expect(isServiceContainerName("web-abc", "application", "web-abc")).toBe( + true, + ); + expect(isServiceContainerName("other-xyz", "application", "web-abc")).toBe( + false, + ); + // Service access must not unlock the host-wide "dokploy" stats. + expect(isServiceContainerName("dokploy", "application", "web-abc")).toBe( + false, + ); + }); + + it("matches stack tasks inside the service's namespace only", () => { + expect( + isServiceContainerName("stack-abc_web.1.task", "stack", "stack-abc"), + ).toBe(true); + expect( + isServiceContainerName("stack-abcdef_web.1.task", "stack", "stack-abc"), + ).toBe(false); + }); + + it("leaves compose containers to the project label filter", () => { + expect( + isServiceContainerName("custom-name", "docker-compose", "compose-abc"), + ).toBe(true); + }); + + it("rejects an unknown appType", () => { + // The socket would apply no ownership filter for it. + expect(isServiceContainerName("web-abc", "invalid", "web-abc")).toBe(false); + expect(isServiceContainerName("web-abc", "", "web-abc")).toBe(false); + }); +}); + describe("canAccessTerminalOverWss", () => { it("denies the local host terminal to a plain member", async () => { mockFindMember.mockResolvedValue({ role: "member" }); diff --git a/apps/dokploy/components/dashboard/monitoring/free/container/show-free-compose-monitoring.tsx b/apps/dokploy/components/dashboard/monitoring/free/container/show-free-compose-monitoring.tsx index fc57221bd..5870d3ace 100644 --- a/apps/dokploy/components/dashboard/monitoring/free/container/show-free-compose-monitoring.tsx +++ b/apps/dokploy/components/dashboard/monitoring/free/container/show-free-compose-monitoring.tsx @@ -27,12 +27,14 @@ interface Props { appName: string; serverId?: string; appType: "stack" | "docker-compose"; + serviceId?: string; } export const ComposeFreeMonitoring = ({ appName, appType = "stack", serverId, + serviceId, }: Props) => { const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery( { @@ -123,6 +125,7 @@ export const ComposeFreeMonitoring = ({ diff --git a/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx b/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx index 9722ae51e..8f313d8d4 100644 --- a/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx +++ b/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx @@ -46,6 +46,7 @@ const defaultData = { interface Props { appName: string; appType?: "application" | "stack" | "docker-compose"; + serviceId?: string; } export interface DockerStats { cpu: { @@ -120,6 +121,7 @@ export const convertMemoryToBytes = ( export const ContainerFreeMonitoring = ({ appName, appType = "application", + serviceId, }: Props) => { const { data } = api.application.readAppMonitoring.useQuery( { appName }, @@ -171,7 +173,11 @@ export const ContainerFreeMonitoring = ({ if (!appName) return; const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const wsUrl = `${protocol}//${window.location.host}/listen-docker-stats-monitoring?appName=${appName}&appType=${appType}`; + const params = new URLSearchParams({ appName, appType }); + if (serviceId) { + params.append("serviceId", serviceId); + } + const wsUrl = `${protocol}//${window.location.host}/listen-docker-stats-monitoring?${params.toString()}`; const ws = new WebSocket(wsUrl); ws.onmessage = (e) => { @@ -205,7 +211,7 @@ export const ContainerFreeMonitoring = ({ }; return () => ws.close(); - }, [appName]); + }, [appName, serviceId]); return (
diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx index 27c9099c8..cddecc9ad 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx @@ -345,6 +345,7 @@ const Service = (
{/* )} */} diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx index 9184515d2..aecb6140e 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx @@ -381,6 +381,7 @@ const Service = ( serverId={data?.serverId || ""} appName={data?.appName || ""} appType={data?.composeType || "docker-compose"} + serviceId={data?.composeId} /> {/*
*/} {/* )} */} diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/libsql/[libsqlId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/libsql/[libsqlId].tsx index b31adfe0b..dffc1bebe 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/libsql/[libsqlId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/libsql/[libsqlId].tsx @@ -268,6 +268,7 @@ const Libsql = (
*/} {/*
*/} {/* )} */} diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mariadb/[mariadbId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mariadb/[mariadbId].tsx index ee5ac55b5..b5ad24ee0 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mariadb/[mariadbId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mariadb/[mariadbId].tsx @@ -296,6 +296,7 @@ const Mariadb = (
*/} {/*
*/} {/* )} */} diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mongo/[mongoId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mongo/[mongoId].tsx index 7e922863f..9599b9507 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mongo/[mongoId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mongo/[mongoId].tsx @@ -296,6 +296,7 @@ const Mongo = (
*/} {/*
*/} {/* )} */} diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mysql/[mysqlId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mysql/[mysqlId].tsx index fa44c4e07..28c7b98f9 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mysql/[mysqlId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mysql/[mysqlId].tsx @@ -275,6 +275,7 @@ const MySql = ( <> )} diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/postgres/[postgresId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/postgres/[postgresId].tsx index d734f2c59..41a7fc82c 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/postgres/[postgresId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/postgres/[postgresId].tsx @@ -283,6 +283,7 @@ const Postgresql = ( <> )} diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/redis/[redisId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/redis/[redisId].tsx index c3723a99a..349015e41 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/redis/[redisId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/redis/[redisId].tsx @@ -294,6 +294,7 @@ const Redis = (
*/} {/*
*/} {/* )} */} diff --git a/apps/dokploy/server/wss/authorize.ts b/apps/dokploy/server/wss/authorize.ts index b9a04311c..9cf922b12 100644 --- a/apps/dokploy/server/wss/authorize.ts +++ b/apps/dokploy/server/wss/authorize.ts @@ -1,4 +1,5 @@ import { getAccessibleServerIds } from "@dokploy/server"; +import { db } from "@dokploy/server/db"; import { checkServiceAccess, findMemberByUserId, @@ -59,6 +60,66 @@ export const canAccessDockerOverWss = async ( return true; }; +// Returns the appName of the service with this id, whichever service type it +// is. Service-scoped sockets use it to check that the requested container +// belongs to the service the caller was authorized for. +export const findServiceAppName = async ( + serviceId: string, +): Promise => { + const columns = { appName: true } as const; + const results = await Promise.all([ + db.query.applications.findFirst({ + columns, + where: (t, { eq }) => eq(t.applicationId, serviceId), + }), + db.query.compose.findFirst({ + columns, + where: (t, { eq }) => eq(t.composeId, serviceId), + }), + db.query.postgres.findFirst({ + columns, + where: (t, { eq }) => eq(t.postgresId, serviceId), + }), + db.query.mysql.findFirst({ + columns, + where: (t, { eq }) => eq(t.mysqlId, serviceId), + }), + db.query.mariadb.findFirst({ + columns, + where: (t, { eq }) => eq(t.mariadbId, serviceId), + }), + db.query.mongo.findFirst({ + columns, + where: (t, { eq }) => eq(t.mongoId, serviceId), + }), + db.query.redis.findFirst({ + columns, + where: (t, { eq }) => eq(t.redisId, serviceId), + }), + db.query.libsql.findFirst({ + columns, + where: (t, { eq }) => eq(t.libsqlId, serviceId), + }), + ]); + return results.find(Boolean)?.appName ?? null; +}; + +// Whether a stats request for `appName` targets the service named +// `serviceAppName`. Applications and databases run as one swarm service named +// after the app; stack tasks are namespaced "_". Compose +// container names are free-form, so the stats socket also filters compose and +// stack containers by their project/namespace label. `appType` comes from the +// URL, so any other value is rejected. +export const isServiceContainerName = ( + appName: string, + appType: string, + serviceAppName: string, +): boolean => { + if (appType === "application") return appName === serviceAppName; + if (appType === "stack") return appName.startsWith(`${serviceAppName}_`); + return appType === "docker-compose"; +}; + // Authorizes the host/server SSH terminal opened over a WebSocket. The local // host terminal is a root shell on the control-plane host, so it is restricted // to owner/admin. A remote server terminal needs server access plus diff --git a/apps/dokploy/server/wss/docker-stats.ts b/apps/dokploy/server/wss/docker-stats.ts index 5a0412777..6e9f7265b 100644 --- a/apps/dokploy/server/wss/docker-stats.ts +++ b/apps/dokploy/server/wss/docker-stats.ts @@ -10,7 +10,11 @@ import { } from "@dokploy/server"; import { quote } from "shell-quote"; import { WebSocketServer } from "ws"; -import { canAccessDockerOverWss } from "./authorize"; +import { + canAccessDockerOverWss, + findServiceAppName, + isServiceContainerName, +} from "./authorize"; type AppType = "application" | "stack" | "docker-compose"; @@ -110,10 +114,25 @@ export const setupDockerStatsMonitoringSocketServer = ( ws.close(4003, "Not authorized"); return; } + + // Service access only covers that service's containers, so pin the + // request to them instead of trusting appName. + const serviceAppName = serviceId + ? await findServiceAppName(serviceId) + : null; + if ( + serviceId && + (!serviceAppName || + !isServiceContainerName(appName, appType, serviceAppName)) + ) { + ws.close(4003, "Not authorized"); + return; + } + const intervalId = setInterval(async () => { try { // Special case: when monitoring "dokploy", get host system stats instead of container stats - if (appName === "dokploy") { + if (appName === "dokploy" && !serviceId) { const stat = await getHostSystemStats(); await recordAdvancedStats(stat, appName); @@ -133,10 +152,18 @@ export const setupDockerStatsMonitoringSocketServer = ( label: [`com.docker.swarm.service.name=${appName}`], }), ...(appType === "stack" && { - label: [`com.docker.swarm.task.name=${appName}`], + label: [ + `com.docker.swarm.task.name=${appName}`, + ...(serviceAppName + ? [`com.docker.stack.namespace=${serviceAppName}`] + : []), + ], }), ...(appType === "docker-compose" && { name: [appName], + ...(serviceAppName && { + label: [`com.docker.compose.project=${serviceAppName}`], + }), }), };