mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
Merge pull request #547 from Dokploy/536-implement-custom-certificates-in-external-server
feat(certificates): create certificates in a remote server
This commit is contained in:
@@ -18,10 +18,25 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { AlertTriangle } from "lucide-react";
|
import { AlertTriangle, HelpCircle } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -35,6 +50,7 @@ const addCertificate = z.object({
|
|||||||
certificateData: z.string().min(1, "Certificate data is required"),
|
certificateData: z.string().min(1, "Certificate data is required"),
|
||||||
privateKey: z.string().min(1, "Private key is required"),
|
privateKey: z.string().min(1, "Private key is required"),
|
||||||
autoRenew: z.boolean().optional(),
|
autoRenew: z.boolean().optional(),
|
||||||
|
serverId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type AddCertificate = z.infer<typeof addCertificate>;
|
type AddCertificate = z.infer<typeof addCertificate>;
|
||||||
@@ -44,6 +60,7 @@ export const AddCertificate = () => {
|
|||||||
|
|
||||||
const { mutateAsync, isError, error, isLoading } =
|
const { mutateAsync, isError, error, isLoading } =
|
||||||
api.certificates.create.useMutation();
|
api.certificates.create.useMutation();
|
||||||
|
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||||
|
|
||||||
const form = useForm<AddCertificate>({
|
const form = useForm<AddCertificate>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -64,6 +81,7 @@ export const AddCertificate = () => {
|
|||||||
certificateData: data.certificateData,
|
certificateData: data.certificateData,
|
||||||
privateKey: data.privateKey,
|
privateKey: data.privateKey,
|
||||||
autoRenew: data.autoRenew,
|
autoRenew: data.autoRenew,
|
||||||
|
serverId: data.serverId,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Certificate Created");
|
toast.success("Certificate Created");
|
||||||
@@ -144,6 +162,47 @@ export const AddCertificate = () => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="serverId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
|
||||||
|
Select a Server (Optional)
|
||||||
|
<HelpCircle className="size-4 text-muted-foreground" />
|
||||||
|
</FormLabel>
|
||||||
|
</TooltipTrigger>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a Server" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{servers?.map((server) => (
|
||||||
|
<SelectItem
|
||||||
|
key={server.serverId}
|
||||||
|
value={server.serverId}
|
||||||
|
>
|
||||||
|
{server.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectLabel>Servers ({servers?.length})</SelectLabel>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<DialogFooter className="flex w-full flex-row !justify-between pt-3">
|
<DialogFooter className="flex w-full flex-row !justify-between pt-3">
|
||||||
|
|||||||
@@ -48,16 +48,13 @@ export const SettingsLayout = ({ children }: Props) => {
|
|||||||
icon: Database,
|
icon: Database,
|
||||||
href: "/dashboard/settings/destinations",
|
href: "/dashboard/settings/destinations",
|
||||||
},
|
},
|
||||||
...(!isCloud
|
|
||||||
? [
|
{
|
||||||
{
|
title: "Certificates",
|
||||||
title: "Certificates",
|
label: "",
|
||||||
label: "",
|
icon: ShieldCheck,
|
||||||
icon: ShieldCheck,
|
href: "/dashboard/settings/certificates",
|
||||||
href: "/dashboard/settings/certificates",
|
},
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{
|
{
|
||||||
title: "SSH Keys",
|
title: "SSH Keys",
|
||||||
label: "",
|
label: "",
|
||||||
|
|||||||
13
apps/dokploy/drizzle/0040_graceful_wolfsbane.sql
Normal file
13
apps/dokploy/drizzle/0040_graceful_wolfsbane.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
ALTER TABLE "certificate" ADD COLUMN "adminId" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "certificate" ADD COLUMN "serverId" text;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "certificate" ADD CONSTRAINT "certificate_adminId_admin_adminId_fk" FOREIGN KEY ("adminId") REFERENCES "public"."admin"("adminId") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "certificate" ADD CONSTRAINT "certificate_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
3909
apps/dokploy/drizzle/meta/0040_snapshot.json
Normal file
3909
apps/dokploy/drizzle/meta/0040_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -281,6 +281,13 @@
|
|||||||
"when": 1728021127765,
|
"when": 1728021127765,
|
||||||
"tag": "0039_many_tiger_shark",
|
"tag": "0039_many_tiger_shark",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 40,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1728780577084,
|
||||||
|
"tag": "0040_graceful_wolfsbane",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,30 +1,62 @@
|
|||||||
import { adminProcedure, createTRPCRouter } from "@/server/api/trpc";
|
import { adminProcedure, createTRPCRouter } from "@/server/api/trpc";
|
||||||
import { apiCreateCertificate, apiFindCertificate } from "@/server/db/schema";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
apiCreateCertificate,
|
||||||
|
apiFindCertificate,
|
||||||
|
certificates,
|
||||||
|
} from "@/server/db/schema";
|
||||||
|
|
||||||
|
import { db } from "@/server/db";
|
||||||
|
import {
|
||||||
|
IS_CLOUD,
|
||||||
createCertificate,
|
createCertificate,
|
||||||
findCertificateById,
|
findCertificateById,
|
||||||
findCertificates,
|
|
||||||
removeCertificateById,
|
removeCertificateById,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
export const certificateRouter = createTRPCRouter({
|
export const certificateRouter = createTRPCRouter({
|
||||||
create: adminProcedure
|
create: adminProcedure
|
||||||
.input(apiCreateCertificate)
|
.input(apiCreateCertificate)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
return await createCertificate(input);
|
if (IS_CLOUD && !input.serverId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "Please set a server to create a certificate",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return await createCertificate(input, ctx.user.adminId);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
one: adminProcedure.input(apiFindCertificate).query(async ({ input }) => {
|
one: adminProcedure
|
||||||
return await findCertificateById(input.certificateId);
|
.input(apiFindCertificate)
|
||||||
}),
|
.query(async ({ input, ctx }) => {
|
||||||
|
const certificates = await findCertificateById(input.certificateId);
|
||||||
|
if (IS_CLOUD && certificates.adminId !== ctx.user.adminId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not allowed to access this certificate",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return certificates;
|
||||||
|
}),
|
||||||
remove: adminProcedure
|
remove: adminProcedure
|
||||||
.input(apiFindCertificate)
|
.input(apiFindCertificate)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const certificates = await findCertificateById(input.certificateId);
|
||||||
|
if (IS_CLOUD && certificates.adminId !== ctx.user.adminId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not allowed to delete this certificate",
|
||||||
|
});
|
||||||
|
}
|
||||||
await removeCertificateById(input.certificateId);
|
await removeCertificateById(input.certificateId);
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
all: adminProcedure.query(async () => {
|
all: adminProcedure.query(async ({ ctx }) => {
|
||||||
return findCertificates();
|
return await db.query.certificates.findMany({
|
||||||
|
// TODO: Remove this line when the cloud version is ready
|
||||||
|
...(IS_CLOUD && { where: eq(certificates.adminId, ctx.user.adminId) }),
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createInsertSchema } from "drizzle-zod";
|
|||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { auth } from "./auth";
|
import { auth } from "./auth";
|
||||||
|
import { certificates } from "./certificate";
|
||||||
import { registry } from "./registry";
|
import { registry } from "./registry";
|
||||||
import { certificateType } from "./shared";
|
import { certificateType } from "./shared";
|
||||||
import { sshKeys } from "./ssh-key";
|
import { sshKeys } from "./ssh-key";
|
||||||
@@ -37,6 +38,7 @@ export const adminsRelations = relations(admins, ({ one, many }) => ({
|
|||||||
users: many(users),
|
users: many(users),
|
||||||
registry: many(registry),
|
registry: many(registry),
|
||||||
sshKeys: many(sshKeys),
|
sshKeys: many(sshKeys),
|
||||||
|
certificates: many(certificates),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const createSchema = createInsertSchema(admins, {
|
const createSchema = createInsertSchema(admins, {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import { relations } from "drizzle-orm";
|
||||||
import { boolean, pgTable, text } from "drizzle-orm/pg-core";
|
import { boolean, pgTable, text } from "drizzle-orm/pg-core";
|
||||||
import { createInsertSchema } from "drizzle-zod";
|
import { createInsertSchema } from "drizzle-zod";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { admins } from "./admin";
|
||||||
|
import { server } from "./server";
|
||||||
import { generateAppName } from "./utils";
|
import { generateAppName } from "./utils";
|
||||||
|
|
||||||
export const certificates = pgTable("certificate", {
|
export const certificates = pgTable("certificate", {
|
||||||
@@ -17,13 +20,34 @@ export const certificates = pgTable("certificate", {
|
|||||||
.$defaultFn(() => generateAppName("certificate"))
|
.$defaultFn(() => generateAppName("certificate"))
|
||||||
.unique(),
|
.unique(),
|
||||||
autoRenew: boolean("autoRenew"),
|
autoRenew: boolean("autoRenew"),
|
||||||
|
adminId: text("adminId").references(() => admins.adminId, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
serverId: text("serverId").references(() => server.serverId, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const certificatesRelations = relations(
|
||||||
|
certificates,
|
||||||
|
({ one, many }) => ({
|
||||||
|
server: one(server, {
|
||||||
|
fields: [certificates.serverId],
|
||||||
|
references: [server.serverId],
|
||||||
|
}),
|
||||||
|
admin: one(admins, {
|
||||||
|
fields: [certificates.adminId],
|
||||||
|
references: [admins.adminId],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export const apiCreateCertificate = createInsertSchema(certificates, {
|
export const apiCreateCertificate = createInsertSchema(certificates, {
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
certificateData: z.string().min(1),
|
certificateData: z.string().min(1),
|
||||||
privateKey: z.string().min(1),
|
privateKey: z.string().min(1),
|
||||||
autoRenew: z.boolean().optional(),
|
autoRenew: z.boolean().optional(),
|
||||||
|
serverId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiFindCertificate = z.object({
|
export const apiFindCertificate = z.object({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import { admins } from "./admin";
|
import { admins } from "./admin";
|
||||||
import { applications } from "./application";
|
import { applications } from "./application";
|
||||||
|
import { certificates } from "./certificate";
|
||||||
import { compose } from "./compose";
|
import { compose } from "./compose";
|
||||||
import { deployments } from "./deployment";
|
import { deployments } from "./deployment";
|
||||||
import { mariadb } from "./mariadb";
|
import { mariadb } from "./mariadb";
|
||||||
@@ -58,6 +59,7 @@ export const serverRelations = relations(server, ({ one, many }) => ({
|
|||||||
mongo: many(mongo),
|
mongo: many(mongo),
|
||||||
mysql: many(mysql),
|
mysql: many(mysql),
|
||||||
postgres: many(postgres),
|
postgres: many(postgres),
|
||||||
|
certificates: many(certificates),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const createSchema = createInsertSchema(server, {
|
const createSchema = createInsertSchema(server, {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { TRPCError } from "@trpc/server";
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { dump } from "js-yaml";
|
import { dump } from "js-yaml";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
|
import { encodeBase64 } from "../utils/docker/utils";
|
||||||
|
import { execAsyncRemote } from "../utils/process/execAsync";
|
||||||
|
|
||||||
export type Certificate = typeof certificates.$inferSelect;
|
export type Certificate = typeof certificates.$inferSelect;
|
||||||
|
|
||||||
@@ -28,11 +30,13 @@ export const findCertificateById = async (certificateId: string) => {
|
|||||||
|
|
||||||
export const createCertificate = async (
|
export const createCertificate = async (
|
||||||
certificateData: z.infer<typeof apiCreateCertificate>,
|
certificateData: z.infer<typeof apiCreateCertificate>,
|
||||||
|
adminId: string,
|
||||||
) => {
|
) => {
|
||||||
const certificate = await db
|
const certificate = await db
|
||||||
.insert(certificates)
|
.insert(certificates)
|
||||||
.values({
|
.values({
|
||||||
...certificateData,
|
...certificateData,
|
||||||
|
adminId: adminId,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -46,15 +50,21 @@ export const createCertificate = async (
|
|||||||
const cer = certificate[0];
|
const cer = certificate[0];
|
||||||
|
|
||||||
createCertificateFiles(cer);
|
createCertificateFiles(cer);
|
||||||
|
|
||||||
return cer;
|
return cer;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const removeCertificateById = async (certificateId: string) => {
|
export const removeCertificateById = async (certificateId: string) => {
|
||||||
const { CERTIFICATES_PATH } = paths();
|
|
||||||
const certificate = await findCertificateById(certificateId);
|
const certificate = await findCertificateById(certificateId);
|
||||||
|
const { CERTIFICATES_PATH } = paths(!!certificate.serverId);
|
||||||
const certDir = path.join(CERTIFICATES_PATH, certificate.certificatePath);
|
const certDir = path.join(CERTIFICATES_PATH, certificate.certificatePath);
|
||||||
|
|
||||||
await removeDirectoryIfExistsContent(certDir);
|
if (certificate.serverId) {
|
||||||
|
await execAsyncRemote(certificate.serverId, `rm -rf ${certDir}`);
|
||||||
|
} else {
|
||||||
|
await removeDirectoryIfExistsContent(certDir);
|
||||||
|
}
|
||||||
|
|
||||||
const result = await db
|
const result = await db
|
||||||
.delete(certificates)
|
.delete(certificates)
|
||||||
.where(eq(certificates.certificateId, certificateId))
|
.where(eq(certificates.certificateId, certificateId))
|
||||||
@@ -70,27 +80,14 @@ export const removeCertificateById = async (certificateId: string) => {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const findCertificates = async () => {
|
const createCertificateFiles = async (certificate: Certificate) => {
|
||||||
return await db.query.certificates.findMany();
|
const { CERTIFICATES_PATH } = paths(!!certificate.serverId);
|
||||||
};
|
|
||||||
|
|
||||||
const createCertificateFiles = (certificate: Certificate) => {
|
|
||||||
const { CERTIFICATES_PATH } = paths();
|
|
||||||
const dockerPath = "/etc/traefik";
|
|
||||||
const certDir = path.join(CERTIFICATES_PATH, certificate.certificatePath);
|
const certDir = path.join(CERTIFICATES_PATH, certificate.certificatePath);
|
||||||
const crtPath = path.join(certDir, "chain.crt");
|
const crtPath = path.join(certDir, "chain.crt");
|
||||||
const keyPath = path.join(certDir, "privkey.key");
|
const keyPath = path.join(certDir, "privkey.key");
|
||||||
|
|
||||||
const chainPath = path.join(dockerPath, certDir, "chain.crt");
|
const chainPath = path.join(certDir, "chain.crt");
|
||||||
const keyPathDocker = path.join(dockerPath, certDir, "privkey.key");
|
const keyPathDocker = path.join(certDir, "privkey.key");
|
||||||
|
|
||||||
if (!fs.existsSync(certDir)) {
|
|
||||||
fs.mkdirSync(certDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(crtPath, certificate.certificateData);
|
|
||||||
fs.writeFileSync(keyPath, certificate.privateKey);
|
|
||||||
|
|
||||||
const traefikConfig = {
|
const traefikConfig = {
|
||||||
tls: {
|
tls: {
|
||||||
certificates: [
|
certificates: [
|
||||||
@@ -101,8 +98,28 @@ const createCertificateFiles = (certificate: Certificate) => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const yamlConfig = dump(traefikConfig);
|
const yamlConfig = dump(traefikConfig);
|
||||||
const configFile = path.join(certDir, "certificate.yml");
|
const configFile = path.join(certDir, "certificate.yml");
|
||||||
fs.writeFileSync(configFile, yamlConfig);
|
|
||||||
|
if (certificate.serverId) {
|
||||||
|
const certificateData = encodeBase64(certificate.certificateData);
|
||||||
|
const privateKey = encodeBase64(certificate.privateKey);
|
||||||
|
const command = `
|
||||||
|
mkdir -p ${certDir};
|
||||||
|
echo "${certificateData}" | base64 -d > "${crtPath}";
|
||||||
|
echo "${privateKey}" | base64 -d > "${keyPath}";
|
||||||
|
echo "${yamlConfig}" > "${configFile}";
|
||||||
|
`;
|
||||||
|
|
||||||
|
await execAsyncRemote(certificate.serverId, command);
|
||||||
|
} else {
|
||||||
|
if (!fs.existsSync(certDir)) {
|
||||||
|
fs.mkdirSync(certDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(crtPath, certificate.certificateData);
|
||||||
|
fs.writeFileSync(keyPath, certificate.privateKey);
|
||||||
|
|
||||||
|
fs.writeFileSync(configFile, yamlConfig);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user