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}`],
+ }),
}),
};