feat: init multi server feature

This commit is contained in:
Mauricio Siu 2024-09-08 01:45:39 -06:00
parent 0b18f86a91
commit bd0bbdea26
37 changed files with 12476 additions and 212 deletions

View File

@ -0,0 +1,253 @@
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 { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const Schema = z.object({
name: z.string().min(1, {
message: "Name is required",
}),
description: z.string().optional(),
ipAddress: z.string().min(1, {
message: "IP Address is required",
}),
port: z.number().optional(),
username: z.string().optional(),
sshKeyId: z.string().min(1, {
message: "SSH Key is required",
}),
});
type Schema = z.infer<typeof Schema>;
export const AddServer = () => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { data: sshKeys } = api.sshKey.all.useQuery();
const { mutateAsync, error, isError } = api.server.create.useMutation();
const form = useForm<Schema>({
defaultValues: {
description: "",
name: "",
ipAddress: "",
port: 22,
username: "root",
sshKeyId: "",
},
resolver: zodResolver(Schema),
});
useEffect(() => {
form.reset({
description: "",
name: "",
ipAddress: "",
port: 22,
username: "root",
sshKeyId: "",
});
}, [form, form.reset, form.formState.isSubmitSuccessful]);
const onSubmit = async (data: Schema) => {
await mutateAsync({
name: data.name,
description: data.description || "",
ipAddress: data.ipAddress || "",
port: data.port || 22,
username: data.username || "root",
sshKeyId: data.sshKeyId || "",
})
.then(async (data) => {
await utils.server.all.invalidate();
toast.success("Server Created");
setIsOpen(false);
})
.catch(() => {
toast.error("Error to create a server");
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button>
<PlusIcon className="h-4 w-4" />
Create Server
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-3xl ">
<DialogHeader>
<DialogTitle>Add Server</DialogTitle>
<DialogDescription>
Add a server to deploy your applications remotely.
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form-add-server"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<div className="flex flex-col gap-4 ">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Hostinger Server" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="This server is for databases..."
className="resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="sshKeyId"
render={({ field }) => (
<FormItem>
<FormLabel>Select a SSH Key</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a SSH Key" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{sshKeys?.map((sshKey) => (
<SelectItem
key={sshKey.sshKeyId}
value={sshKey.sshKeyId}
>
{sshKey.name}
</SelectItem>
))}
<SelectLabel>
Registries ({sshKeys?.length})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="ipAddress"
render={({ field }) => (
<FormItem>
<FormLabel>IP Address</FormLabel>
<FormControl>
<Input placeholder="192.168.1.100" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>Port</FormLabel>
<FormControl>
<Input placeholder="22" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="root" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
<DialogFooter>
<Button
isLoading={form.formState.isSubmitting}
form="hook-form-add-server"
type="submit"
>
Create
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,172 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
import { RocketIcon, ServerIcon } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { ShowDeployment } from "../../application/deployments/show-deployment";
import { DialogAction } from "@/components/shared/dialog-action";
interface Props {
serverId: string;
}
export const SetupServer = ({ serverId }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const url = useUrl();
const { data: server } = api.server.one.useQuery(
{
serverId,
},
{
enabled: !!serverId,
},
);
const { mutateAsync } = api.server.setup.useMutation();
const [activeLog, setActiveLog] = useState<string | null>(null);
const { data: deployments, refetch } = api.deployment.allByServer.useQuery(
{ serverId },
{
enabled: !!serverId,
refetchInterval: 1000,
},
);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
>
Setup Server
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-4xl overflow-y-auto max-h-screen ">
<DialogHeader>
<div className="flex flex-col gap-1.5">
<DialogTitle className="flex items-center gap-2">
<ServerIcon className="size-5" /> Setup Server
</DialogTitle>
<p className="text-muted-foreground text-sm">
To setup a server, please click on the button below.
</p>
</div>
</DialogHeader>
<div id="hook-form-add-gitlab" className="grid w-full gap-1">
<CardContent className="p-0">
<div className="flex flex-col gap-4">
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
<div className="flex flex-row gap-2 justify-between w-full items-end max-sm:flex-col">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Deployments</CardTitle>
<CardDescription>
See all the 5 Server Setup
</CardDescription>
</div>
<DialogAction
title={"Setup Server?"}
description="This will setup the server and all associated data"
onClick={async () => {
await mutateAsync({
serverId: server?.serverId || "",
})
.then(async () => {
refetch();
toast.success("Server setup successfully");
})
.catch(() => {
toast.error("Error configuring server");
});
}}
>
<Button>Setup Server</Button>
</DialogAction>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{server?.deployments?.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
<RocketIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
No deployments found
</span>
</div>
) : (
<div className="flex flex-col gap-4">
{deployments?.map((deployment) => (
<div
key={deployment.deploymentId}
className="flex items-center justify-between rounded-lg border p-4 gap-2"
>
<div className="flex flex-col">
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
{deployment.status}
<StatusTooltip
status={deployment?.status}
className="size-2.5"
/>
</span>
<span className="text-sm text-muted-foreground">
{deployment.title}
</span>
{deployment.description && (
<span className="break-all text-sm text-muted-foreground">
{deployment.description}
</span>
)}
</div>
<div className="flex flex-col items-end gap-2">
<div className="text-sm capitalize text-muted-foreground">
<DateTooltip date={deployment.createdAt} />
</div>
<Button
onClick={() => {
setActiveLog(deployment.logPath);
}}
>
View
</Button>
</div>
</div>
))}
</div>
)}
<ShowDeployment
open={activeLog !== null}
onClose={() => setActiveLog(null)}
logPath={activeLog}
/>
</CardContent>
</Card>
</div>
</CardContent>
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,163 @@
import { api } from "@/utils/api";
import { format } from "date-fns";
import { AddServer } from "./add-server";
import { KeyIcon, MoreHorizontal, ServerIcon } from "lucide-react";
import Link from "next/link";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { SetupServer } from "./setup-server";
import { TerminalModal } from "../web-server/terminal-modal";
export const ShowServers = () => {
const { data, refetch } = api.server.all.useQuery();
const { mutateAsync } = api.server.remove.useMutation();
const { data: sshKeys } = api.sshKey.all.useQuery();
return (
<div className="p-6 space-y-6">
<div className="space-y-2 flex flex-row justify-between items-end">
<div>
<h1 className="text-2xl font-bold">Servers</h1>
<p className="text-muted-foreground">
Add servers to deploy your applications remotely.
</p>
</div>
{sshKeys && sshKeys?.length > 0 && (
<div>
<AddServer />
</div>
)}
</div>
<div className="grid gap-4 sm:grid-cols-1 md:grid-cols-1">
{sshKeys?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<KeyIcon className="size-8" />
<span className="text-base text-muted-foreground">
No SSH Keys found. Add a SSH Key to start adding servers.{" "}
<Link
href="/dashboard/settings/ssh-keys"
className="text-primary"
>
Add SSH Key
</Link>
</span>
</div>
) : (
data &&
data.length === 0 && (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<ServerIcon className="size-8" />
<span className="text-base text-muted-foreground">
No Servers found. Add a server to deploy your applications
remotely.
</span>
</div>
)
)}
{data && data?.length > 0 && (
<div className="flex flex-col gap-6">
<Table>
<TableCaption>See all servers</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Name</TableHead>
<TableHead className="text-center">IP Address</TableHead>
<TableHead className="text-center">Port</TableHead>
<TableHead className="text-center">Username</TableHead>
<TableHead className="text-center">Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.map((server) => {
return (
<TableRow key={server.serverId}>
<TableCell className="w-[100px]">{server.name}</TableCell>
<TableCell className="text-center">
<Badge>{server.ipAddress}</Badge>
</TableCell>
<TableCell className="text-center">
{server.port}
</TableCell>
<TableCell className="text-center">
{server.username}
</TableCell>
<TableCell className="text-right">
<span className="text-sm text-muted-foreground">
{format(new Date(server.createdAt), "PPpp")}
</span>
</TableCell>
<TableCell className="text-right flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<TerminalModal serverId={server.serverId}>
<span>Enter the terminal</span>
</TerminalModal>
<SetupServer serverId={server.serverId} />
<DialogAction
title={"Delete Server"}
description="This will delete the server and all associated data"
onClick={async () => {
await mutateAsync({
serverId: server.serverId,
})
.then(() => {
refetch();
toast.success(
`Server ${server.name} deleted succesfully`,
);
})
.catch((err) => {
toast.error(err.message);
});
}}
>
<DropdownMenuItem
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
onSelect={(e) => e.preventDefault()}
>
Delete Server
</DropdownMenuItem>
</DialogAction>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</div>
</div>
);
};

View File

@ -1,4 +1,3 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@ -8,79 +7,27 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import dynamic from "next/dynamic";
import type React from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { RemoveSSHPrivateKey } from "./remove-ssh-private-key";
const Terminal = dynamic(() => import("./terminal").then((e) => e.Terminal), {
ssr: false,
});
const addSSHPrivateKey = z.object({
sshPrivateKey: z
.string({
required_error: "SSH private key is required",
})
.min(1, "SSH private key is required"),
});
type AddSSHPrivateKey = z.infer<typeof addSSHPrivateKey>;
interface Props {
children?: React.ReactNode;
serverId: string;
}
export const TerminalModal = ({ children }: Props) => {
const { data, refetch } = api.admin.one.useQuery();
const [user, setUser] = useState("root");
const [terminalUser, setTerminalUser] = useState("root");
const { mutateAsync, isLoading } =
api.settings.saveSSHPrivateKey.useMutation();
const form = useForm<AddSSHPrivateKey>({
defaultValues: {
sshPrivateKey: "",
export const TerminalModal = ({ children, serverId }: Props) => {
const { data } = api.server.one.useQuery(
{
serverId,
},
resolver: zodResolver(addSSHPrivateKey),
});
{ enabled: !!serverId },
);
useEffect(() => {
if (data) {
form.reset({});
}
}, [data, form, form.reset]);
const onSubmit = async (formData: AddSSHPrivateKey) => {
await mutateAsync({
sshPrivateKey: formData.sshPrivateKey,
})
.then(async () => {
toast.success("SSH Key Updated");
await refetch();
})
.catch(() => {
toast.error("Error to Update the ssh key");
});
};
return (
<Dialog>
<DialogTrigger asChild>
@ -92,75 +39,14 @@ export const TerminalModal = ({ children }: Props) => {
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-7xl">
<DialogHeader className="flex flex-row justify-between pt-4">
<div>
<DialogTitle>Terminal</DialogTitle>
<DialogHeader className="flex flex-col gap-1">
<DialogTitle>Terminal ({data?.name})</DialogTitle>
<DialogDescription>Easy way to access the server</DialogDescription>
</div>
{data?.haveSSH && (
<div>
<RemoveSSHPrivateKey />
</div>
)}
</DialogHeader>
{!data?.haveSSH ? (
<div>
<div className="flex flex-col gap-4">
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-8 "
>
<div className="grid w-full">
<FormField
control={form.control}
name="sshPrivateKey"
render={({ field }) => {
return (
<FormItem>
<FormLabel>SSH Private Key</FormLabel>
<FormDescription>
In order to access the server you need to add an
ssh private key
</FormDescription>
<FormControl>
<Textarea
placeholder={
"-----BEGIN CERTIFICATE-----\nMIIFRDCCAyygAwIBAgIUEPOR47ys6VDwMVB9tYoeEka83uQwDQYJKoZIhvcNAQELBQAwGTEXMBUGA1UEAwwObWktZG9taW5pby5jb20wHhcNMjQwMzExMDQyNzU3WhcN\n------END CERTIFICATE-----"
}
className="h-32"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
Save
</Button>
</div>
</form>
</Form>
</div>
</div>
) : (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label>Log in as</Label>
<div className="flex flex-row gap-4">
<Input value={user} onChange={(e) => setUser(e.target.value)} />
<Button onClick={() => setTerminalUser(user)}>Login</Button>
</div>
</div>
<Terminal id="terminal" userSSH={terminalUser} />
<div className="flex flex-col gap-4">
<Terminal id="terminal" serverId={serverId} />
</div>
)}
</DialogContent>
</Dialog>
);

View File

@ -7,10 +7,10 @@ import { AttachAddon } from "@xterm/addon-attach";
interface Props {
id: string;
userSSH?: string;
serverId: string;
}
export const Terminal: React.FC<Props> = ({ id, userSSH = "root" }) => {
export const Terminal: React.FC<Props> = ({ id, serverId }) => {
const termRef = useRef(null);
useEffect(() => {
@ -33,7 +33,7 @@ export const Terminal: React.FC<Props> = ({ id, userSSH = "root" }) => {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/terminal?userSSH=${userSSH}`;
const wsUrl = `${protocol}//${window.location.host}/terminal?serverId=${serverId}`;
const ws = new WebSocket(wsUrl);
const addonAttach = new AttachAddon(ws);
@ -46,7 +46,7 @@ export const Terminal: React.FC<Props> = ({ id, userSSH = "root" }) => {
return () => {
ws.readyState === WebSocket.OPEN && ws.close();
};
}, [id, userSSH]);
}, [id, serverId]);
return (
<div className="flex flex-col gap-4">

View File

@ -74,7 +74,7 @@ export const SettingsLayout = ({ children }: Props) => {
{
title: "Cluster",
label: "",
icon: Server,
icon: BoxesIcon,
href: "/dashboard/settings/cluster",
},
{
@ -83,6 +83,12 @@ export const SettingsLayout = ({ children }: Props) => {
icon: Bell,
href: "/dashboard/settings/notifications",
},
{
title: "Servers",
label: "",
icon: Server,
href: "/dashboard/settings/servers",
},
]
: []),
...(user?.canAccessToSSHKeys
@ -117,6 +123,7 @@ export const SettingsLayout = ({ children }: Props) => {
import {
Activity,
Bell,
BoxesIcon,
Database,
GitBranch,
KeyIcon,

View File

@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS "server" (
"serverId" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"description" text,
"ipAddress" text NOT NULL,
"port" integer NOT NULL,
"username" text DEFAULT 'root' NOT NULL,
"appName" text,
"createdAt" text NOT NULL,
"adminId" text NOT NULL,
CONSTRAINT "server_appName_unique" UNIQUE("appName")
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "server" ADD CONSTRAINT "server_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 $$;

View File

@ -0,0 +1,6 @@
ALTER TABLE "deployment" ADD COLUMN "serverId" text;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "deployment" ADD CONSTRAINT "deployment_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 $$;

View File

@ -0,0 +1,8 @@
ALTER TABLE "server" DROP CONSTRAINT "server_appName_unique";--> statement-breakpoint
ALTER TABLE "server" ALTER COLUMN "appName" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "server" ADD COLUMN "sshKeyId" text;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "server" ADD CONSTRAINT "server_sshKeyId_ssh-key_sshKeyId_fk" FOREIGN KEY ("sshKeyId") REFERENCES "public"."ssh-key"("sshKeyId") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -260,6 +260,27 @@
"when": 1725519351871,
"tag": "0036_tired_ronan",
"breakpoints": true
},
{
"idx": 37,
"version": "6",
"when": 1725773488051,
"tag": "0037_small_adam_warlock",
"breakpoints": true
},
{
"idx": 38,
"version": "6",
"when": 1725773967628,
"tag": "0038_thankful_magneto",
"breakpoints": true
},
{
"idx": 39,
"version": "6",
"when": 1725776089878,
"tag": "0039_military_doctor_faustus",
"breakpoints": true
}
]
}

View File

@ -130,7 +130,8 @@
"zod": "^3.23.4",
"zod-form-data": "^2.0.2",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-controllable-state": "1.1.0"
"@radix-ui/react-use-controllable-state": "1.1.0",
"ssh2": "1.15.0"
},
"devDependencies": {
"@biomejs/biome": "1.8.3",
@ -167,7 +168,8 @@
"typescript": "^5.4.2",
"vite-tsconfig-paths": "4.3.2",
"vitest": "^1.6.0",
"xterm-readline": "1.1.1"
"xterm-readline": "1.1.1",
"@types/ssh2": "1.15.1"
},
"ct3aMetadata": {
"initVersion": "7.25.2"

View File

@ -0,0 +1,49 @@
import { ShowServers } from "@/components/dashboard/settings/servers/show-servers";
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">
<ShowServers />
</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 } = await validateRequest(ctx.req, ctx.res);
if (!user) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
if (user.rol === "user") {
return {
redirect: {
permanent: true,
destination: "/dashboard/settings/profile",
},
};
}
return {
props: {},
};
}

View File

@ -69,8 +69,8 @@ export default function Home({ hasAdmin }: Props) {
const router = useRouter();
const form = useForm<Login>({
defaultValues: {
email: "",
password: "",
email: "user1@hotmail.com",
password: "Password123",
},
resolver: zodResolver(loginSchema),
});

View File

@ -16,6 +16,7 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { isAdminPresent } from "@/server/api/services/admin";
import { IS_CLOUD } from "@/server/constants";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle } from "lucide-react";
@ -220,6 +221,11 @@ const Register = ({ hasAdmin }: Props) => {
export default Register;
export async function getServerSideProps() {
if (IS_CLOUD) {
return {
props: {},
};
}
const hasAdmin = await isAdminPresent();
if (hasAdmin) {

View File

@ -29,6 +29,7 @@ import { securityRouter } from "./routers/security";
import { settingsRouter } from "./routers/settings";
import { sshRouter } from "./routers/ssh-key";
import { userRouter } from "./routers/user";
import { serverRouter } from "./routers/server";
/**
* This is the primary router for your server.
@ -66,6 +67,7 @@ export const appRouter = createTRPCRouter({
bitbucket: bitbucketRouter,
gitlab: gitlabRouter,
github: githubRouter,
server: serverRouter,
});
// export type definition of API

View File

@ -29,20 +29,23 @@ import {
protectedProcedure,
publicProcedure,
} from "../trpc";
import { IS_CLOUD } from "@/server/constants";
export const authRouter = createTRPCRouter({
createAdmin: publicProcedure
.input(apiCreateAdmin)
.mutation(async ({ ctx, input }) => {
try {
if (!IS_CLOUD) {
const admin = await db.query.admins.findFirst({});
if (admin) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Admin already exists",
});
}
}
const newAdmin = await createAdmin(input);
const session = await lucia.createSession(newAdmin.id || "", {});
ctx.res.appendHeader(
@ -51,6 +54,7 @@ export const authRouter = createTRPCRouter({
);
return true;
} catch (error) {
console.log(error);
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error to create the main admin",

View File

@ -1,10 +1,12 @@
import {
apiFindAllByApplication,
apiFindAllByCompose,
apiFindAllByServer,
} from "@/server/db/schema";
import {
findAllDeploymentsByApplicationId,
findAllDeploymentsByComposeId,
findAllDeploymentsByServerId,
} from "../services/deployment";
import { createTRPCRouter, protectedProcedure } from "../trpc";
@ -20,4 +22,9 @@ export const deploymentRouter = createTRPCRouter({
.query(async ({ input }) => {
return await findAllDeploymentsByComposeId(input.composeId);
}),
allByServer: protectedProcedure
.input(apiFindAllByServer)
.query(async ({ input }) => {
return await findAllDeploymentsByServerId(input.serverId);
}),
});

View File

@ -34,6 +34,7 @@ import {
checkProjectAccess,
findUserByAuthId,
} from "../services/user";
import { findAdmin, findAdminByAuthId } from "../services/admin";
export const projectRouter = createTRPCRouter({
create: protectedProcedure
@ -43,7 +44,8 @@ export const projectRouter = createTRPCRouter({
if (ctx.user.rol === "user") {
await checkProjectAccess(ctx.user.authId, "create");
}
const project = await createProject(input);
const project = await createProject(input, ctx.user.adminId);
if (ctx.user.rol === "user") {
await addNewProject(ctx.user.authId, project.projectId);
}
@ -153,6 +155,7 @@ export const projectRouter = createTRPCRouter({
return query;
}
return await db.query.projects.findMany({
with: {
applications: true,
@ -163,6 +166,7 @@ export const projectRouter = createTRPCRouter({
redis: true,
compose: true,
},
where: eq(projects.adminId, ctx.user.adminId),
orderBy: desc(projects.createdAt),
});
}),

View File

@ -0,0 +1,97 @@
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { db } from "@/server/db";
import {
apiCreateServer,
apiFindOneServer,
apiRemoveProject,
apiRemoveServer,
apiUpdateServer,
server,
} from "@/server/db/schema";
import { TRPCError } from "@trpc/server";
import { desc } from "drizzle-orm";
import {
deleteServer,
findServerById,
updateServerById,
createServer,
} from "../services/server";
import { setupServer } from "@/server/utils/servers/setup-server";
import { removeDeploymentsByServerId } from "../services/deployment";
export const serverRouter = createTRPCRouter({
create: protectedProcedure
.input(apiCreateServer)
.mutation(async ({ ctx, input }) => {
try {
const project = await createServer(input, ctx.user.adminId);
return project;
} catch (error) {
console.log(error);
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error to create the server",
cause: error,
});
}
}),
one: protectedProcedure
.input(apiFindOneServer)
.query(async ({ input, ctx }) => {
return await findServerById(input.serverId);
}),
all: protectedProcedure.query(async ({ ctx }) => {
return await db.query.server.findMany({
orderBy: desc(server.createdAt),
});
}),
setup: protectedProcedure
.input(apiFindOneServer)
.mutation(async ({ input, ctx }) => {
try {
const currentServer = await setupServer(input.serverId);
return currentServer;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error to setup this server",
cause: error,
});
}
}),
remove: protectedProcedure
.input(apiRemoveServer)
.mutation(async ({ input, ctx }) => {
try {
const currentServer = await findServerById(input.serverId);
await removeDeploymentsByServerId(currentServer);
await deleteServer(input.serverId);
return currentServer;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error to delete this server",
cause: error,
});
}
}),
update: protectedProcedure
.input(apiUpdateServer)
.mutation(async ({ input }) => {
try {
const currentServer = await updateServerById(input.serverId, {
...input,
});
return currentServer;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error to update this server",
cause: error,
});
}
}),
});

View File

@ -5,6 +5,7 @@ import { db } from "@/server/db";
import {
type apiCreateDeployment,
type apiCreateDeploymentCompose,
type apiCreateDeploymentServer,
deployments,
} from "@/server/db/schema";
import { removeDirectoryIfExistsContent } from "@/server/utils/filesystem/directory";
@ -13,6 +14,7 @@ import { format } from "date-fns";
import { desc, eq } from "drizzle-orm";
import { type Application, findApplicationById } from "./application";
import { type Compose, findComposeById } from "./compose";
import { findServerById, type Server } from "./server";
export type Deployment = typeof deployments.$inferSelect;
@ -240,3 +242,81 @@ export const updateDeploymentStatus = async (
return application;
};
export const createServerDeployment = async (
deployment: Omit<
typeof apiCreateDeploymentServer._type,
"deploymentId" | "createdAt" | "status" | "logPath"
>,
) => {
try {
const server = await findServerById(deployment.serverId);
await removeLastFiveDeployments(deployment.serverId);
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
const fileName = `${server.appName}-${formattedDateTime}.log`;
const logFilePath = path.join(LOGS_PATH, server.appName, fileName);
await fsPromises.mkdir(path.join(LOGS_PATH, server.appName), {
recursive: true,
});
await fsPromises.writeFile(logFilePath, "Initializing Setup Server");
const deploymentCreate = await db
.insert(deployments)
.values({
serverId: server.serverId,
title: deployment.title || "Deployment",
description: deployment.description || "",
status: "running",
logPath: logFilePath,
})
.returning();
if (deploymentCreate.length === 0 || !deploymentCreate[0]) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error to create the deployment",
});
}
return deploymentCreate[0];
} catch (error) {
console.log(error);
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error to create the deployment",
});
}
};
export const removeLastFiveDeployments = async (serverId: string) => {
const deploymentList = await db.query.deployments.findMany({
where: eq(deployments.serverId, serverId),
orderBy: desc(deployments.createdAt),
});
if (deploymentList.length >= 5) {
const deploymentsToDelete = deploymentList.slice(4);
for (const oldDeployment of deploymentsToDelete) {
const logPath = path.join(oldDeployment.logPath);
if (existsSync(logPath)) {
await fsPromises.unlink(logPath);
}
await removeDeployment(oldDeployment.deploymentId);
}
}
};
export const removeDeploymentsByServerId = async (server: Server) => {
const { appName } = server;
const logsPath = path.join(LOGS_PATH, appName);
await removeDirectoryIfExistsContent(logsPath);
await db
.delete(deployments)
.where(eq(deployments.serverId, server.serverId))
.returning();
};
export const findAllDeploymentsByServerId = async (serverId: string) => {
const deploymentsList = await db.query.deployments.findMany({
where: eq(deployments.serverId, serverId),
orderBy: desc(deployments.createdAt),
});
return deploymentsList;
};

View File

@ -11,17 +11,18 @@ import {
} from "@/server/db/schema";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { findAdmin } from "./admin";
export type Project = typeof projects.$inferSelect;
export const createProject = async (input: typeof apiCreateProject._type) => {
const admin = await findAdmin();
export const createProject = async (
input: typeof apiCreateProject._type,
adminId: string,
) => {
const newProject = await db
.insert(projects)
.values({
...input,
adminId: admin.adminId,
adminId: adminId,
})
.returning()
.then((value) => value[0]);

View File

@ -0,0 +1,72 @@
import { db } from "@/server/db";
import { type apiCreateServer, server } from "@/server/db/schema";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
export type Server = typeof server.$inferSelect;
export const createServer = async (
input: typeof apiCreateServer._type,
adminId: string,
) => {
const newServer = await db
.insert(server)
.values({
...input,
adminId: adminId,
})
.returning()
.then((value) => value[0]);
if (!newServer) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error to create the server",
});
}
return newServer;
};
export const findServerById = async (serverId: string) => {
const currentServer = await db.query.server.findFirst({
where: eq(server.serverId, serverId),
with: {
deployments: true,
sshKey: true,
},
});
if (!currentServer) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Server not found",
});
}
return currentServer;
};
export const deleteServer = async (serverId: string) => {
const currentServer = await db
.delete(server)
.where(eq(server.serverId, serverId))
.returning()
.then((value) => value[0]);
return currentServer;
};
export const updateServerById = async (
serverId: string,
serverData: Partial<Server>,
) => {
const result = await db
.update(server)
.set({
...serverData,
})
.where(eq(server.serverId, serverId))
.returning()
.then((res) => res[0]);
return result;
};

View File

@ -22,6 +22,8 @@ import superjson from "superjson";
import { ZodError } from "zod";
import { validateRequest } from "../auth/auth";
import { validateBearerToken } from "../auth/token";
import { findAdminByAuthId } from "./services/admin";
import { findUserByAuthId } from "./services/user";
/**
* 1. CONTEXT
@ -32,7 +34,7 @@ import { validateBearerToken } from "../auth/token";
*/
interface CreateContextOptions {
user: (User & { authId: string }) | null;
user: (User & { authId: string; adminId: string }) | null;
session: Session | null;
req: CreateNextContextOptions["req"];
res: CreateNextContextOptions["res"];
@ -86,6 +88,7 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => {
rol: user.rol,
id: user.id,
secret: user.secret,
adminId: user.adminId,
},
}) || {
user: null,

View File

@ -6,6 +6,8 @@ import { Lucia } from "lucia/dist/core.js";
import type { Session, User } from "lucia/dist/core.js";
import { db } from "../db";
import { type DatabaseUser, auth, sessionTable } from "../db/schema";
import { findUserByAuthId } from "../api/services/user";
import { findAdminByAuthId } from "../api/services/admin";
globalThis.crypto = webcrypto as Crypto;
export const adapter = new DrizzlePostgreSQLAdapter(db, sessionTable, auth);
@ -22,6 +24,7 @@ export const lucia = new Lucia(adapter, {
email: attributes.email,
rol: attributes.rol,
secret: attributes.secret !== null,
adminId: attributes.adminId,
};
},
});
@ -29,12 +32,15 @@ export const lucia = new Lucia(adapter, {
declare module "lucia" {
interface Register {
Lucia: typeof lucia;
DatabaseUserAttributes: Omit<DatabaseUser, "id"> & { authId: string };
DatabaseUserAttributes: Omit<DatabaseUser, "id"> & {
authId: string;
adminId: string;
};
}
}
export type ReturnValidateToken = Promise<{
user: (User & { authId: string }) | null;
user: (User & { authId: string; adminId: string }) | null;
session: Session | null;
}>;
@ -63,6 +69,17 @@ export async function validateRequest(
lucia.createBlankSessionCookie().serialize(),
);
}
if (result.user) {
if (result.user?.rol === "admin") {
const admin = await findAdminByAuthId(result.user.id);
result.user.adminId = admin.adminId;
} else if (result.user?.rol === "user") {
const userResult = await findUserByAuthId(result.user.id);
result.user.adminId = userResult.adminId;
}
}
return {
session: result.session,
...((result.user && {
@ -72,6 +89,7 @@ export async function validateRequest(
rol: result.user.rol,
id: result.user.id,
secret: result.user.secret,
adminId: result.user.adminId,
},
}) || {
user: null,

View File

@ -5,6 +5,7 @@ export const BASE_PATH =
process.env.NODE_ENV === "production"
? "/etc/dokploy"
: path.join(process.cwd(), ".docker");
export const IS_CLOUD = process.env.IS_CLOUD === "true";
export const MAIN_TRAEFIK_PATH = `${BASE_PATH}/traefik`;
export const DYNAMIC_TRAEFIK_PATH = `${BASE_PATH}/traefik/dynamic`;
export const LOGS_PATH = `${BASE_PATH}/logs`;
@ -15,3 +16,27 @@ export const CERTIFICATES_PATH = `${DYNAMIC_TRAEFIK_PATH}/certificates`;
export const REGISTRY_PATH = `${DYNAMIC_TRAEFIK_PATH}/registry`;
export const MONITORING_PATH = `${BASE_PATH}/monitoring`;
export const docker = new Docker();
export const getPaths = (basePath: string) => {
// return [
// MAIN_TRAEFIK_PATH: `${basePath}/traefik`,
// DYNAMIC_TRAEFIK_PATH: `${basePath}/traefik/dynamic`,
// LOGS_PATH: `${basePath}/logs`,
// APPLICATIONS_PATH: `${basePath}/applications`,
// COMPOSE_PATH: `${basePath}/compose`,
// SSH_PATH: `${basePath}/ssh`,
// CERTIFICATES_PATH: `${basePath}/certificates`,
// MONITORING_PATH: `${basePath}/monitoring`,
// ];
return [
`${basePath}/traefik`,
`${basePath}/traefik/dynamic`,
`${basePath}/logs`,
`${basePath}/applications`,
`${basePath}/compose`,
`${basePath}/ssh`,
`${basePath}/certificates`,
`${basePath}/monitoring`,
];
};

View File

@ -5,6 +5,7 @@ import { nanoid } from "nanoid";
import { z } from "zod";
import { applications } from "./application";
import { compose } from "./compose";
import { server } from "./server";
export const deploymentStatus = pgEnum("deploymentStatus", [
"running",
@ -28,6 +29,9 @@ export const deployments = pgTable("deployment", {
composeId: text("composeId").references(() => compose.composeId, {
onDelete: "cascade",
}),
serverId: text("serverId").references(() => server.serverId, {
onDelete: "cascade",
}),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
@ -42,6 +46,10 @@ export const deploymentsRelations = relations(deployments, ({ one }) => ({
fields: [deployments.composeId],
references: [compose.composeId],
}),
server: one(server, {
fields: [deployments.serverId],
references: [server.serverId],
}),
}));
const schema = createInsertSchema(deployments, {
@ -77,6 +85,18 @@ export const apiCreateDeploymentCompose = schema
composeId: z.string().min(1),
});
export const apiCreateDeploymentServer = schema
.pick({
title: true,
status: true,
logPath: true,
serverId: true,
description: true,
})
.extend({
serverId: z.string().min(1),
});
export const apiFindAllByApplication = schema
.pick({
applicationId: true,
@ -94,3 +114,12 @@ export const apiFindAllByCompose = schema
composeId: z.string().min(1),
})
.required();
export const apiFindAllByServer = schema
.pick({
serverId: true,
})
.extend({
serverId: z.string().min(1),
})
.required();

View File

@ -27,3 +27,4 @@ export * from "./git-provider";
export * from "./bitbucket";
export * from "./github";
export * from "./gitlab";
export * from "./server";

View File

@ -0,0 +1,82 @@
import { relations } from "drizzle-orm";
import { integer, 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 { generateAppName } from "./utils";
import { deployments } from "./deployment";
import { sshKeys } from "./ssh-key";
export const server = pgTable("server", {
serverId: text("serverId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
name: text("name").notNull(),
description: text("description"),
ipAddress: text("ipAddress").notNull(),
port: integer("port").notNull(),
username: text("username").notNull().default("root"),
appName: text("appName")
.notNull()
.$defaultFn(() => generateAppName("server")),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
adminId: text("adminId")
.notNull()
.references(() => admins.adminId, { onDelete: "cascade" }),
sshKeyId: text("sshKeyId").references(() => sshKeys.sshKeyId, {
onDelete: "set null",
}),
});
export const serverRelations = relations(server, ({ one, many }) => ({
admin: one(admins, {
fields: [server.adminId],
references: [admins.adminId],
}),
deployments: many(deployments),
sshKey: one(sshKeys, {
fields: [server.sshKeyId],
references: [sshKeys.sshKeyId],
}),
}));
const createSchema = createInsertSchema(server, {
serverId: z.string().min(1),
name: z.string().min(1),
description: z.string().optional(),
});
export const apiCreateServer = createSchema
.pick({
name: true,
description: true,
ipAddress: true,
port: true,
username: true,
sshKeyId: true,
})
.required();
export const apiFindOneServer = createSchema
.pick({
serverId: true,
})
.required();
export const apiRemoveServer = createSchema
.pick({
serverId: true,
})
.required();
export const apiUpdateServer = createSchema
.pick({
name: true,
description: true,
serverId: true,
})
.required();

View File

@ -2,9 +2,10 @@ 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 { pgTable, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { server } from "./server";
export const sshKeys = pgTable("ssh-key", {
sshKeyId: text("sshKeyId")
@ -23,6 +24,7 @@ export const sshKeys = pgTable("ssh-key", {
export const sshKeysRelations = relations(sshKeys, ({ many }) => ({
applications: many(applications),
compose: many(compose),
servers: many(server),
}));
const createSchema = createInsertSchema(

View File

@ -1,8 +1,10 @@
import { type ConnectionOptions, Queue } from "bullmq";
export const redisConfig: ConnectionOptions = {
host: process.env.NODE_ENV === "production" ? "dokploy-redis" : "127.0.0.1",
port: 6379,
host: "31.220.108.27",
password: "xYBugfHkULig1iLN",
// host: process.env.NODE_ENV === "production" ? "dokploy-redis" : "127.0.0.1",
port: 1233,
};
// TODO: maybe add a options to clean the queue to the times
const myQueue = new Queue("deployments", {

View File

@ -3,7 +3,7 @@ import * as path from "node:path";
import { SSH_PATH } from "@/server/constants";
import { spawnAsync } from "../process/spawnAsync";
const readSSHKey = async (id: string) => {
export const readSSHKey = async (id: string) => {
try {
if (!fs.existsSync(SSH_PATH)) {
fs.mkdirSync(SSH_PATH, { recursive: true });

View File

@ -0,0 +1,235 @@
import { findServerById } from "@/server/api/services/server";
import { recreateDirectory } from "../filesystem/directory";
import { slugify } from "@/lib/slug";
import path from "node:path";
import {
APPLICATIONS_PATH,
BASE_PATH,
CERTIFICATES_PATH,
DYNAMIC_TRAEFIK_PATH,
getPaths,
LOGS_PATH,
MAIN_TRAEFIK_PATH,
MONITORING_PATH,
SSH_PATH,
} from "@/server/constants";
import {
createServerDeployment,
updateDeploymentStatus,
} from "@/server/api/services/deployment";
import { chmodSync, createWriteStream } from "node:fs";
import { Client } from "ssh2";
import { readSSHKey } from "../filesystem/ssh";
export const setupServer = async (serverId: string) => {
const server = await findServerById(serverId);
const slugifyName = slugify(`server ${server.name}`);
const fullPath = path.join(LOGS_PATH, slugifyName);
await recreateDirectory(fullPath);
const deployment = await createServerDeployment({
serverId: server.serverId,
title: "Setup Server",
description: "Setup Server",
});
const writeStream = createWriteStream(deployment.logPath, { flags: "a" });
try {
writeStream.write("\nInstalling Server Dependencies: ✅\n");
await connectToServer(serverId, deployment.logPath);
writeStream.close();
await updateDeploymentStatus(deployment.deploymentId, "done");
} catch (err) {
console.log(err);
await updateDeploymentStatus(deployment.deploymentId, "error");
writeStream.write(err);
writeStream.close();
}
};
const setupTraefikInstance = async (serverId: string) => {};
const connectToServer = async (serverId: string, logPath: string) => {
const writeStream = createWriteStream(logPath, { flags: "a" });
const client = new Client();
const server = await findServerById(serverId);
if (!server.sshKeyId) return;
const keys = await readSSHKey(server.sshKeyId);
return new Promise<void>((resolve, reject) => {
client
.on("ready", () => {
console.log("Client :: ready");
const bashCommand = `
# check if something is running on port 80
if ss -tulnp | grep ':80 ' >/dev/null; then
echo "Error: something is already running on port 80" >&2
exit 1
fi
# check if something is running on port 443
if ss -tulnp | grep ':443 ' >/dev/null; then
echo "Error: something is already running on port 443" >&2
exit 1
fi
command_exists() {
command -v "$@" > /dev/null 2>&1
}
if command_exists docker; then
echo "Docker already installed ✅"
else
echo "Installing Docker ✅"
curl -sSL https://get.docker.com | sh -s -- --version 27.2.0
fi
# Check if the node is already part of a Docker Swarm
if docker info | grep -q 'Swarm: active'; then
echo "Already part of a Docker Swarm ✅"
else
# Get IP address
get_ip() {
# Try to get IPv4
local ipv4=\$(curl -4s https://ifconfig.io 2>/dev/null)
if [ -n "\$ipv4" ]; then
echo "\$ipv4"
else
# Try to get IPv6
local ipv6=\$(curl -6s https://ifconfig.io 2>/dev/null)
if [ -n "\$ipv6" ]; then
echo "\$ipv6"
fi
fi
}
advertise_addr=\$(get_ip)
# Initialize Docker Swarm
docker swarm init --advertise-addr \$advertise_addr
echo "Swarm initialized ✅"
fi
# Check if the dokploy-network already exists
if docker network ls | grep -q 'dokploy-network'; then
echo "Network dokploy-network already exists ✅"
else
# Create the dokploy-network if it doesn't exist
docker network create --driver overlay --attachable dokploy-network
echo "Network created ✅"
fi
# Check if the /etc/dokploy directory exists
if [ -d /etc/dokploy ]; then
echo "/etc/dokploy already exists ✅"
else
# Create the /etc/dokploy directory
mkdir -p /etc/dokploy
chmod 777 /etc/dokploy
echo "Directory /etc/dokploy created ✅"
fi
${setupDirectories()}
`;
client.exec(bashCommand, (err, stream) => {
if (err) {
writeStream.write(err);
reject(err);
return;
}
stream
.on("close", () => {
writeStream.write("Connection closed ✅");
client.end();
resolve();
})
.on("data", (data) => {
writeStream.write(data.toString());
console.log(`OUTPUT: ${data}`);
})
.stderr.on("data", (data) => {
writeStream.write(data.toString());
console.log(`STDERR: ${data}`);
});
});
})
.connect({
host: server.ipAddress,
port: server.port,
username: server.username,
privateKey: keys.privateKey,
timeout: 10000,
});
});
};
const setupDirectories = () => {
// const directories = [
// BASE_PATH,
// MAIN_TRAEFIK_PATH,
// DYNAMIC_TRAEFIK_PATH,
// LOGS_PATH,
// APPLICATIONS_PATH,
// SSH_PATH,
// CERTIFICATES_PATH,
// MONITORING_PATH,
// ];
const directories = getPaths("/etc/dokploy");
const createDirsCommand = directories
.map((dir) => `mkdir -p "${dir}"`)
.join(" && ");
const chmodCommand = `chmod 700 "${SSH_PATH}"`;
const command = `
${createDirsCommand}
${chmodCommand}
`;
console.log(command);
return command;
};
export const setupSwarm = async () => {
const command = `
# Check if the node is already part of a Docker Swarm
if docker info | grep -q 'Swarm: active'; then
echo "Already part of a Docker Swarm ✅"
else
# Get IP address
get_ip() {
# Try to get IPv4
local ipv4=\$(curl -4s https://ifconfig.io 2>/dev/null)
if [ -n "\$ipv4" ]; then
echo "\$ipv4"
else
# Try to get IPv6
local ipv6=\$(curl -6s https://ifconfig.io 2>/dev/null)
if [ -n "\$ipv6" ]; then
echo "\$ipv6"
fi
fi
}
advertise_addr=\$(get_ip)
# Initialize Docker Swarm
docker swarm init --advertise-addr \$advertise_addr
echo "Swarm initialized ✅"
fi
`;
console.log(command);
return command;
};
// mkdir -p "/Users/mauricio/Documents/Github/Personal/dokploy/apps/dokploy/.docker" && mkdir -p "/Users/mauricio/Documents/Github/Personal/dokploy/apps/dokploy/.docker/traefik" && mkdir -p "/Users/mauricio/Documents/Github/Personal/dokploy/apps/dokploy/.docker/traefik/dynamic" && mkdir -p "/Users/mauricio/Documents/Github/Personal/dokploy/apps/dokploy/.docker/logs" && mkdir -p "/Users/mauricio/Documents/Github/Personal/dokploy/apps/dokploy/.docker/applications" && mkdir -p "/Users/mauricio/Documents/Github/Personal/dokploy/apps/dokploy/.docker/ssh" && mkdir -p "/Users/mauricio/Documents/Github/Personal/dokploy/apps/dokploy/.docker/traefik/dynamic/certificates" && mkdir -p "/Users/mauricio/Documents/Github/Personal/dokploy/apps/dokploy/.docker/monitoring"
// chmod 700 "/Users/mauricio/Documents/Github/Personal/dokploy/apps/dokploy/.docker/ssh"

View File

@ -1,12 +1,11 @@
import { writeFileSync } from "node:fs";
import type http from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawn } from "node-pty";
import { publicIpv4, publicIpv6 } from "public-ip";
import { WebSocketServer } from "ws";
import { findAdmin } from "../api/services/admin";
import { validateWebSocketRequest } from "../auth/auth";
import { findServerById } from "../api/services/server";
import { SSH_PATH } from "../constants";
import path from "node:path";
export const getPublicIpWithFallback = async () => {
// @ts-ignore
@ -50,33 +49,30 @@ export const setupTerminalWebSocketServer = (
}
});
// eslint-disable-next-line @typescript-eslint/no-misused-promises
wssTerm.on("connection", async (ws, req) => {
const url = new URL(req.url || "", `http://${req.headers.host}`);
const userSSH = url.searchParams.get("userSSH");
const serverId = url.searchParams.get("serverId");
const { user, session } = await validateWebSocketRequest(req);
if (!user || !session) {
if (!user || !session || !serverId) {
ws.close();
return;
}
if (user) {
const admin = await findAdmin();
const privateKey = admin.sshPrivateKey || "";
const tempDir = tmpdir();
const tempKeyPath = join(tempDir, "temp_ssh_key");
writeFileSync(tempKeyPath, privateKey, { encoding: "utf8", mode: 0o600 });
const sshUser = userSSH;
const ip =
process.env.NODE_ENV === "production"
? await getPublicIpWithFallback()
: "localhost";
const server = await findServerById(serverId);
if (!server) {
ws.close();
return;
}
const privateKey = path.join(SSH_PATH, `${server.sshKeyId}_rsa`);
const sshCommand = [
"ssh",
...((process.env.NODE_ENV === "production" && ["-i", tempKeyPath]) ||
[]),
`${sshUser}@${ip}`,
"-o",
"StrictHostKeyChecking=no",
"-i",
privateKey,
`${server.username}@${server.ipAddress}`,
];
const ptyProcess = spawn("ssh", sshCommand.slice(1), {
name: "xterm-256color",
@ -107,6 +103,5 @@ export const setupTerminalWebSocketServer = (
ws.on("close", () => {
ptyProcess.kill();
});
}
});
};

View File

@ -380,6 +380,9 @@ importers:
sonner:
specifier: ^1.4.0
version: 1.5.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
ssh2:
specifier: 1.15.0
version: 1.15.0
superjson:
specifier: ^2.2.1
version: 2.2.1
@ -459,6 +462,9 @@ importers:
'@types/react-dom':
specifier: 18.3.0
version: 18.3.0
'@types/ssh2':
specifier: 1.15.1
version: 1.15.1
'@types/swagger-ui-react':
specifier: ^4.18.3
version: 4.18.3
@ -3832,8 +3838,8 @@ packages:
'@types/serve-static@1.15.7':
resolution: {integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==}
'@types/ssh2@1.15.0':
resolution: {integrity: sha512-YcT8jP5F8NzWeevWvcyrrLB3zcneVjzYY9ZDSMAMboI+2zR1qYWFhwsyOFVzT7Jorn67vqxC0FRiw8YyG9P1ww==}
'@types/ssh2@1.15.1':
resolution: {integrity: sha512-ZIbEqKAsi5gj35y4P4vkJYly642wIbY6PqoN0xiyQGshKUGXR9WQjF/iF9mXBQ8uBKy3ezfsCkcoHKhd0BzuDA==}
'@types/swagger-ui-react@4.18.3':
resolution: {integrity: sha512-Mo/R7IjDVwtiFPs84pWvh5pI9iyNGBjmfielxqbOh2Jv+8WVSDVe8Nu25kb5BOuV2xmGS3o33jr6nwDJMBcX+Q==}
@ -12449,7 +12455,7 @@ snapshots:
'@types/docker-modem@3.0.6':
dependencies:
'@types/node': 20.14.10
'@types/ssh2': 1.15.0
'@types/ssh2': 1.15.1
'@types/dockerode@3.3.23':
dependencies:
@ -12579,7 +12585,7 @@ snapshots:
'@types/node': 20.14.10
'@types/send': 0.17.4
'@types/ssh2@1.15.0':
'@types/ssh2@1.15.1':
dependencies:
'@types/node': 18.19.42