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:
commit
b63488baba
@ -18,10 +18,25 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
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 {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { AlertTriangle, HelpCircle } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@ -35,6 +50,7 @@ const addCertificate = z.object({
|
||||
certificateData: z.string().min(1, "Certificate data is required"),
|
||||
privateKey: z.string().min(1, "Private key is required"),
|
||||
autoRenew: z.boolean().optional(),
|
||||
serverId: z.string().optional(),
|
||||
});
|
||||
|
||||
type AddCertificate = z.infer<typeof addCertificate>;
|
||||
@ -44,6 +60,7 @@ export const AddCertificate = () => {
|
||||
|
||||
const { mutateAsync, isError, error, isLoading } =
|
||||
api.certificates.create.useMutation();
|
||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||
|
||||
const form = useForm<AddCertificate>({
|
||||
defaultValues: {
|
||||
@ -64,6 +81,7 @@ export const AddCertificate = () => {
|
||||
certificateData: data.certificateData,
|
||||
privateKey: data.privateKey,
|
||||
autoRenew: data.autoRenew,
|
||||
serverId: data.serverId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Certificate Created");
|
||||
@ -144,6 +162,47 @@ export const AddCertificate = () => {
|
||||
</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>
|
||||
|
||||
<DialogFooter className="flex w-full flex-row !justify-between pt-3">
|
||||
|
@ -48,16 +48,13 @@ export const SettingsLayout = ({ children }: Props) => {
|
||||
icon: Database,
|
||||
href: "/dashboard/settings/destinations",
|
||||
},
|
||||
...(!isCloud
|
||||
? [
|
||||
{
|
||||
title: "Certificates",
|
||||
label: "",
|
||||
icon: ShieldCheck,
|
||||
href: "/dashboard/settings/certificates",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
{
|
||||
title: "Certificates",
|
||||
label: "",
|
||||
icon: ShieldCheck,
|
||||
href: "/dashboard/settings/certificates",
|
||||
},
|
||||
{
|
||||
title: "SSH Keys",
|
||||
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,
|
||||
"tag": "0039_many_tiger_shark",
|
||||
"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 { apiCreateCertificate, apiFindCertificate } from "@/server/db/schema";
|
||||
|
||||
import {
|
||||
apiCreateCertificate,
|
||||
apiFindCertificate,
|
||||
certificates,
|
||||
} from "@/server/db/schema";
|
||||
|
||||
import { db } from "@/server/db";
|
||||
import {
|
||||
IS_CLOUD,
|
||||
createCertificate,
|
||||
findCertificateById,
|
||||
findCertificates,
|
||||
removeCertificateById,
|
||||
} from "@dokploy/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export const certificateRouter = createTRPCRouter({
|
||||
create: adminProcedure
|
||||
.input(apiCreateCertificate)
|
||||
.mutation(async ({ input }) => {
|
||||
return await createCertificate(input);
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
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 }) => {
|
||||
return await findCertificateById(input.certificateId);
|
||||
}),
|
||||
one: adminProcedure
|
||||
.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
|
||||
.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);
|
||||
return true;
|
||||
}),
|
||||
all: adminProcedure.query(async () => {
|
||||
return findCertificates();
|
||||
all: adminProcedure.query(async ({ ctx }) => {
|
||||
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 { z } from "zod";
|
||||
import { auth } from "./auth";
|
||||
import { certificates } from "./certificate";
|
||||
import { registry } from "./registry";
|
||||
import { certificateType } from "./shared";
|
||||
import { sshKeys } from "./ssh-key";
|
||||
@ -37,6 +38,7 @@ export const adminsRelations = relations(admins, ({ one, many }) => ({
|
||||
users: many(users),
|
||||
registry: many(registry),
|
||||
sshKeys: many(sshKeys),
|
||||
certificates: many(certificates),
|
||||
}));
|
||||
|
||||
const createSchema = createInsertSchema(admins, {
|
||||
|
@ -1,7 +1,10 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { boolean, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { admins } from "./admin";
|
||||
import { server } from "./server";
|
||||
import { generateAppName } from "./utils";
|
||||
|
||||
export const certificates = pgTable("certificate", {
|
||||
@ -17,13 +20,34 @@ export const certificates = pgTable("certificate", {
|
||||
.$defaultFn(() => generateAppName("certificate"))
|
||||
.unique(),
|
||||
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, {
|
||||
name: z.string().min(1),
|
||||
certificateData: z.string().min(1),
|
||||
privateKey: z.string().min(1),
|
||||
autoRenew: z.boolean().optional(),
|
||||
serverId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const apiFindCertificate = z.object({
|
||||
|
@ -6,6 +6,7 @@ import { z } from "zod";
|
||||
|
||||
import { admins } from "./admin";
|
||||
import { applications } from "./application";
|
||||
import { certificates } from "./certificate";
|
||||
import { compose } from "./compose";
|
||||
import { deployments } from "./deployment";
|
||||
import { mariadb } from "./mariadb";
|
||||
@ -58,6 +59,7 @@ export const serverRelations = relations(server, ({ one, many }) => ({
|
||||
mongo: many(mongo),
|
||||
mysql: many(mysql),
|
||||
postgres: many(postgres),
|
||||
certificates: many(certificates),
|
||||
}));
|
||||
|
||||
const createSchema = createInsertSchema(server, {
|
||||
|
@ -8,6 +8,8 @@ import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { dump } from "js-yaml";
|
||||
import type { z } from "zod";
|
||||
import { encodeBase64 } from "../utils/docker/utils";
|
||||
import { execAsyncRemote } from "../utils/process/execAsync";
|
||||
|
||||
export type Certificate = typeof certificates.$inferSelect;
|
||||
|
||||
@ -28,11 +30,13 @@ export const findCertificateById = async (certificateId: string) => {
|
||||
|
||||
export const createCertificate = async (
|
||||
certificateData: z.infer<typeof apiCreateCertificate>,
|
||||
adminId: string,
|
||||
) => {
|
||||
const certificate = await db
|
||||
.insert(certificates)
|
||||
.values({
|
||||
...certificateData,
|
||||
adminId: adminId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
@ -46,15 +50,21 @@ export const createCertificate = async (
|
||||
const cer = certificate[0];
|
||||
|
||||
createCertificateFiles(cer);
|
||||
|
||||
return cer;
|
||||
};
|
||||
|
||||
export const removeCertificateById = async (certificateId: string) => {
|
||||
const { CERTIFICATES_PATH } = paths();
|
||||
const certificate = await findCertificateById(certificateId);
|
||||
const { CERTIFICATES_PATH } = paths(!!certificate.serverId);
|
||||
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
|
||||
.delete(certificates)
|
||||
.where(eq(certificates.certificateId, certificateId))
|
||||
@ -70,27 +80,14 @@ export const removeCertificateById = async (certificateId: string) => {
|
||||
return result;
|
||||
};
|
||||
|
||||
export const findCertificates = async () => {
|
||||
return await db.query.certificates.findMany();
|
||||
};
|
||||
|
||||
const createCertificateFiles = (certificate: Certificate) => {
|
||||
const { CERTIFICATES_PATH } = paths();
|
||||
const dockerPath = "/etc/traefik";
|
||||
const createCertificateFiles = async (certificate: Certificate) => {
|
||||
const { CERTIFICATES_PATH } = paths(!!certificate.serverId);
|
||||
const certDir = path.join(CERTIFICATES_PATH, certificate.certificatePath);
|
||||
const crtPath = path.join(certDir, "chain.crt");
|
||||
const keyPath = path.join(certDir, "privkey.key");
|
||||
|
||||
const chainPath = path.join(dockerPath, certDir, "chain.crt");
|
||||
const keyPathDocker = path.join(dockerPath, certDir, "privkey.key");
|
||||
|
||||
if (!fs.existsSync(certDir)) {
|
||||
fs.mkdirSync(certDir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(crtPath, certificate.certificateData);
|
||||
fs.writeFileSync(keyPath, certificate.privateKey);
|
||||
|
||||
const chainPath = path.join(certDir, "chain.crt");
|
||||
const keyPathDocker = path.join(certDir, "privkey.key");
|
||||
const traefikConfig = {
|
||||
tls: {
|
||||
certificates: [
|
||||
@ -101,8 +98,28 @@ const createCertificateFiles = (certificate: Certificate) => {
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const yamlConfig = dump(traefikConfig);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user