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
commit b63488baba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 4105 additions and 43 deletions

View File

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

View File

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

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,
"tag": "0039_many_tiger_shark",
"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 { 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) }),
});
}),
});

View File

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

View File

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

View File

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

View File

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