mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-16 13:45:28 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53464e9096 | |||
| 136a09d016 | |||
| c867a3c4c9 | |||
| 471e91caeb | |||
| 16c22c74fd | |||
| 37df4e484e | |||
| 5ff8d05ff7 | |||
| a9871f3a8d | |||
| 0049421095 | |||
| 3d67665c33 | |||
| 2bfcac58b0 | |||
| a7d8378f52 | |||
| bdefc02e77 | |||
| 293e19703a | |||
| cd9edb290b | |||
| b5473e63fd | |||
| e0006758c0 | |||
| d9fe046396 | |||
| 94f3815eb4 | |||
| 1f1acacf58 |
@@ -42,6 +42,7 @@ const ENTERPRISE_RESOURCES = [
|
||||
"auditLog",
|
||||
"vaultProvider",
|
||||
"dnsProvider",
|
||||
"ai",
|
||||
];
|
||||
|
||||
describe("enterpriseOnlyResources set", () => {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// apiReadTraefikConfig validates against the real local paths(), which outside
|
||||
// production live under the working directory.
|
||||
const traefikPath = vi.hoisted(() => `${process.cwd()}/.docker/traefik`);
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findServerById: vi.fn(),
|
||||
readDirectory: vi.fn(),
|
||||
readConfigInPath: vi.fn(),
|
||||
writeTraefikConfigInPath: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/permission", () => ({
|
||||
checkPermission: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server", () => ({
|
||||
IS_CLOUD: false,
|
||||
paths: () => ({ MAIN_TRAEFIK_PATH: traefikPath }),
|
||||
...mocks,
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/lib/auth", () => ({
|
||||
validateRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/server/api/utils/audit", () => ({
|
||||
audit: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/server/queues/queueSetup", () => ({
|
||||
cleanAllDeploymentQueue: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/server/utils/backup", () => ({
|
||||
removeJob: vi.fn(),
|
||||
schedule: vi.fn(),
|
||||
}));
|
||||
|
||||
const { settingsRouter } = await import("@/server/api/routers/settings");
|
||||
|
||||
const caller = settingsRouter.createCaller({
|
||||
session: { activeOrganizationId: "org-a" },
|
||||
user: { id: "user-1", email: "user@example.com", role: "member" },
|
||||
} as Parameters<typeof settingsRouter.createCaller>[0]);
|
||||
|
||||
const filePath = path.join(traefikPath, "dynamic/app.yml");
|
||||
|
||||
describe("traefik file endpoints scope remote servers to the active organization", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.findServerById.mockImplementation(async (serverId: string) => ({
|
||||
serverId,
|
||||
organizationId: serverId === "server-a" ? "org-a" : "org-b",
|
||||
}));
|
||||
mocks.readDirectory.mockResolvedValue([]);
|
||||
mocks.readConfigInPath.mockResolvedValue("http: {}");
|
||||
});
|
||||
|
||||
it("rejects listing a server from another organization", async () => {
|
||||
await expect(
|
||||
caller.readDirectories({ serverId: "server-b" }),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
expect(mocks.readDirectory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects writing to a server from another organization", async () => {
|
||||
await expect(
|
||||
caller.updateTraefikFile({
|
||||
path: filePath,
|
||||
traefikConfig: "http: {}",
|
||||
serverId: "server-b",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
expect(mocks.writeTraefikConfigInPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects reading from a server from another organization", async () => {
|
||||
await expect(
|
||||
caller.readTraefikFile({ path: filePath, serverId: "server-b" }),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
expect(mocks.readConfigInPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows servers of the active organization", async () => {
|
||||
await caller.readDirectories({ serverId: "server-a" });
|
||||
await caller.readTraefikFile({ path: filePath, serverId: "server-a" });
|
||||
await caller.updateTraefikFile({
|
||||
path: filePath,
|
||||
traefikConfig: "http: {}",
|
||||
serverId: "server-a",
|
||||
});
|
||||
|
||||
expect(mocks.readDirectory).toHaveBeenCalledWith(traefikPath, "server-a");
|
||||
expect(mocks.readConfigInPath).toHaveBeenCalledWith(filePath, "server-a");
|
||||
expect(mocks.writeTraefikConfigInPath).toHaveBeenCalledWith(
|
||||
filePath,
|
||||
"http: {}",
|
||||
"server-a",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not look up a server when none is given", async () => {
|
||||
await caller.readDirectories({});
|
||||
await caller.updateTraefikFile({
|
||||
path: filePath,
|
||||
traefikConfig: "http: {}",
|
||||
});
|
||||
|
||||
expect(mocks.findServerById).not.toHaveBeenCalled();
|
||||
expect(mocks.writeTraefikConfigInPath).toHaveBeenCalledWith(
|
||||
filePath,
|
||||
"http: {}",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@ describe("pipeBetweenServers", () => {
|
||||
target: { serverId: null, command: "sleep 0.3; cat > /dev/null" },
|
||||
});
|
||||
expect(bytes).toBe(11);
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it("pipes data larger than the pipe buffer unchanged", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pipe-"));
|
||||
@@ -47,7 +47,7 @@ describe("pipeBetweenServers", () => {
|
||||
target: { serverId: null, command: "echo boom >&2; exit 3" },
|
||||
}),
|
||||
).rejects.toThrow("target exited with code 3: boom");
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it("reports a source failure", async () => {
|
||||
await expect(
|
||||
@@ -56,5 +56,5 @@ describe("pipeBetweenServers", () => {
|
||||
target: { serverId: null, command: "cat > /dev/null" },
|
||||
}),
|
||||
).rejects.toThrow("source exited with code 2");
|
||||
});
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
@@ -435,7 +435,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
{viewMode === "detailed" &&
|
||||
template?.tags?.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-1.5">
|
||||
{template?.tags?.map((tag) => (
|
||||
{template.tags.slice(0, 6).map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="green"
|
||||
@@ -444,6 +444,15 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{template.tags.length > 6 && (
|
||||
<Badge
|
||||
variant="green"
|
||||
className="text-[10px] px-2 py-0"
|
||||
title={template.tags.slice(6).join(", ")}
|
||||
>
|
||||
+{template.tags.length - 6}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,9 @@ import { HandleAiProviders } from "./handle-ai-providers";
|
||||
export const AiForm = () => {
|
||||
const { data: aiConfigs, refetch, isPending } = api.ai.getAll.useQuery();
|
||||
const { mutateAsync, isPending: isRemoving } = api.ai.delete.useMutation();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const { data: currentUser } = api.user.get.useQuery();
|
||||
|
||||
const isOrgAdmin =
|
||||
currentUser?.role === "owner" || currentUser?.role === "admin";
|
||||
|
||||
@@ -36,7 +38,9 @@ export const AiForm = () => {
|
||||
</div>
|
||||
<div className="flex flex-row gap-2">
|
||||
{isOrgAdmin && <HandleAiProviders />}
|
||||
{aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
|
||||
{permissions?.ai.create && aiConfigs && aiConfigs?.length > 0 && (
|
||||
<HandleAi />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
@@ -53,7 +57,7 @@ export const AiForm = () => {
|
||||
<span className="text-base text-muted-foreground text-center">
|
||||
You don't have any AI configurations
|
||||
</span>
|
||||
<HandleAi />
|
||||
{permissions?.ai.create && <HandleAi />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 rounded-lg min-h-[25vh]">
|
||||
@@ -70,33 +74,37 @@ export const AiForm = () => {
|
||||
<CardDescription>{config.model}</CardDescription>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<HandleAi aiId={config.aiId} />
|
||||
<DialogAction
|
||||
title="Delete AI"
|
||||
description="Are you sure you want to delete this AI?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
aiId: config.aiId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("AI deleted successfully");
|
||||
refetch();
|
||||
{permissions?.ai.update && (
|
||||
<HandleAi aiId={config.aiId} />
|
||||
)}
|
||||
{permissions?.ai.delete && (
|
||||
<DialogAction
|
||||
title="Delete AI"
|
||||
description="Are you sure you want to delete this AI?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
aiId: config.aiId,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting AI");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 "
|
||||
isLoading={isRemoving}
|
||||
.then(() => {
|
||||
toast.success("AI deleted successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting AI");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 "
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -148,6 +148,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
{
|
||||
apiUrl: apiUrl ?? "",
|
||||
apiKey: apiKey ?? "",
|
||||
...(aiId ? { aiId } : {}),
|
||||
},
|
||||
{
|
||||
enabled: !!apiUrl && (isLocalOllama || !!apiKey),
|
||||
@@ -482,6 +483,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
apiUrl={apiUrl}
|
||||
apiKey={apiKey}
|
||||
model={form.watch("model")}
|
||||
aiId={aiId}
|
||||
/>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
{aiId ? "Update" : "Create"}
|
||||
@@ -498,10 +500,12 @@ function TestConnectionButton({
|
||||
apiUrl,
|
||||
apiKey,
|
||||
model,
|
||||
aiId,
|
||||
}: {
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
aiId?: string;
|
||||
}) {
|
||||
const { mutate, isPending } = api.ai.testConnection.useMutation({
|
||||
onSuccess: () => {
|
||||
@@ -521,7 +525,9 @@ function TestConnectionButton({
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isDisabled || isPending}
|
||||
onClick={() => mutate({ apiUrl, apiKey, model })}
|
||||
onClick={() =>
|
||||
mutate({ apiUrl, apiKey, model, ...(aiId ? { aiId } : {}) })
|
||||
}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
|
||||
@@ -351,7 +351,13 @@ const MENU: Menu = {
|
||||
icon: BotIcon,
|
||||
url: "/dashboard/settings/ai",
|
||||
isSingle: true,
|
||||
isEnabled: ({ permissions }) => !!permissions?.organization.update,
|
||||
isEnabled: ({ permissions }) =>
|
||||
!!(
|
||||
permissions?.ai.read ||
|
||||
permissions?.ai.create ||
|
||||
permissions?.ai.update ||
|
||||
permissions?.ai.delete
|
||||
),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
|
||||
@@ -185,6 +185,11 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
|
||||
description:
|
||||
"Manage the providers (Grafana Loki, Prometheus, Datadog, New Relic, etc.) that container logs and metrics are shipped to",
|
||||
},
|
||||
ai: {
|
||||
label: "AI Settings",
|
||||
description:
|
||||
"Manage AI providers, models, and use AI features (log analysis, suggestions)",
|
||||
},
|
||||
};
|
||||
|
||||
/** Descriptions for each action within a resource */
|
||||
@@ -495,6 +500,25 @@ const ACTION_META: Record<
|
||||
description: "Remove logs and metrics providers",
|
||||
},
|
||||
},
|
||||
ai: {
|
||||
read: {
|
||||
label: "Read",
|
||||
description:
|
||||
"View AI providers and use AI features (log analysis, suggestions)",
|
||||
},
|
||||
create: {
|
||||
label: "Create",
|
||||
description: "Add new AI provider configurations",
|
||||
},
|
||||
update: {
|
||||
label: "Update",
|
||||
description: "Edit AI provider settings and custom providers",
|
||||
},
|
||||
delete: {
|
||||
label: "Delete",
|
||||
description: "Remove AI provider configurations",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Resources that should be hidden from the custom role editor (better-auth internals) */
|
||||
@@ -606,6 +630,7 @@ const ROLE_PRESETS: {
|
||||
logs: ["read"],
|
||||
monitoring: ["read"],
|
||||
auditLog: ["read"],
|
||||
ai: ["read", "create", "update", "delete"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
getAiSettingById,
|
||||
getAiSettingsByOrganizationId,
|
||||
getCustomAiProviders,
|
||||
maskAiApiKey,
|
||||
mergeAiApiKey,
|
||||
resolveAiApiKey,
|
||||
saveAiSettings,
|
||||
saveCustomAiProviders,
|
||||
suggestVariants,
|
||||
@@ -23,6 +26,7 @@ import { createComposeByTemplate } from "@dokploy/server/services/compose";
|
||||
import {
|
||||
addNewService,
|
||||
checkServiceAccess,
|
||||
hasPermission,
|
||||
} from "@dokploy/server/services/permission";
|
||||
import { findProjectById } from "@dokploy/server/services/project";
|
||||
import {
|
||||
@@ -38,23 +42,48 @@ import { slugify } from "@/lib/slug";
|
||||
import {
|
||||
adminProcedure,
|
||||
createTRPCRouter,
|
||||
protectedProcedure,
|
||||
withAnyPermission,
|
||||
withPermission,
|
||||
} from "@/server/api/trpc";
|
||||
import { audit } from "@/server/api/utils/audit";
|
||||
import { generatePassword } from "@/templates/utils";
|
||||
|
||||
const resolveInputApiKey = (
|
||||
input: { apiKey: string; apiUrl: string; aiId?: string },
|
||||
ctx: { user: { id: string }; session: { activeOrganizationId: string } },
|
||||
) =>
|
||||
resolveAiApiKey({
|
||||
apiKey: input.apiKey,
|
||||
apiUrl: input.apiUrl,
|
||||
aiId: input.aiId,
|
||||
organizationId: ctx.session.activeOrganizationId,
|
||||
authorize: () => hasPermission(ctx, { ai: ["update"] }),
|
||||
});
|
||||
|
||||
export const aiRouter = createTRPCRouter({
|
||||
one: adminProcedure
|
||||
one: withAnyPermission("ai", ["read", "update"])
|
||||
.input(z.object({ aiId: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
return await getAiSettingById(input.aiId);
|
||||
.query(async ({ input, ctx }) => {
|
||||
const setting = await getAiSettingById(
|
||||
input.aiId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
return maskAiApiKey(setting);
|
||||
}),
|
||||
|
||||
getModels: protectedProcedure
|
||||
.input(z.object({ apiUrl: z.string().min(1), apiKey: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
getModels: withAnyPermission("ai", ["read", "create", "update"])
|
||||
.input(
|
||||
z.object({
|
||||
apiUrl: z.string().min(1),
|
||||
apiKey: z.string(),
|
||||
aiId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
try {
|
||||
const apiKey = await resolveInputApiKey(input, ctx);
|
||||
const providerName = getProviderName(input.apiUrl);
|
||||
const headers = getProviderHeaders(input.apiUrl, input.apiKey);
|
||||
const headers = getProviderHeaders(input.apiUrl, apiKey);
|
||||
let response = null;
|
||||
switch (providerName) {
|
||||
case "ollama":
|
||||
@@ -62,7 +91,7 @@ export const aiRouter = createTRPCRouter({
|
||||
break;
|
||||
case "gemini":
|
||||
response = await fetch(
|
||||
`${input.apiUrl}/models?key=${encodeURIComponent(input.apiKey)}`,
|
||||
`${input.apiUrl}/models?key=${encodeURIComponent(apiKey)}`,
|
||||
{ headers: {} },
|
||||
);
|
||||
break;
|
||||
@@ -125,7 +154,7 @@ export const aiRouter = createTRPCRouter({
|
||||
},
|
||||
] as Model[];
|
||||
default:
|
||||
if (!input.apiKey)
|
||||
if (!apiKey)
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "API key must contain at least 1 character(s)",
|
||||
@@ -177,33 +206,76 @@ export const aiRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
}),
|
||||
create: adminProcedure.input(apiCreateAi).mutation(async ({ ctx, input }) => {
|
||||
return await saveAiSettings(ctx.session.activeOrganizationId, input);
|
||||
}),
|
||||
|
||||
update: adminProcedure.input(apiUpdateAi).mutation(async ({ ctx, input }) => {
|
||||
return await saveAiSettings(ctx.session.activeOrganizationId, input);
|
||||
}),
|
||||
|
||||
getAll: adminProcedure.query(async ({ ctx }) => {
|
||||
return await getAiSettingsByOrganizationId(
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
}),
|
||||
|
||||
get: adminProcedure
|
||||
.input(z.object({ aiId: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
return await getAiSettingById(input.aiId);
|
||||
create: withPermission("ai", "create")
|
||||
.input(apiCreateAi)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const result = await saveAiSettings(
|
||||
ctx.session.activeOrganizationId,
|
||||
input,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "create",
|
||||
resourceType: "ai",
|
||||
resourceName: input.name,
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
|
||||
delete: adminProcedure
|
||||
.input(z.object({ aiId: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
return await deleteAiSettings(input.aiId);
|
||||
update: withPermission("ai", "update")
|
||||
.input(apiUpdateAi)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await getAiSettingById(
|
||||
input.aiId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
if (input.apiKey) {
|
||||
input.apiKey = mergeAiApiKey(input.apiKey, existing.apiKey);
|
||||
}
|
||||
const result = await saveAiSettings(
|
||||
ctx.session.activeOrganizationId,
|
||||
input,
|
||||
false,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "ai",
|
||||
resourceId: input.aiId,
|
||||
resourceName: input.name,
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
|
||||
getCustomProviders: protectedProcedure.query(async ({ ctx }) => {
|
||||
getAll: withAnyPermission("ai", ["read", "create", "update", "delete"]).query(
|
||||
async ({ ctx }) => {
|
||||
const settings = await getAiSettingsByOrganizationId(
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
return settings.map(({ apiKey, ...rest }) => rest);
|
||||
},
|
||||
),
|
||||
|
||||
delete: withPermission("ai", "delete")
|
||||
.input(z.object({ aiId: z.string() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const setting = await getAiSettingById(
|
||||
input.aiId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
await deleteAiSettings(input.aiId, ctx.session.activeOrganizationId);
|
||||
await audit(ctx, {
|
||||
action: "delete",
|
||||
resourceType: "ai",
|
||||
resourceId: input.aiId,
|
||||
resourceName: setting.name,
|
||||
});
|
||||
return true;
|
||||
}),
|
||||
|
||||
getCustomProviders: withAnyPermission("ai", [
|
||||
"read",
|
||||
"create",
|
||||
"update",
|
||||
]).query(async ({ ctx }) => {
|
||||
return await getCustomAiProviders(ctx.session.activeOrganizationId);
|
||||
}),
|
||||
|
||||
@@ -216,7 +288,7 @@ export const aiRouter = createTRPCRouter({
|
||||
);
|
||||
}),
|
||||
|
||||
getEnabledProviders: protectedProcedure.query(async ({ ctx }) => {
|
||||
getEnabledProviders: withPermission("ai", "read").query(async ({ ctx }) => {
|
||||
const settings = await getAiSettingsByOrganizationId(
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
@@ -225,7 +297,7 @@ export const aiRouter = createTRPCRouter({
|
||||
.map((s) => ({ aiId: s.aiId, name: s.name, model: s.model }));
|
||||
}),
|
||||
|
||||
analyzeLogs: protectedProcedure
|
||||
analyzeLogs: withPermission("ai", "read")
|
||||
.input(
|
||||
z.object({
|
||||
aiId: z.string().min(1),
|
||||
@@ -235,7 +307,10 @@ export const aiRouter = createTRPCRouter({
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const aiSettings = await getAiSettingById(input.aiId);
|
||||
const aiSettings = await getAiSettingById(
|
||||
input.aiId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
if (!aiSettings?.isEnabled) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
@@ -243,13 +318,6 @@ export const aiRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (aiSettings.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Access denied",
|
||||
});
|
||||
}
|
||||
|
||||
const provider = selectAIProvider(aiSettings);
|
||||
const model = provider(aiSettings.model);
|
||||
|
||||
@@ -283,19 +351,21 @@ ${input.logs}`,
|
||||
}
|
||||
}),
|
||||
|
||||
testConnection: protectedProcedure
|
||||
testConnection: withAnyPermission("ai", ["create", "update"])
|
||||
.input(
|
||||
z.object({
|
||||
apiUrl: z.string().min(1),
|
||||
apiKey: z.string(),
|
||||
model: z.string().min(1),
|
||||
aiId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const apiKey = await resolveInputApiKey(input, ctx);
|
||||
const provider = selectAIProvider({
|
||||
apiUrl: input.apiUrl,
|
||||
apiKey: input.apiKey,
|
||||
apiKey,
|
||||
});
|
||||
const model = provider(input.model);
|
||||
const result = await generateText({
|
||||
@@ -317,7 +387,7 @@ ${input.logs}`,
|
||||
}
|
||||
}),
|
||||
|
||||
suggest: protectedProcedure
|
||||
suggest: withPermission("ai", "read")
|
||||
.input(
|
||||
z.object({
|
||||
aiId: z.string(),
|
||||
@@ -338,7 +408,7 @@ ${input.logs}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
deploy: protectedProcedure
|
||||
deploy: withPermission("ai", "read")
|
||||
.input(deploySuggestionSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const environment = await findEnvironmentById(input.environmentId);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
CLEANUP_CRON_JOB,
|
||||
checkGPUStatus,
|
||||
@@ -81,6 +82,32 @@ import {
|
||||
publicProcedure,
|
||||
} from "../trpc";
|
||||
|
||||
/**
|
||||
* Confines a caller-supplied Traefik file path to the Traefik configuration
|
||||
* directory (MAIN_TRAEFIK_PATH). The read/write Traefik file endpoints only
|
||||
* expose files listed under this directory, but the raw path was passed
|
||||
* straight to the filesystem helpers, letting a user with the `traefikFiles`
|
||||
* permission read or overwrite arbitrary files on the host (for example
|
||||
* `/etc/dokploy/.env` or `~/.ssh/authorized_keys`) via an absolute path or
|
||||
* `../` traversal. Reject anything that escapes the directory.
|
||||
*/
|
||||
const resolveTraefikFilePath = (
|
||||
filePath: string,
|
||||
serverId?: string | null,
|
||||
): string => {
|
||||
const { MAIN_TRAEFIK_PATH } = paths(!!serverId);
|
||||
const base = path.resolve(MAIN_TRAEFIK_PATH);
|
||||
const resolved = path.resolve(base, filePath);
|
||||
if (resolved !== base && !resolved.startsWith(`${base}${path.sep}`)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"Invalid path: file must be inside the Traefik configuration directory",
|
||||
});
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
|
||||
export const settingsRouter = createTRPCRouter({
|
||||
getWebServerSettings: protectedProcedure.query(async () => {
|
||||
if (IS_CLOUD) {
|
||||
@@ -586,6 +613,12 @@ export const settingsRouter = createTRPCRouter({
|
||||
.query(async ({ ctx, input }) => {
|
||||
try {
|
||||
await checkPermission(ctx, { traefikFiles: ["read"] });
|
||||
if (input?.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
}
|
||||
const { MAIN_TRAEFIK_PATH } = paths(!!input?.serverId);
|
||||
const result = await readDirectory(MAIN_TRAEFIK_PATH, input?.serverId);
|
||||
return result || [];
|
||||
@@ -598,8 +631,15 @@ export const settingsRouter = createTRPCRouter({
|
||||
.input(apiModifyTraefikConfig)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await checkPermission(ctx, { traefikFiles: ["write"] });
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
}
|
||||
const safePath = resolveTraefikFilePath(input.path, input?.serverId);
|
||||
await writeTraefikConfigInPath(
|
||||
input.path,
|
||||
safePath,
|
||||
input.traefikConfig,
|
||||
input?.serverId,
|
||||
);
|
||||
@@ -624,7 +664,8 @@ export const settingsRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
|
||||
return readConfigInPath(input.path, input.serverId);
|
||||
const safePath = resolveTraefikFilePath(input.path, input.serverId);
|
||||
return readConfigInPath(safePath, input.serverId);
|
||||
}),
|
||||
getIp: protectedProcedure.query(async () => {
|
||||
if (IS_CLOUD) {
|
||||
|
||||
@@ -12,7 +12,10 @@ import { db } from "@dokploy/server/db";
|
||||
import { hasValidLicense } from "@dokploy/server/index";
|
||||
import type { statements } from "@dokploy/server/lib/access-control";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { checkPermission } from "@dokploy/server/services/permission";
|
||||
import {
|
||||
checkPermission,
|
||||
resolvePermissions,
|
||||
} from "@dokploy/server/services/permission";
|
||||
import type { OpenApiMeta } from "@dokploy/trpc-openapi";
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
|
||||
@@ -82,14 +85,14 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => {
|
||||
return createInnerTRPCContext({
|
||||
req,
|
||||
res,
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
session: session
|
||||
? {
|
||||
...session,
|
||||
activeOrganizationId: session.activeOrganizationId || "",
|
||||
}
|
||||
: null,
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
user: user
|
||||
? {
|
||||
...user,
|
||||
@@ -263,3 +266,25 @@ export const withPermission = <R extends Resource>(
|
||||
await checkPermission(ctx, { [resource]: [action] } as any);
|
||||
return next();
|
||||
});
|
||||
|
||||
export const withAnyPermission = <R extends Resource>(
|
||||
resource: R,
|
||||
actions: ActionOf<R>[],
|
||||
) =>
|
||||
protectedProcedure.use(async ({ ctx, next }) => {
|
||||
const permissions = await resolvePermissions(ctx);
|
||||
const resourcePerms = permissions[resource];
|
||||
|
||||
if (resourcePerms) {
|
||||
for (const action of actions) {
|
||||
if ((resourcePerms as Record<string, boolean>)[action as string]) {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Permission denied",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,4 +95,5 @@ export type AuditResourceType =
|
||||
| "network"
|
||||
| "vaultProvider"
|
||||
| "dnsProvider"
|
||||
| "telemetryProvider";
|
||||
| "telemetryProvider"
|
||||
| "ai";
|
||||
|
||||
+8
-8
@@ -476,8 +476,8 @@ packages:
|
||||
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
|
||||
engines: {node: ^4.5.0 || >= 5.9}
|
||||
|
||||
brace-expansion@5.0.9:
|
||||
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
|
||||
brace-expansion@5.0.12:
|
||||
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
chokidar@4.0.3:
|
||||
@@ -773,8 +773,8 @@ packages:
|
||||
resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==}
|
||||
engines: {node: '>=10.2.0'}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
source-map-js@1.2.2:
|
||||
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
strip-bom@3.0.0:
|
||||
@@ -1166,7 +1166,7 @@ snapshots:
|
||||
|
||||
base64id@2.0.0: {}
|
||||
|
||||
brace-expansion@5.0.9:
|
||||
brace-expansion@5.0.12:
|
||||
dependencies:
|
||||
balanced-match: 4.0.4
|
||||
|
||||
@@ -1200,7 +1200,7 @@ snapshots:
|
||||
css-tree@3.2.1:
|
||||
dependencies:
|
||||
mdn-data: 2.27.1
|
||||
source-map-js: 1.2.1
|
||||
source-map-js: 1.2.2
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
@@ -1361,7 +1361,7 @@ snapshots:
|
||||
|
||||
minimatch@10.2.6:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.9
|
||||
brace-expansion: 5.0.12
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
@@ -1490,7 +1490,7 @@ snapshots:
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
source-map-js@1.2.2: {}
|
||||
|
||||
strip-bom@3.0.0: {}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { createAccessControl } from "better-auth/plugins/access";
|
||||
* The rest are Dokploy-specific resources.
|
||||
*
|
||||
* Enterprise-only resources (only assignable via custom roles):
|
||||
* deployment, envVars, server, registry, certificate, backup, domain, logs, monitoring
|
||||
* deployment, envVars, server, registry, certificate, backup, domain, logs, monitoring, ai
|
||||
*/
|
||||
export const statements = {
|
||||
// better-auth organization plugin defaults
|
||||
@@ -51,6 +51,7 @@ export const statements = {
|
||||
auditLog: ["read"],
|
||||
vaultProvider: ["read", "create", "update", "delete"],
|
||||
dnsProvider: ["read", "create", "update", "delete"],
|
||||
ai: ["read", "create", "update", "delete"],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -79,6 +80,7 @@ export const enterpriseOnlyResources = new Set<string>([
|
||||
"auditLog",
|
||||
"vaultProvider",
|
||||
"dnsProvider",
|
||||
"ai",
|
||||
]);
|
||||
|
||||
export const ac = createAccessControl(statements);
|
||||
@@ -121,6 +123,7 @@ export const ownerRole = ac.newRole({
|
||||
vaultProvider: ["read", "create", "update", "delete"],
|
||||
dnsProvider: ["read", "create", "update", "delete"],
|
||||
telemetryProvider: ["read", "create", "delete"],
|
||||
ai: ["read", "create", "update", "delete"],
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -161,6 +164,7 @@ export const adminRole = ac.newRole({
|
||||
vaultProvider: ["read", "create", "update", "delete"],
|
||||
dnsProvider: ["read", "create", "update", "delete"],
|
||||
telemetryProvider: ["read", "create", "delete"],
|
||||
ai: ["read", "create", "update", "delete"],
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -207,4 +211,5 @@ export const memberRole = ac.newRole({
|
||||
// Members need provider/secret names for env editor autocomplete; values are never exposed
|
||||
vaultProvider: ["read"],
|
||||
dnsProvider: [],
|
||||
ai: [],
|
||||
});
|
||||
|
||||
@@ -4,12 +4,22 @@ 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";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { IS_CLOUD } from "../constants";
|
||||
import { findServerById } from "./server";
|
||||
import { getWebServerSettings } from "./web-server-settings";
|
||||
|
||||
export const AI_SECRET_MASK = "********";
|
||||
|
||||
export const maskAiApiKey = <T extends { apiKey: string }>(settings: T): T => ({
|
||||
...settings,
|
||||
apiKey: AI_SECRET_MASK,
|
||||
});
|
||||
|
||||
export const mergeAiApiKey = (incoming: string, existing: string): string =>
|
||||
incoming === AI_SECRET_MASK ? existing : incoming;
|
||||
|
||||
interface SuggestionItem {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -36,9 +46,12 @@ export const getAiSettingsByOrganizationId = async (organizationId: string) => {
|
||||
return aiSettings;
|
||||
};
|
||||
|
||||
export const getAiSettingById = async (aiId: string) => {
|
||||
export const getAiSettingById = async (
|
||||
aiId: string,
|
||||
organizationId: string,
|
||||
) => {
|
||||
const aiSetting = await db.query.ai.findFirst({
|
||||
where: eq(ai.aiId, aiId),
|
||||
where: and(eq(ai.aiId, aiId), eq(ai.organizationId, organizationId)),
|
||||
});
|
||||
if (!aiSetting) {
|
||||
throw new TRPCError({
|
||||
@@ -95,11 +108,43 @@ export const saveCustomAiProviders = async (
|
||||
return providers;
|
||||
};
|
||||
|
||||
const normalizeApiUrl = (url: string) => url.trim().replace(/\/+$/, "");
|
||||
export const normalizeApiUrl = (url: string) => url.trim().replace(/\/+$/, "");
|
||||
|
||||
export const saveAiSettings = async (organizationId: string, settings: any) => {
|
||||
export const resolveAiApiKey = async (opts: {
|
||||
apiKey: string;
|
||||
apiUrl: string;
|
||||
aiId: string | undefined;
|
||||
organizationId: string;
|
||||
authorize: () => Promise<boolean>;
|
||||
}): Promise<string> => {
|
||||
if (opts.apiKey !== AI_SECRET_MASK || !opts.aiId) return opts.apiKey;
|
||||
if (!(await opts.authorize())) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Permission denied",
|
||||
});
|
||||
}
|
||||
const stored = await getAiSettingById(opts.aiId, opts.organizationId);
|
||||
if (normalizeApiUrl(stored.apiUrl) !== normalizeApiUrl(opts.apiUrl)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "API URL does not match the stored configuration",
|
||||
});
|
||||
}
|
||||
return stored.apiKey;
|
||||
};
|
||||
|
||||
export const saveAiSettings = async (
|
||||
organizationId: string,
|
||||
settings: any,
|
||||
checkExistence = true,
|
||||
) => {
|
||||
const aiId = settings.aiId;
|
||||
|
||||
if (aiId && checkExistence) {
|
||||
await getAiSettingById(aiId, organizationId);
|
||||
}
|
||||
|
||||
if (settings.apiUrl) {
|
||||
const customProviders = await getCustomAiProviders(organizationId);
|
||||
if (customProviders.length > 0) {
|
||||
@@ -132,8 +177,13 @@ export const saveAiSettings = async (organizationId: string, settings: any) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteAiSettings = async (aiId: string) => {
|
||||
return db.delete(ai).where(eq(ai.aiId, aiId));
|
||||
export const deleteAiSettings = async (
|
||||
aiId: string,
|
||||
organizationId: string,
|
||||
) => {
|
||||
return db
|
||||
.delete(ai)
|
||||
.where(and(eq(ai.aiId, aiId), eq(ai.organizationId, organizationId)));
|
||||
};
|
||||
|
||||
interface Props {
|
||||
@@ -144,14 +194,14 @@ interface Props {
|
||||
}
|
||||
|
||||
export const suggestVariants = async ({
|
||||
organizationId: _organizationId,
|
||||
organizationId,
|
||||
aiId,
|
||||
input,
|
||||
serverId,
|
||||
}: Props) => {
|
||||
try {
|
||||
const aiSettings = await getAiSettingById(aiId);
|
||||
if (!aiSettings || !aiSettings.isEnabled) {
|
||||
const aiSettings = await getAiSettingById(aiId, organizationId);
|
||||
if (!aiSettings?.isEnabled) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "AI features are not enabled for this configuration",
|
||||
@@ -209,7 +259,6 @@ export const suggestVariants = async ({
|
||||
|
||||
const result = await generateText({
|
||||
model,
|
||||
// @ts-ignore - Zod + AI SDK Output.object() causes excessively deep instantiation
|
||||
output: Output.object({ schema: fullSchema }),
|
||||
prompt: `
|
||||
Act as advanced DevOps engineer. Analyze the user's request and generate up to 3 deployment suggestions, each with a complete docker compose configuration.
|
||||
|
||||
@@ -106,6 +106,7 @@ export const pipeBetweenServers = async ({
|
||||
bytes += chunk.length;
|
||||
onProgress?.(bytes);
|
||||
});
|
||||
from.stdout.on("error", () => {});
|
||||
from.stdout.pipe(to.stdin);
|
||||
|
||||
const targetExit = to.exit.then((code) => {
|
||||
|
||||
Reference in New Issue
Block a user