From 9f6e82abf62ec1cdeb6d0adf7eb8f2fa2fbb076a Mon Sep 17 00:00:00 2001 From: Gourav Kumar Dhiman Date: Thu, 22 Oct 2026 19:10:00 +0530 Subject: [PATCH] fix(monitoring): let members with service access see container monitoring (#5716) * fix(monitoring): let members with service access see container monitoring The Monitoring tab opened /listen-docker-stats-monitoring without a serviceId, so the socket required the docker permission and closed with "Not authorized" for members granted the service. Pass the serviceId from every service page, as the Logs tab does. With a serviceId, the socket now also checks that the requested container belongs to that service (app name for applications and databases, project or namespace label for compose and stacks), and never serves host stats. Closes #5674 Co-Authored-By: Claude Opus 5.5 * fix(monitoring): reject unknown appType on the service-scoped stats path appType comes from the URL, so an unexpected value skipped every ownership filter and returned the first running container's stats. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- apps/dokploy/__test__/wss/authorize.test.ts | 39 ++++++++++++ .../show-free-compose-monitoring.tsx | 3 + .../show-free-container-monitoring.tsx | 10 ++- .../services/application/[applicationId].tsx | 1 + .../services/compose/[composeId].tsx | 1 + .../services/libsql/[libsqlId].tsx | 1 + .../services/mariadb/[mariadbId].tsx | 1 + .../services/mongo/[mongoId].tsx | 1 + .../services/mysql/[mysqlId].tsx | 1 + .../services/postgres/[postgresId].tsx | 1 + .../services/redis/[redisId].tsx | 1 + apps/dokploy/server/wss/authorize.ts | 61 +++++++++++++++++++ apps/dokploy/server/wss/docker-stats.ts | 33 +++++++++- 13 files changed, 149 insertions(+), 5 deletions(-) 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}`], + }), }), };