mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-23 17:05:26 +00:00
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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
ad222962b8
commit
9f6e82abf6
@@ -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" });
|
||||
|
||||
+3
@@ -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 = ({
|
||||
<ContainerFreeMonitoring
|
||||
appName={containerAppName || ""}
|
||||
appType={appType}
|
||||
serviceId={serviceId}
|
||||
/>
|
||||
</CardContent>
|
||||
</>
|
||||
|
||||
+8
-2
@@ -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 (
|
||||
<div className="rounded-xl bg-background flex flex-col gap-4">
|
||||
|
||||
+1
@@ -345,6 +345,7 @@ const Service = (
|
||||
<div>
|
||||
<ContainerFreeMonitoring
|
||||
appName={data?.appName || ""}
|
||||
serviceId={data?.applicationId}
|
||||
/>
|
||||
</div>
|
||||
{/* )} */}
|
||||
|
||||
+1
@@ -381,6 +381,7 @@ const Service = (
|
||||
serverId={data?.serverId || ""}
|
||||
appName={data?.appName || ""}
|
||||
appType={data?.composeType || "docker-compose"}
|
||||
serviceId={data?.composeId}
|
||||
/>
|
||||
{/* </div> */}
|
||||
{/* )} */}
|
||||
|
||||
+1
@@ -268,6 +268,7 @@ const Libsql = (
|
||||
<div> */}
|
||||
<ContainerFreeMonitoring
|
||||
appName={data?.appName || ""}
|
||||
serviceId={data?.libsqlId}
|
||||
/>
|
||||
{/* </div> */}
|
||||
{/* )} */}
|
||||
|
||||
+1
@@ -296,6 +296,7 @@ const Mariadb = (
|
||||
<div> */}
|
||||
<ContainerFreeMonitoring
|
||||
appName={data?.appName || ""}
|
||||
serviceId={data?.mariadbId}
|
||||
/>
|
||||
{/* </div> */}
|
||||
{/* )} */}
|
||||
|
||||
+1
@@ -296,6 +296,7 @@ const Mongo = (
|
||||
<div> */}
|
||||
<ContainerFreeMonitoring
|
||||
appName={data?.appName || ""}
|
||||
serviceId={data?.mongoId}
|
||||
/>
|
||||
{/* </div> */}
|
||||
{/* )} */}
|
||||
|
||||
+1
@@ -275,6 +275,7 @@ const MySql = (
|
||||
<>
|
||||
<ContainerFreeMonitoring
|
||||
appName={data?.appName || ""}
|
||||
serviceId={data?.mysqlId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
+1
@@ -283,6 +283,7 @@ const Postgresql = (
|
||||
<>
|
||||
<ContainerFreeMonitoring
|
||||
appName={data?.appName || ""}
|
||||
serviceId={data?.postgresId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
+1
@@ -294,6 +294,7 @@ const Redis = (
|
||||
<div> */}
|
||||
<ContainerFreeMonitoring
|
||||
appName={data?.appName || ""}
|
||||
serviceId={data?.redisId}
|
||||
/>
|
||||
{/* </div> */}
|
||||
{/* )} */}
|
||||
|
||||
@@ -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<string | null> => {
|
||||
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 "<stack>_<service>". 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
|
||||
|
||||
@@ -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}`],
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user