Merge pull request #276 from lorenzomigliorero/feat/shared-ssh

feat: shared ssh
This commit is contained in:
Mauricio Siu 2024-07-27 12:39:33 -06:00 committed by GitHub
commit 24cb47bcb1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 4160 additions and 391 deletions

View File

@ -19,7 +19,7 @@ const baseApp: ApplicationNested = {
createdAt: "",
customGitBranch: "",
customGitBuildPath: "",
customGitSSHKey: "",
customGitSSHKeyId: null,
customGitUrl: "",
description: "",
dockerfile: null,

View File

@ -1,13 +1,4 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
@ -17,11 +8,20 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import copy from "copy-to-clipboard";
import { CopyIcon, LockIcon } from "lucide-react";
import { KeyRoundIcon, LockIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@ -33,6 +33,7 @@ const GitProviderSchema = z.object({
}),
branch: z.string().min(1, "Branch required"),
buildPath: z.string().min(1, "Build Path required"),
sshKey: z.string().optional(),
});
type GitProvider = z.infer<typeof GitProviderSchema>;
@ -43,19 +44,18 @@ interface Props {
export const SaveGitProvider = ({ applicationId }: Props) => {
const { data, refetch } = api.application.one.useQuery({ applicationId });
const { data: sshKeys } = api.sshKey.all.useQuery();
const router = useRouter();
const { mutateAsync, isLoading } =
api.application.saveGitProdiver.useMutation();
const { mutateAsync: generateSSHKey, isLoading: isGeneratingSSHKey } =
api.application.generateSSHKey.useMutation();
const { mutateAsync: removeSSHKey, isLoading: isRemovingSSHKey } =
api.application.removeSSHKey.useMutation();
const form = useForm<GitProvider>({
defaultValues: {
branch: "",
buildPath: "/",
repositoryURL: "",
sshKey: undefined,
},
resolver: zodResolver(GitProviderSchema),
});
@ -63,6 +63,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
useEffect(() => {
if (data) {
form.reset({
sshKey: data.customGitSSHKeyId || undefined,
branch: data.customGitBranch || "",
buildPath: data.customGitBuildPath || "/",
repositoryURL: data.customGitUrl || "",
@ -75,6 +76,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
customGitBranch: values.branch,
customGitBuildPath: values.buildPath,
customGitUrl: values.repositoryURL,
customGitSSHKeyId: values.sshKey === "none" ? null : values.sshKey,
applicationId,
})
.then(async () => {
@ -92,160 +94,103 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="grid md:grid-cols-2 gap-4 ">
<div className="md:col-span-2 space-y-4">
<FormField
control={form.control}
name="repositoryURL"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row justify-between">
Repository URL
<div className="flex gap-2">
<Dialog>
<DialogTrigger className="flex flex-row gap-2">
<LockIcon className="size-4 text-muted-foreground" />?
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Private Repository</DialogTitle>
<DialogDescription>
If your repository is private is necessary to
generate SSH Keys to add to your git provider.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="relative">
<Textarea
placeholder="Please click on Generate SSH Key"
className="no-scrollbar h-64 text-muted-foreground"
disabled={!data?.customGitSSHKey}
contentEditable={false}
value={
data?.customGitSSHKey ||
"Please click on Generate SSH Key"
}
/>
<button
type="button"
className="absolute right-2 top-2"
onClick={() => {
copy(
data?.customGitSSHKey ||
"Generate a SSH Key",
);
toast.success("SSH Copied to clipboard");
}}
<div className="grid md:grid-cols-2 gap-4">
<div className="flex items-end col-span-2 gap-4">
<div className="grow">
<FormField
control={form.control}
name="repositoryURL"
render={({ field }) => (
<FormItem>
<FormLabel>Repository URL</FormLabel>
<FormControl>
<Input placeholder="git@bitbucket.org" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{sshKeys && sshKeys.length > 0 ? (
<FormField
control={form.control}
name="sshKey"
render={({ field }) => (
<FormItem className="basis-40">
<FormLabel className="w-full inline-flex justify-between">
SSH Key
<LockIcon className="size-4 text-muted-foreground" />
</FormLabel>
<FormControl>
<Select
key={field.value}
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a key" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{sshKeys?.map((sshKey) => (
<SelectItem
key={sshKey.sshKeyId}
value={sshKey.sshKeyId}
>
<CopyIcon className="size-4" />
</button>
</div>
</div>
<DialogFooter className="flex sm:justify-between gap-3.5 flex-col sm:flex-col w-full">
<div className="flex flex-row gap-2 w-full justify-between flex-wrap">
{data?.customGitSSHKey && (
<Button
variant="destructive"
isLoading={
isGeneratingSSHKey || isRemovingSSHKey
}
className="max-sm:w-full"
onClick={async () => {
await removeSSHKey({
applicationId,
})
.then(async () => {
toast.success("SSH Key Removed");
await refetch();
})
.catch(() => {
toast.error(
"Error to remove the SSH Key",
);
});
}}
type="button"
>
Remove SSH Key
</Button>
)}
<Button
isLoading={
isGeneratingSSHKey || isRemovingSSHKey
}
className="max-sm:w-full"
onClick={async () => {
await generateSSHKey({
applicationId,
})
.then(async () => {
toast.success("SSH Key Generated");
await refetch();
})
.catch(() => {
toast.error(
"Error to generate the SSH Key",
);
});
}}
type="button"
>
Generate SSH Key
</Button>
</div>
<span className="text-sm text-muted-foreground">
Is recommended to remove the SSH Key if you want
to deploy a public repository.
</span>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</FormLabel>
<FormControl>
<Input placeholder="git@bitbucket.org" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="space-y-4">
<FormField
control={form.control}
name="branch"
render={({ field }) => (
<FormItem>
<FormLabel>Branch</FormLabel>
<FormControl>
<Input placeholder="Branch" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="space-y-4">
<FormField
control={form.control}
name="buildPath"
render={({ field }) => (
<FormItem>
<FormLabel>Build Path</FormLabel>
<FormControl>
<Input placeholder="/" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{sshKey.name}
</SelectItem>
))}
<SelectItem value="none">None</SelectItem>
<SelectLabel>Keys ({sshKeys?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
) : (
<Button
variant="secondary"
onClick={() => router.push("/dashboard/settings/ssh-keys")}
type="button"
>
<KeyRoundIcon className="size-4" /> Add SSH Key
</Button>
)}
</div>
<FormField
control={form.control}
name="branch"
render={({ field }) => (
<FormItem>
<FormLabel>Branch</FormLabel>
<FormControl>
<Input placeholder="Branch" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="buildPath"
render={({ field }) => (
<FormItem>
<FormLabel>Build Path</FormLabel>
<FormControl>
<Input placeholder="/" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex flex-row justify-end">
<Button type="submit" className="w-fit" isLoading={isLoading}>
Save{" "}
Save
</Button>
</div>
</form>

View File

@ -1,13 +1,4 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
@ -17,11 +8,19 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import copy from "copy-to-clipboard";
import { CopyIcon, LockIcon } from "lucide-react";
import { KeyRoundIcon, LockIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@ -33,6 +32,7 @@ const GitProviderSchema = z.object({
message: "Repository URL is required",
}),
branch: z.string().min(1, "Branch required"),
sshKey: z.string().optional(),
});
type GitProvider = z.infer<typeof GitProviderSchema>;
@ -43,19 +43,17 @@ interface Props {
export const SaveGitProviderCompose = ({ composeId }: Props) => {
const { data, refetch } = api.compose.one.useQuery({ composeId });
const { data: sshKeys } = api.sshKey.all.useQuery();
const router = useRouter();
const { mutateAsync, isLoading } = api.compose.update.useMutation();
const { mutateAsync: generateSSHKey, isLoading: isGeneratingSSHKey } =
api.compose.generateSSHKey.useMutation();
const { mutateAsync: removeSSHKey, isLoading: isRemovingSSHKey } =
api.compose.removeSSHKey.useMutation();
const form = useForm<GitProvider>({
defaultValues: {
branch: "",
repositoryURL: "",
composePath: "./docker-compose.yml",
sshKey: undefined,
},
resolver: zodResolver(GitProviderSchema),
});
@ -63,6 +61,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
useEffect(() => {
if (data) {
form.reset({
sshKey: data.customGitSSHKeyId || undefined,
branch: data.customGitBranch || "",
repositoryURL: data.customGitUrl || "",
composePath: data.composePath,
@ -74,6 +73,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
await mutateAsync({
customGitBranch: values.branch,
customGitUrl: values.repositoryURL,
customGitSSHKeyId: values.sshKey === "none" ? null : values.sshKey,
composeId,
sourceType: "git",
composePath: values.composePath,
@ -94,123 +94,72 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
className="flex flex-col gap-4"
>
<div className="grid md:grid-cols-2 gap-4 ">
<div className="md:col-span-2 space-y-4">
<FormField
control={form.control}
name="repositoryURL"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row justify-between">
Repository URL
<div className="flex gap-2">
<Dialog>
<DialogTrigger className="flex flex-row gap-2">
<LockIcon className="size-4 text-muted-foreground" />?
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Private Repository</DialogTitle>
<DialogDescription>
If your repository is private is necessary to
generate SSH Keys to add to your git provider.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="relative">
<Textarea
placeholder="Please click on Generate SSH Key"
className="no-scrollbar h-64 text-muted-foreground"
disabled={!data?.customGitSSHKey}
contentEditable={false}
value={
data?.customGitSSHKey ||
"Please click on Generate SSH Key"
}
/>
<button
type="button"
className="absolute right-2 top-2"
onClick={() => {
copy(
data?.customGitSSHKey ||
"Generate a SSH Key",
);
toast.success("SSH Copied to clipboard");
}}
<div className="flex items-end col-span-2 gap-4">
<div className="grow">
<FormField
control={form.control}
name="repositoryURL"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row justify-between">
Repository URL
</FormLabel>
<FormControl>
<Input placeholder="git@bitbucket.org" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{sshKeys && sshKeys.length > 0 ? (
<FormField
control={form.control}
name="sshKey"
render={({ field }) => (
<FormItem className="basis-40">
<FormLabel className="w-full inline-flex justify-between">
SSH Key
<LockIcon className="size-4 text-muted-foreground" />
</FormLabel>
<FormControl>
<Select
key={field.value}
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a key" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{sshKeys?.map((sshKey) => (
<SelectItem
key={sshKey.sshKeyId}
value={sshKey.sshKeyId}
>
<CopyIcon className="size-4" />
</button>
</div>
</div>
<DialogFooter className="flex sm:justify-between gap-3.5 flex-col sm:flex-col w-full">
<div className="flex flex-row gap-2 w-full justify-between flex-wrap">
{data?.customGitSSHKey && (
<Button
variant="destructive"
isLoading={
isGeneratingSSHKey || isRemovingSSHKey
}
className="max-sm:w-full"
onClick={async () => {
await removeSSHKey({
composeId,
})
.then(async () => {
toast.success("SSH Key Removed");
await refetch();
})
.catch(() => {
toast.error(
"Error to remove the SSH Key",
);
});
}}
type="button"
>
Remove SSH Key
</Button>
)}
<Button
isLoading={
isGeneratingSSHKey || isRemovingSSHKey
}
className="max-sm:w-full"
onClick={async () => {
await generateSSHKey({
composeId,
})
.then(async () => {
toast.success("SSH Key Generated");
await refetch();
})
.catch(() => {
toast.error(
"Error to generate the SSH Key",
);
});
}}
type="button"
>
Generate SSH Key
</Button>
</div>
<span className="text-sm text-muted-foreground">
Is recommended to remove the SSH Key if you want
to deploy a public repository.
</span>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</FormLabel>
<FormControl>
<Input placeholder="git@bitbucket.org" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{sshKey.name}
</SelectItem>
))}
<SelectItem value="none">None</SelectItem>
<SelectLabel>Keys ({sshKeys?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
) : (
<Button
variant="secondary"
onClick={() => router.push("/dashboard/settings/ssh-keys")}
type="button"
>
<KeyRoundIcon className="size-4" /> Add SSH Key
</Button>
)}
</div>
<div className="space-y-4">
<FormField

View File

@ -0,0 +1,200 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { sshKeyCreate, type sshKeyType } from "@/server/db/validations";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { type ReactNode, useState } from "react";
import { flushSync } from "react-dom";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import type { z } from "zod";
type SSHKey = z.infer<typeof sshKeyCreate>;
interface Props {
children: ReactNode;
}
export const AddSSHKey = ({ children }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync, isError, error, isLoading } =
api.sshKey.create.useMutation();
const generateMutation = api.sshKey.generate.useMutation();
const form = useForm<SSHKey>({
resolver: zodResolver(sshKeyCreate),
});
const onSubmit = async (data: SSHKey) => {
await mutateAsync(data)
.then(async () => {
toast.success("SSH key created successfully");
await utils.sshKey.all.invalidate();
/*
Flushsync is needed for a bug witht he react-hook-form reset method
https://github.com/orgs/react-hook-form/discussions/7589#discussioncomment-10060621
*/
flushSync(() => form.reset());
setIsOpen(false);
})
.catch(() => {
toast.error("Error to create the SSH key");
});
};
const onGenerateSSHKey = (type: z.infer<typeof sshKeyType>) =>
generateMutation
.mutateAsync(type)
.then(async (data) => {
toast.success("SSH Key Generated");
form.setValue("privateKey", data.privateKey);
form.setValue("publicKey", data.publicKey);
})
.catch(() => {
toast.error("Error to generate the SSH Key");
});
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger className="" asChild>
{children}
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>SSH Key</DialogTitle>
<DialogDescription className="space-y-4">
<div>
In this section you can add one of your keys or generate a new
one.
</div>
<div className="flex gap-4">
<Button
variant={"secondary"}
disabled={generateMutation.isLoading}
className="max-sm:w-full"
onClick={() => onGenerateSSHKey("rsa")}
type="button"
>
Generate RSA SSH Key
</Button>
<Button
variant={"secondary"}
disabled={generateMutation.isLoading}
className="max-sm:w-full"
onClick={() => onGenerateSSHKey("ed25519")}
type="button"
>
Generate ED25519 SSH Key
</Button>
</div>
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
className="grid w-full gap-4 "
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="name"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder={"Personal projects"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input
placeholder={"Used on my personal Hetzner VPS"}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="privateKey"
render={({ field }) => (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Private Key</FormLabel>
</div>
<FormControl>
<Textarea
placeholder={"-----BEGIN RSA PRIVATE KEY-----"}
rows={5}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="publicKey"
render={({ field }) => (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Public Key</FormLabel>
</div>
<FormControl>
<Input placeholder={"ssh-rsa AAAAB3NzaC1yc2E"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button isLoading={isLoading} type="submit">
Create
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,61 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { TrashIcon } from "lucide-react";
import React from "react";
import { toast } from "sonner";
interface Props {
sshKeyId: string;
}
export const DeleteSSHKey = ({ sshKeyId }: Props) => {
const { mutateAsync, isLoading } = api.sshKey.remove.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<TrashIcon className="size-4 text-muted-foreground" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the SSH
key
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
sshKeyId,
})
.then(() => {
utils.sshKey.all.invalidate();
toast.success("SSH Key delete successfully");
})
.catch(() => {
toast.error("Error to delete SSH key");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@ -0,0 +1,96 @@
import { UpdateSSHKey } from "@/components/dashboard/settings/ssh-keys/update-ssh-key";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { formatDistanceToNow } from "date-fns";
import { KeyRound, KeyRoundIcon, PenBoxIcon } from "lucide-react";
import { AddSSHKey } from "./add-ssh-key";
import { DeleteSSHKey } from "./delete-ssh-key";
export const ShowDestinations = () => {
const { data } = api.sshKey.all.useQuery();
return (
<div className="w-full">
<Card className="h-full bg-transparent">
<CardHeader>
<CardTitle className="text-xl">SSH Keys</CardTitle>
<CardDescription>
Use SSH to beeing able cloning from private repositories.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 pt-4">
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3">
<KeyRound className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground">
Add your first SSH Key
</span>
<AddSSHKey>
<Button>
<KeyRoundIcon className="size-4" /> Add SSH Key
</Button>
</AddSSHKey>
</div>
) : (
<div className="space-y-8">
<div className="flex flex-col gap-4">
<div className="flex gap-4 text-xs px-3.5">
<div className="col-span-2 basis-4/12">Key</div>
<div className="basis-3/12">Added</div>
<div>Last Used</div>
</div>
{data?.map((sshKey) => (
<div
key={sshKey.sshKeyId}
className="flex gap-4 items-center border p-3.5 rounded-lg text-sm"
>
<div className="flex flex-col basis-4/12">
<span>{sshKey.name}</span>
{sshKey.description && (
<span className="text-xs text-muted-foreground">
{sshKey.description}
</span>
)}
</div>
<div className="basis-3/12">
{formatDistanceToNow(new Date(sshKey.createdAt), {
addSuffix: true,
})}
</div>
<div className="grow">
{sshKey.lastUsedAt
? formatDistanceToNow(new Date(sshKey.lastUsedAt), {
addSuffix: true,
})
: "Never"}
</div>
<div className="flex flex-row gap-1">
<UpdateSSHKey sshKeyId={sshKey.sshKeyId}>
<Button variant="ghost">
<PenBoxIcon className="size-4 text-muted-foreground" />
</Button>
</UpdateSSHKey>
<DeleteSSHKey sshKeyId={sshKey.sshKeyId} />
</div>
</div>
))}
</div>
<AddSSHKey>
<Button>
<KeyRoundIcon className="size-4" /> Add SSH Key
</Button>
</AddSSHKey>
</div>
)}
</CardContent>
</Card>
</div>
);
};

View File

@ -0,0 +1,168 @@
import { DeleteSSHKey } from "@/components/dashboard/settings/ssh-keys/delete-ssh-key";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { sshKeyUpdate } from "@/server/db/validations";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import copy from "copy-to-clipboard";
import { CopyIcon } from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import type { z } from "zod";
type SSHKey = z.infer<typeof sshKeyUpdate>;
interface Props {
children: ReactNode;
sshKeyId?: string;
}
export const UpdateSSHKey = ({ children, sshKeyId = "" }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { data } = api.sshKey.one.useQuery({
sshKeyId,
});
const { mutateAsync, isError, error, isLoading } =
api.sshKey.update.useMutation();
const form = useForm<SSHKey>({
resolver: zodResolver(sshKeyUpdate),
});
useEffect(() => {
if (data) {
form.reset({
...data,
/* Convert null to undefined */
description: data.description || undefined,
});
}
}, [data]);
const onSubmit = async (data: SSHKey) => {
await mutateAsync({
sshKeyId,
...data,
})
.then(async () => {
toast.success("SSH Key Updated");
await utils.sshKey.all.invalidate();
setIsOpen(false);
})
.catch(() => {
toast.error("Error to update the SSH key");
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger className="" asChild>
{children}
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>SSH Key</DialogTitle>
<DialogDescription>
In this section you can edit an SSH key
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
className="grid w-full gap-4 "
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="name"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder={"Personal projects"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input
placeholder={"Used on my personal Hetzner VPS"}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormItem>
<FormLabel>Public Key</FormLabel>
<FormControl>
<div className="relative">
<Textarea
rows={7}
readOnly
disabled
value={data?.publicKey}
/>
<button
type="button"
className="absolute right-2 top-2"
onClick={() => {
copy(data?.publicKey || "Generate a SSH Key");
toast.success("SSH Copied to clipboard");
}}
>
<CopyIcon className="size-4" />
</button>
</div>
</FormControl>
<FormMessage />
</FormItem>
<DialogFooter>
<Button isLoading={isLoading} type="submit">
Update
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -53,6 +53,12 @@ export const SettingsLayout = ({ children }: Props) => {
icon: ShieldCheck,
href: "/dashboard/settings/certificates",
},
{
title: "SSH Keys",
label: "",
icon: KeyRound,
href: "/dashboard/settings/ssh-keys",
},
{
title: "Users",
label: "",
@ -86,6 +92,7 @@ import {
Activity,
Bell,
Database,
KeyRound,
type LucideIcon,
Route,
Server,

View File

@ -57,7 +57,7 @@ echo "Network created"
mkdir -p /etc/dokploy
chmod -R 777 /etc/dokploy
chmod 777 /etc/dokploy
docker pull dokploy/dokploy:canary

View File

@ -70,7 +70,7 @@ echo "Network created"
mkdir -p /etc/dokploy
chmod -R 777 /etc/dokploy
chmod 777 /etc/dokploy
docker pull dokploy/dokploy:latest

View File

@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS "ssh-key" (
"sshKeyId" text PRIMARY KEY NOT NULL,
"publicKey" text NOT NULL,
"name" text NOT NULL,
"description" text,
"createdAt" text NOT NULL,
"lastUsedAt" text
);
--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "customGitSSHKeyId" text;--> statement-breakpoint
ALTER TABLE "compose" ADD COLUMN "customGitSSHKeyId" text;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "application" ADD CONSTRAINT "application_customGitSSHKeyId_ssh-key_sshKeyId_fk" FOREIGN KEY ("customGitSSHKeyId") REFERENCES "public"."ssh-key"("sshKeyId") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "compose" ADD CONSTRAINT "compose_customGitSSHKeyId_ssh-key_sshKeyId_fk" FOREIGN KEY ("customGitSSHKeyId") REFERENCES "public"."ssh-key"("sshKeyId") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
ALTER TABLE "application" DROP COLUMN IF EXISTS "customGitSSHKey";--> statement-breakpoint
ALTER TABLE "compose" DROP COLUMN IF EXISTS "customGitSSHKey";

File diff suppressed because it is too large Load Diff

View File

@ -183,6 +183,13 @@
"when": 1721633853118,
"tag": "0025_lying_mephisto",
"breakpoints": true
},
{
"idx": 26,
"version": "6",
"when": 1721979220929,
"tag": "0026_known_dormammu",
"breakpoints": true
}
]
}

View File

@ -0,0 +1,41 @@
import { ShowDestinations } from "@/components/dashboard/settings/ssh-keys/show-ssh-keys";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { SettingsLayout } from "@/components/layouts/settings-layout";
import { validateRequest } from "@/server/auth/auth";
import type { GetServerSidePropsContext } from "next";
import React, { type ReactElement } from "react";
const Page = () => {
return (
<div className="flex flex-col gap-4 w-full">
<ShowDestinations />
</div>
);
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return (
<DashboardLayout tab={"settings"}>
<SettingsLayout>{page}</SettingsLayout>
</DashboardLayout>
);
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { user, session } = await validateRequest(ctx.req, ctx.res);
if (!user || user.rol === "user") {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
return {
props: {},
};
}

View File

@ -23,6 +23,7 @@ import { redisRouter } from "./routers/redis";
import { registryRouter } from "./routers/registry";
import { securityRouter } from "./routers/security";
import { settingsRouter } from "./routers/settings";
import { sshRouter } from "./routers/ssh-key";
import { userRouter } from "./routers/user";
/**
@ -56,6 +57,7 @@ export const appRouter = createTRPCRouter({
registry: registryRouter,
cluster: clusterRouter,
notification: notificationRouter,
sshKey: sshRouter,
});
// export type definition of API

View File

@ -31,11 +31,6 @@ import {
removeDirectoryCode,
removeMonitoringDirectory,
} from "@/server/utils/filesystem/directory";
import {
generateSSHKey,
readRSAFile,
removeRSAFiles,
} from "@/server/utils/filesystem/ssh";
import {
readConfig,
removeTraefikConfig,
@ -130,7 +125,6 @@ export const applicationRouter = createTRPCRouter({
async () => await removeMonitoringDirectory(application?.appName),
async () => await removeTraefikConfig(application?.appName),
async () => await removeService(application?.appName),
async () => await removeRSAFiles(application?.appName),
];
for (const operation of cleanupOperations) {
@ -235,36 +229,11 @@ export const applicationRouter = createTRPCRouter({
customGitBranch: input.customGitBranch,
customGitBuildPath: input.customGitBuildPath,
customGitUrl: input.customGitUrl,
customGitSSHKeyId: input.customGitSSHKeyId,
sourceType: "git",
applicationStatus: "idle",
});
return true;
}),
generateSSHKey: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input }) => {
const application = await findApplicationById(input.applicationId);
try {
await generateSSHKey(application.appName);
const file = await readRSAFile(application.appName);
await updateApplication(input.applicationId, {
customGitSSHKey: file,
});
} catch (error) {}
return true;
}),
removeSSHKey: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input }) => {
const application = await findApplicationById(input.applicationId);
await removeRSAFiles(application.appName);
await updateApplication(input.applicationId, {
customGitSSHKey: null,
});
return true;
}),
markRunning: protectedProcedure

View File

@ -16,11 +16,6 @@ import { myQueue } from "@/server/queues/queueSetup";
import { createCommand } from "@/server/utils/builders/compose";
import { randomizeComposeFile } from "@/server/utils/docker/compose";
import { removeComposeDirectory } from "@/server/utils/filesystem/directory";
import {
generateSSHKey,
readRSAFile,
removeRSAFiles,
} from "@/server/utils/filesystem/ssh";
import { templates } from "@/templates/templates";
import type { TemplatesKeys } from "@/templates/types/templates-data.type";
import {
@ -102,7 +97,6 @@ export const composeRouter = createTRPCRouter({
async () => await removeCompose(composeResult),
async () => await removeDeploymentsByComposeId(composeResult),
async () => await removeComposeDirectory(composeResult.appName),
async () => await removeRSAFiles(composeResult.appName),
];
for (const operation of cleanupOperations) {
@ -181,38 +175,12 @@ export const composeRouter = createTRPCRouter({
const command = createCommand(compose);
return `docker ${command}`;
}),
generateSSHKey: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input }) => {
const compose = await findComposeById(input.composeId);
try {
await generateSSHKey(compose.appName);
const file = await readRSAFile(compose.appName);
await updateCompose(input.composeId, {
customGitSSHKey: file,
});
} catch (error) {}
return true;
}),
refreshToken: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input }) => {
await updateCompose(input.composeId, {
refreshToken: nanoid(),
});
return true;
}),
removeSSHKey: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input }) => {
const compose = await findComposeById(input.composeId);
await removeRSAFiles(compose.appName);
await updateCompose(input.composeId, {
customGitSSHKey: null,
});
return true;
}),
deployTemplate: protectedProcedure

View File

@ -0,0 +1,70 @@
import {
adminProcedure,
createTRPCRouter,
protectedProcedure,
} from "@/server/api/trpc";
import { db } from "@/server/db";
import {
apiCreateSshKey,
apiFindOneSshKey,
apiGenerateSSHKey,
apiRemoveSshKey,
apiUpdateSshKey,
} from "@/server/db/schema";
import { generateSSHKey } from "@/server/utils/filesystem/ssh";
import { TRPCError } from "@trpc/server";
import {
createSshKey,
findSSHKeyById,
removeSSHKeyById,
updateSSHKeyById,
} from "../services/ssh-key";
export const sshRouter = createTRPCRouter({
create: protectedProcedure
.input(apiCreateSshKey)
.mutation(async ({ input }) => {
try {
await createSshKey(input);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error to create the ssh key",
cause: error,
});
}
}),
remove: adminProcedure.input(apiRemoveSshKey).mutation(async ({ input }) => {
try {
return await removeSSHKeyById(input.sshKeyId);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error to delete this ssh key",
});
}
}),
one: protectedProcedure.input(apiFindOneSshKey).query(async ({ input }) => {
const sshKey = await findSSHKeyById(input.sshKeyId);
return sshKey;
}),
all: adminProcedure.query(async () => {
return await db.query.sshKeys.findMany({});
}),
generate: protectedProcedure
.input(apiGenerateSSHKey)
.mutation(async ({ input }) => {
return await generateSSHKey(input);
}),
update: adminProcedure.input(apiUpdateSshKey).mutation(async ({ input }) => {
try {
return await updateSSHKeyById(input);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error to update this ssh key",
cause: error,
});
}
}),
});

View File

@ -0,0 +1,78 @@
import { db } from "@/server/db";
import {
type apiCreateSshKey,
type apiFindOneSshKey,
type apiRemoveSshKey,
type apiUpdateSshKey,
sshKeys,
} from "@/server/db/schema";
import { removeSSHKey, saveSSHKey } from "@/server/utils/filesystem/ssh";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
export const createSshKey = async ({
privateKey,
...input
}: typeof apiCreateSshKey._type) => {
await db.transaction(async (tx) => {
const sshKey = await tx
.insert(sshKeys)
.values(input)
.returning()
.then((response) => response[0])
.catch((e) => console.error(e));
if (sshKey) {
saveSSHKey(sshKey.sshKeyId, sshKey.publicKey, privateKey);
}
if (!sshKey) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error to create the ssh key",
});
}
return sshKey;
});
};
export const removeSSHKeyById = async (
sshKeyId: (typeof apiRemoveSshKey._type)["sshKeyId"],
) => {
const result = await db
.delete(sshKeys)
.where(eq(sshKeys.sshKeyId, sshKeyId))
.returning();
removeSSHKey(sshKeyId);
return result[0];
};
export const updateSSHKeyById = async ({
sshKeyId,
...input
}: typeof apiUpdateSshKey._type) => {
const result = await db
.update(sshKeys)
.set(input)
.where(eq(sshKeys.sshKeyId, sshKeyId))
.returning();
return result[0];
};
export const findSSHKeyById = async (
sshKeyId: (typeof apiFindOneSshKey._type)["sshKeyId"],
) => {
const sshKey = await db.query.sshKeys.findFirst({
where: eq(sshKeys.sshKeyId, sshKeyId),
});
if (!sshKey) {
throw new TRPCError({
code: "NOT_FOUND",
message: "SSH Key not found",
});
}
return sshKey;
};

View File

@ -20,6 +20,7 @@ import { redirects } from "./redirects";
import { registry } from "./registry";
import { security } from "./security";
import { applicationStatus } from "./shared";
import { sshKeys } from "./ssh-key";
import { generateAppName } from "./utils";
export const sourceType = pgEnum("sourceType", [
@ -132,7 +133,12 @@ export const applications = pgTable("application", {
customGitUrl: text("customGitUrl"),
customGitBranch: text("customGitBranch"),
customGitBuildPath: text("customGitBuildPath"),
customGitSSHKey: text("customGitSSHKey"),
customGitSSHKeyId: text("customGitSSHKeyId").references(
() => sshKeys.sshKeyId,
{
onDelete: "set null",
},
),
dockerfile: text("dockerfile"),
// Drop
dropBuildPath: text("dropBuildPath"),
@ -170,6 +176,10 @@ export const applicationsRelations = relations(
references: [projects.projectId],
}),
deployments: many(deployments),
customGitSSHKey: one(sshKeys, {
fields: [applications.customGitSSHKeyId],
references: [sshKeys.sshKeyId],
}),
domains: many(domains),
mounts: many(mounts),
redirects: many(redirects),
@ -289,7 +299,7 @@ const createSchema = createInsertSchema(applications, {
dockerImage: z.string().optional(),
username: z.string().optional(),
password: z.string().optional(),
customGitSSHKey: z.string().optional(),
customGitSSHKeyId: z.string().optional(),
repository: z.string().optional(),
dockerfile: z.string().optional(),
branch: z.string().optional(),
@ -371,7 +381,12 @@ export const apiSaveGitProvider = createSchema
customGitBuildPath: true,
customGitUrl: true,
})
.required();
.required()
.merge(
createSchema.pick({
customGitSSHKeyId: true,
}),
);
export const apiSaveEnvironmentVariables = createSchema
.pick({

View File

@ -1,3 +1,4 @@
import { sshKeys } from "@/server/db/schema/ssh-key";
import { generatePassword } from "@/templates/utils";
import { relations } from "drizzle-orm";
import { boolean, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
@ -41,7 +42,12 @@ export const compose = pgTable("compose", {
// Git
customGitUrl: text("customGitUrl"),
customGitBranch: text("customGitBranch"),
customGitSSHKey: text("customGitSSHKey"),
customGitSSHKeyId: text("customGitSSHKeyId").references(
() => sshKeys.sshKeyId,
{
onDelete: "set null",
},
),
//
command: text("command").notNull().default(""),
//
@ -62,6 +68,10 @@ export const composeRelations = relations(compose, ({ one, many }) => ({
}),
deployments: many(deployments),
mounts: many(mounts),
customGitSSHKey: one(sshKeys, {
fields: [compose.customGitSSHKeyId],
references: [sshKeys.sshKeyId],
}),
}));
const createSchema = createInsertSchema(compose, {
@ -70,6 +80,7 @@ const createSchema = createInsertSchema(compose, {
env: z.string().optional(),
composeFile: z.string().min(1),
projectId: z.string(),
customGitSSHKeyId: z.string().optional(),
command: z.string().optional(),
composePath: z.string().min(1),
composeType: z.enum(["docker-compose", "stack"]).optional(),

View File

@ -22,3 +22,4 @@ export * from "./shared";
export * from "./compose";
export * from "./registry";
export * from "./notification";
export * from "./ssh-key";

View File

@ -0,0 +1,69 @@
import { applications } from "@/server/db/schema/application";
import { compose } from "@/server/db/schema/compose";
import { sshKeyCreate, sshKeyType } from "@/server/db/validations";
import { relations } from "drizzle-orm";
import { pgTable, text, time } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
export const sshKeys = pgTable("ssh-key", {
sshKeyId: text("sshKeyId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
publicKey: text("publicKey").notNull(),
name: text("name").notNull(),
description: text("description"),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
lastUsedAt: text("lastUsedAt"),
});
export const sshKeysRelations = relations(sshKeys, ({ many }) => ({
applications: many(applications),
compose: many(compose),
}));
const createSchema = createInsertSchema(
sshKeys,
/* Private key is not stored in the DB */
sshKeyCreate.omit({ privateKey: true }).shape,
);
export const apiCreateSshKey = createSchema
.pick({
name: true,
description: true,
publicKey: true,
})
.merge(sshKeyCreate.pick({ privateKey: true }));
export const apiFindOneSshKey = createSchema
.pick({
sshKeyId: true,
})
.required();
export const apiGenerateSSHKey = sshKeyType;
export const apiRemoveSshKey = createSchema
.pick({
sshKeyId: true,
})
.required();
export const apiUpdateSshKey = createSchema
.pick({
name: true,
description: true,
lastUsedAt: true,
})
.partial()
.merge(
createSchema
.pick({
sshKeyId: true,
})
.required(),
);

View File

@ -1,5 +1,39 @@
import { z } from "zod";
export const sshKeyCreate = z.object({
name: z.string().min(1),
description: z.string().optional(),
publicKey: z.string().refine(
(key) => {
const rsaPubPattern = /^ssh-rsa\s+([A-Za-z0-9+/=]+)\s*(.*)?\s*$/;
const ed25519PubPattern = /^ssh-ed25519\s+([A-Za-z0-9+/=]+)\s*(.*)?\s*$/;
return rsaPubPattern.test(key) || ed25519PubPattern.test(key);
},
{
message: "Invalid public key format",
},
),
privateKey: z.string().refine(
(key) => {
const rsaPrivPattern =
/^-----BEGIN RSA PRIVATE KEY-----\n([A-Za-z0-9+/=\n]+)-----END RSA PRIVATE KEY-----\s*$/;
const ed25519PrivPattern =
/^-----BEGIN OPENSSH PRIVATE KEY-----\n([A-Za-z0-9+/=\n]+)-----END OPENSSH PRIVATE KEY-----\s*$/;
return rsaPrivPattern.test(key) || ed25519PrivPattern.test(key);
},
{
message: "Invalid private key format",
},
),
});
export const sshKeyUpdate = sshKeyCreate.pick({
name: true,
description: true,
});
export const sshKeyType = z.enum(["rsa", "ed25519"]).optional();
export const domain = z
.object({
host: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9\.-]*\.[a-zA-Z]{2,}$/, {

View File

@ -1,3 +1,4 @@
import { spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync } from "node:fs";
import {
APPLICATIONS_PATH,
@ -33,7 +34,7 @@ export const setupDirectories = () => {
try {
createDirectoryIfNotExist(dir);
if (dir === SSH_PATH) {
chmodSync(SSH_PATH, "600");
chmodSync(SSH_PATH, "700");
}
} catch (error) {
console.log(error, " On path: ", dir);

View File

@ -3,57 +3,90 @@ import * as path from "node:path";
import { SSH_PATH } from "@/server/constants";
import { spawnAsync } from "../process/spawnAsync";
export const generateSSHKey = async (appName: string) => {
const readSSHKey = async (id: string) => {
try {
if (!fs.existsSync(SSH_PATH)) {
fs.mkdirSync(SSH_PATH, { recursive: true });
}
return {
privateKey: fs.readFileSync(path.join(SSH_PATH, `${id}_rsa`), {
encoding: "utf-8",
}),
publicKey: fs.readFileSync(path.join(SSH_PATH, `${id}_rsa.pub`), {
encoding: "utf-8",
}),
};
} catch (error) {
throw error;
}
};
export const saveSSHKey = async (
id: string,
publicKey: string,
privateKey: string,
) => {
const applicationDirectory = SSH_PATH;
const privateKeyPath = path.join(applicationDirectory, `${id}_rsa`);
const publicKeyPath = path.join(applicationDirectory, `${id}_rsa.pub`);
const privateKeyStream = fs.createWriteStream(privateKeyPath, {
mode: 0o600,
});
privateKeyStream.write(privateKey);
privateKeyStream.end();
fs.writeFileSync(publicKeyPath, publicKey);
};
export const generateSSHKey = async (type: "rsa" | "ed25519" = "rsa") => {
const applicationDirectory = SSH_PATH;
if (!fs.existsSync(applicationDirectory)) {
fs.mkdirSync(applicationDirectory, { recursive: true });
}
const keyPath = path.join(applicationDirectory, `${appName}_rsa`);
const keyPath = path.join(applicationDirectory, "temp_rsa");
if (fs.existsSync(`${keyPath}`)) {
fs.unlinkSync(`${keyPath}`);
}
if (fs.existsSync(`${keyPath}.pub`)) {
fs.unlinkSync(`${keyPath}.pub`);
}
const args = [
"-t",
"rsa",
type,
"-b",
"4096",
"-C",
"dokploy",
"-m",
"PEM",
"-f",
keyPath,
"-N",
"",
];
try {
await spawnAsync("ssh-keygen", args);
return keyPath;
} catch (error) {
throw error;
}
};
export const readRSAFile = async (appName: string) => {
try {
if (!fs.existsSync(SSH_PATH)) {
fs.mkdirSync(SSH_PATH, { recursive: true });
}
const keyPath = path.join(SSH_PATH, `${appName}_rsa.pub`);
const data = fs.readFileSync(keyPath, { encoding: "utf-8" });
const data = await readSSHKey("temp");
await removeSSHKey("temp");
return data;
} catch (error) {
throw error;
}
};
export const removeRSAFiles = async (appName: string) => {
export const removeSSHKey = async (id: string) => {
try {
const publicKeyPath = path.join(SSH_PATH, `${appName}_rsa.pub`);
const privateKeyPath = path.join(SSH_PATH, `${appName}_rsa`);
const publicKeyPath = path.join(SSH_PATH, `${id}_rsa.pub`);
const privateKeyPath = path.join(SSH_PATH, `${id}_rsa`);
await fs.promises.unlink(publicKeyPath);
await fs.promises.unlink(privateKeyPath);
} catch (error) {

View File

@ -1,5 +1,6 @@
import { createWriteStream } from "node:fs";
import path, { join } from "node:path";
import { updateSSHKeyById } from "@/server/api/services/ssh-key";
import { APPLICATIONS_PATH, COMPOSE_PATH, SSH_PATH } from "@/server/constants";
import { TRPCError } from "@trpc/server";
import { recreateDirectory } from "../filesystem/directory";
@ -11,12 +12,12 @@ export const cloneGitRepository = async (
appName: string;
customGitUrl?: string | null;
customGitBranch?: string | null;
customGitSSHKey?: string | null;
customGitSSHKeyId?: string | null;
},
logPath: string,
isCompose = false,
) => {
const { appName, customGitUrl, customGitBranch, customGitSSHKey } = entity;
const { appName, customGitUrl, customGitBranch, customGitSSHKeyId } = entity;
if (!customGitUrl || !customGitBranch) {
throw new TRPCError({
@ -26,7 +27,7 @@ export const cloneGitRepository = async (
}
const writeStream = createWriteStream(logPath, { flags: "a" });
const keyPath = path.join(SSH_PATH, `${appName}_rsa`);
const keyPath = path.join(SSH_PATH, `${customGitSSHKeyId}_rsa`);
const basePath = isCompose ? COMPOSE_PATH : APPLICATIONS_PATH;
const outputPath = join(basePath, appName, "code");
const knownHostsPath = path.join(SSH_PATH, "known_hosts");
@ -40,6 +41,13 @@ export const cloneGitRepository = async (
`\nCloning Repo Custom ${customGitUrl} to ${outputPath}: ✅\n`,
);
if (customGitSSHKeyId) {
await updateSSHKeyById({
sshKeyId: customGitSSHKeyId,
lastUsedAt: new Date().toISOString(),
});
}
await spawnAsync(
"git",
[
@ -60,12 +68,13 @@ export const cloneGitRepository = async (
{
env: {
...process.env,
...(customGitSSHKey && {
...(customGitSSHKeyId && {
GIT_SSH_COMMAND: `ssh -i ${keyPath} -o UserKnownHostsFile=${knownHostsPath}`,
}),
},
},
);
writeStream.write(`\nCloned Custom Git ${customGitUrl}: ✅\n`);
} catch (error) {
writeStream.write(`\nERROR Cloning Custom Git: ${error}: ❌\n`);