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:
Mauricio Siu
2024-10-13 11:01:16 -06:00
committed by GitHub
10 changed files with 4105 additions and 43 deletions

View File

@@ -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">

View File

@@ -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: "",

View 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 $$;

File diff suppressed because it is too large Load Diff

View File

@@ -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
} }
] ]
} }

View File

@@ -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) }),
});
}), }),
}); });

View File

@@ -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, {

View File

@@ -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({

View File

@@ -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, {

View File

@@ -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);
}
}; };