mirror of
https://github.com/khairul169/vaulterm.git
synced 2025-04-28 16:49:39 +07:00
feat: update
This commit is contained in:
parent
3ef0c93c8f
commit
b50abccae0
58
frontend/components/ui/alert.tsx
Normal file
58
frontend/components/ui/alert.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { Card, GetProps, styled, Text, XStack } from "tamagui";
|
||||||
|
import Icons from "./icons";
|
||||||
|
|
||||||
|
const AlertFrame = styled(Card, {
|
||||||
|
px: "$4",
|
||||||
|
py: "$3",
|
||||||
|
bordered: true,
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: {},
|
||||||
|
error: {
|
||||||
|
backgroundColor: "$red2",
|
||||||
|
borderColor: "$red5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const,
|
||||||
|
});
|
||||||
|
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
error: "alert-circle-outline",
|
||||||
|
};
|
||||||
|
|
||||||
|
type AlertProps = GetProps<typeof AlertFrame>;
|
||||||
|
|
||||||
|
const Alert = ({ children, variant = "default", ...props }: AlertProps) => {
|
||||||
|
return (
|
||||||
|
<AlertFrame variant={variant} {...props}>
|
||||||
|
<XStack gap="$2">
|
||||||
|
{icons[variant] != null && (
|
||||||
|
<Icons name={icons[variant] as never} size={18} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text fontSize="$3" f={1}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
|
</AlertFrame>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type ErrorAlert = AlertProps & {
|
||||||
|
error?: unknown | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ErrorAlert = ({ error, ...props }: ErrorAlert) => {
|
||||||
|
if (!error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = (error as any)?.message || "Something went wrong";
|
||||||
|
return (
|
||||||
|
<Alert variant="error" {...props}>
|
||||||
|
{message}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Alert;
|
19
frontend/components/ui/button.tsx
Normal file
19
frontend/components/ui/button.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { GetProps, Button as BaseButton, Spinner } from "tamagui";
|
||||||
|
|
||||||
|
type ButtonProps = GetProps<typeof BaseButton> & {
|
||||||
|
isDisabled?: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Button = ({ icon, isLoading, isDisabled, ...props }: ButtonProps) => {
|
||||||
|
return (
|
||||||
|
<BaseButton
|
||||||
|
icon={isLoading ? <Spinner /> : icon}
|
||||||
|
disabled={isLoading || isDisabled || props.disabled}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Button;
|
58
frontend/components/ui/os-icons.tsx
Normal file
58
frontend/components/ui/os-icons.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { ComponentPropsWithoutRef } from "react";
|
||||||
|
import Icons from "./icons";
|
||||||
|
|
||||||
|
/*
|
||||||
|
var osMap = map[string]string{
|
||||||
|
"arch": "arch",
|
||||||
|
"ubuntu": "ubuntu",
|
||||||
|
"kali": "kali",
|
||||||
|
"raspbian": "raspbian",
|
||||||
|
"pop": "pop",
|
||||||
|
"debian": "debian",
|
||||||
|
"fedora": "fedora",
|
||||||
|
"centos": "centos",
|
||||||
|
"alpine": "alpine",
|
||||||
|
"mint": "mint",
|
||||||
|
"suse": "suse",
|
||||||
|
"darwin": "macos",
|
||||||
|
"windows": "windows",
|
||||||
|
"msys": "windows",
|
||||||
|
"linux": "linux",
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
const icons: Record<string, { name: string; color?: string }> = {
|
||||||
|
ubuntu: { name: "ubuntu" },
|
||||||
|
debian: { name: "debian" },
|
||||||
|
arch: { name: "arch" },
|
||||||
|
mint: { name: "linux-mint" },
|
||||||
|
raspbian: { name: "raspberry-pi" },
|
||||||
|
fedora: { name: "fedora" },
|
||||||
|
centos: { name: "centos" },
|
||||||
|
macos: { name: "apple" },
|
||||||
|
windows: { name: "microsoft-windows" },
|
||||||
|
linux: { name: "linux" },
|
||||||
|
};
|
||||||
|
|
||||||
|
type OSIconsProps = Omit<ComponentPropsWithoutRef<typeof Icons>, "name"> & {
|
||||||
|
name?: string | null;
|
||||||
|
fallback?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const OSIcons = ({ name, fallback, ...props }: OSIconsProps) => {
|
||||||
|
const icon = icons[name || ""];
|
||||||
|
|
||||||
|
if (!icon) {
|
||||||
|
return fallback ? <Icons name={fallback as never} {...props} /> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Icons
|
||||||
|
name={icon.name as never}
|
||||||
|
color={icon.color || "$color"}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OSIcons;
|
@ -6,6 +6,12 @@ export const BASE_WS_URL = BASE_API_URL.replace("http", "ws");
|
|||||||
|
|
||||||
const api = ofetch.create({
|
const api = ofetch.create({
|
||||||
baseURL: BASE_API_URL,
|
baseURL: BASE_API_URL,
|
||||||
|
onResponseError: (error) => {
|
||||||
|
if (error.response._data) {
|
||||||
|
const message = error.response._data.message;
|
||||||
|
throw new Error(message || "Something went wrong");
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const queryClient = new QueryClient();
|
export const queryClient = new QueryClient();
|
||||||
|
16
frontend/pages/hosts/components/credentials-section.tsx
Normal file
16
frontend/pages/hosts/components/credentials-section.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import Icons from "@/components/ui/icons";
|
||||||
|
import React from "react";
|
||||||
|
import { Button, Label, XStack } from "tamagui";
|
||||||
|
|
||||||
|
export default function CredentialsSection() {
|
||||||
|
return (
|
||||||
|
<XStack gap="$3">
|
||||||
|
<Label flex={1} h="$3">
|
||||||
|
Credentials
|
||||||
|
</Label>
|
||||||
|
<Button size="$3" icon={<Icons size={16} name="plus" />}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</XStack>
|
||||||
|
);
|
||||||
|
}
|
@ -1,22 +1,19 @@
|
|||||||
import Icons from "@/components/ui/icons";
|
import Icons from "@/components/ui/icons";
|
||||||
import Modal from "@/components/ui/modal";
|
import Modal from "@/components/ui/modal";
|
||||||
import { SelectField } from "@/components/ui/select";
|
import { SelectField } from "@/components/ui/select";
|
||||||
import { useZForm, UseZFormReturn } from "@/hooks/useZForm";
|
import { useZForm } from "@/hooks/useZForm";
|
||||||
import api from "@/lib/api";
|
|
||||||
import { createDisclosure } from "@/lib/utils";
|
import { createDisclosure } from "@/lib/utils";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Button, Label, ScrollView, XStack } from "tamagui";
|
import { ScrollView, XStack } from "tamagui";
|
||||||
import {
|
import { FormSchema, formSchema, typeOptions } from "../schema/form";
|
||||||
FormSchema,
|
|
||||||
formSchema,
|
|
||||||
incusTypes,
|
|
||||||
pveTypes,
|
|
||||||
typeOptions,
|
|
||||||
} from "../schema/form";
|
|
||||||
import { InputField } from "@/components/ui/input";
|
import { InputField } from "@/components/ui/input";
|
||||||
import FormField from "@/components/ui/form";
|
import FormField from "@/components/ui/form";
|
||||||
import { useKeychains, useSaveHost } from "../hooks/query";
|
import { useSaveHost } from "../hooks/query";
|
||||||
|
import { ErrorAlert } from "@/components/ui/alert";
|
||||||
|
import Button from "@/components/ui/button";
|
||||||
|
import { PVEFormFields } from "./pve";
|
||||||
|
import { IncusFormFields } from "./incus";
|
||||||
|
import { SSHFormFields } from "./ssh";
|
||||||
|
|
||||||
export const hostFormModal = createDisclosure<FormSchema>();
|
export const hostFormModal = createDisclosure<FormSchema>();
|
||||||
|
|
||||||
@ -26,7 +23,6 @@ const HostForm = () => {
|
|||||||
const isEditing = data?.id != null;
|
const isEditing = data?.id != null;
|
||||||
const type = form.watch("type");
|
const type = form.watch("type");
|
||||||
|
|
||||||
const keys = useKeychains();
|
|
||||||
const saveMutation = useSaveHost();
|
const saveMutation = useSaveHost();
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit((values) => {
|
const onSubmit = form.handleSubmit((values) => {
|
||||||
@ -44,6 +40,8 @@ const HostForm = () => {
|
|||||||
title="Host"
|
title="Host"
|
||||||
description={`${isEditing ? "Edit" : "Add new"} host.`}
|
description={`${isEditing ? "Edit" : "Add new"} host.`}
|
||||||
>
|
>
|
||||||
|
<ErrorAlert mx="$4" mb="$4" error={saveMutation.error} />
|
||||||
|
|
||||||
<ScrollView contentContainerStyle={{ padding: "$4", pt: 0, gap: "$4" }}>
|
<ScrollView contentContainerStyle={{ padding: "$4", pt: 0, gap: "$4" }}>
|
||||||
<FormField label="Label">
|
<FormField label="Label">
|
||||||
<InputField f={1} form={form} name="label" placeholder="Label..." />
|
<InputField f={1} form={form} name="label" placeholder="Label..." />
|
||||||
@ -62,47 +60,17 @@ const HostForm = () => {
|
|||||||
form={form}
|
form={form}
|
||||||
name="port"
|
name="port"
|
||||||
keyboardType="number-pad"
|
keyboardType="number-pad"
|
||||||
placeholder="SSH Port"
|
placeholder="Port"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
{type === "pve" && <PVEFormFields form={form} />}
|
{type === "ssh" ? (
|
||||||
{type === "incus" && <IncusFormFields form={form} />}
|
<SSHFormFields form={form} />
|
||||||
|
) : type === "pve" ? (
|
||||||
<XStack gap="$3">
|
<PVEFormFields form={form} />
|
||||||
<Label flex={1} h="$3">
|
) : type === "incus" ? (
|
||||||
Credentials
|
<IncusFormFields form={form} />
|
||||||
</Label>
|
) : null}
|
||||||
<Button size="$3" icon={<Icons size={16} name="plus" />}>
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</XStack>
|
|
||||||
|
|
||||||
<FormField label="User">
|
|
||||||
<SelectField
|
|
||||||
form={form}
|
|
||||||
name="keyId"
|
|
||||||
placeholder="Select User"
|
|
||||||
items={keys.data?.map((key: any) => ({
|
|
||||||
label: key.label,
|
|
||||||
value: key.id,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
{type === "ssh" && (
|
|
||||||
<FormField label="Private Key">
|
|
||||||
<SelectField
|
|
||||||
form={form}
|
|
||||||
name="altKeyId"
|
|
||||||
placeholder="Select Private Key"
|
|
||||||
items={keys.data?.map((key: any) => ({
|
|
||||||
label: key.label,
|
|
||||||
value: key.id,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
<XStack p="$4" gap="$4">
|
<XStack p="$4" gap="$4">
|
||||||
@ -113,6 +81,7 @@ const HostForm = () => {
|
|||||||
flex={1}
|
flex={1}
|
||||||
icon={<Icons name="content-save" size={18} />}
|
icon={<Icons name="content-save" size={18} />}
|
||||||
onPress={onSubmit}
|
onPress={onSubmit}
|
||||||
|
isLoading={saveMutation.isPending}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
@ -121,72 +90,4 @@ const HostForm = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type MiscFormFieldProps = {
|
|
||||||
form: UseZFormReturn<FormSchema>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const PVEFormFields = ({ form }: MiscFormFieldProps) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FormField label="Node">
|
|
||||||
<InputField form={form} name="metadata.node" placeholder="pve" />
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Type">
|
|
||||||
<SelectField
|
|
||||||
form={form}
|
|
||||||
name="metadata.type"
|
|
||||||
placeholder="Select Type"
|
|
||||||
items={pveTypes}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="VMID">
|
|
||||||
<InputField
|
|
||||||
form={form}
|
|
||||||
name="metadata.vmid"
|
|
||||||
keyboardType="number-pad"
|
|
||||||
placeholder="VMID"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const IncusFormFields = ({ form }: MiscFormFieldProps) => {
|
|
||||||
const type = form.watch("metadata.type");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FormField label="Type">
|
|
||||||
<SelectField
|
|
||||||
form={form}
|
|
||||||
name="metadata.type"
|
|
||||||
placeholder="Select Type"
|
|
||||||
items={incusTypes}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Instance ID">
|
|
||||||
<InputField
|
|
||||||
form={form}
|
|
||||||
name="metadata.instance"
|
|
||||||
placeholder="myinstance"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
{type === "lxc" && (
|
|
||||||
<>
|
|
||||||
<FormField label="User ID">
|
|
||||||
<InputField
|
|
||||||
form={form}
|
|
||||||
keyboardType="number-pad"
|
|
||||||
name="metadata.user"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Shell">
|
|
||||||
<InputField form={form} name="metadata.shell" placeholder="bash" />
|
|
||||||
</FormField>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default HostForm;
|
export default HostForm;
|
||||||
|
@ -8,8 +8,13 @@ import Icons from "@/components/ui/icons";
|
|||||||
import SearchInput from "@/components/ui/search-input";
|
import SearchInput from "@/components/ui/search-input";
|
||||||
import { useTermSession } from "@/stores/terminal-sessions";
|
import { useTermSession } from "@/stores/terminal-sessions";
|
||||||
import { hostFormModal } from "./form";
|
import { hostFormModal } from "./form";
|
||||||
|
import OSIcons from "@/components/ui/os-icons";
|
||||||
|
|
||||||
const HostsList = () => {
|
type HostsListProps = {
|
||||||
|
allowEdit?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const HostsList = ({ allowEdit = true }: HostsListProps) => {
|
||||||
const openSession = useTermSession((i) => i.push);
|
const openSession = useTermSession((i) => i.push);
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@ -37,6 +42,7 @@ const HostsList = () => {
|
|||||||
}, [hosts.data, search]);
|
}, [hosts.data, search]);
|
||||||
|
|
||||||
const onOpen = (host: any) => {
|
const onOpen = (host: any) => {
|
||||||
|
if (!allowEdit) return;
|
||||||
hostFormModal.onOpen(host);
|
hostFormModal.onOpen(host);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -88,9 +94,9 @@ const HostsList = () => {
|
|||||||
flexBasis="100%"
|
flexBasis="100%"
|
||||||
cursor="pointer"
|
cursor="pointer"
|
||||||
$gtXs={{ flexBasis: "50%" }}
|
$gtXs={{ flexBasis: "50%" }}
|
||||||
$gtSm={{ flexBasis: "33.3%" }}
|
$gtMd={{ flexBasis: "33.3%" }}
|
||||||
$gtMd={{ flexBasis: "25%" }}
|
$gtLg={{ flexBasis: "25%" }}
|
||||||
$gtLg={{ flexBasis: "20%" }}
|
$gtXl={{ flexBasis: "20%" }}
|
||||||
p="$2"
|
p="$2"
|
||||||
group
|
group
|
||||||
numberOfTaps={2}
|
numberOfTaps={2}
|
||||||
@ -99,6 +105,13 @@ const HostsList = () => {
|
|||||||
>
|
>
|
||||||
<Card bordered p="$4">
|
<Card bordered p="$4">
|
||||||
<XStack>
|
<XStack>
|
||||||
|
<OSIcons
|
||||||
|
name={host.os}
|
||||||
|
size={18}
|
||||||
|
mr="$2"
|
||||||
|
fallback="desktop-classic"
|
||||||
|
/>
|
||||||
|
|
||||||
<View flex={1}>
|
<View flex={1}>
|
||||||
<Text>{host.label}</Text>
|
<Text>{host.label}</Text>
|
||||||
<Text fontSize="$3" mt="$2">
|
<Text fontSize="$3" mt="$2">
|
||||||
@ -106,18 +119,20 @@ const HostsList = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Button
|
{allowEdit && (
|
||||||
circular
|
<Button
|
||||||
display="none"
|
circular
|
||||||
$sm={{ display: "block" }}
|
display="none"
|
||||||
$group-hover={{ display: "block" }}
|
$sm={{ display: "block" }}
|
||||||
onPress={(e) => {
|
$group-hover={{ display: "block" }}
|
||||||
e.stopPropagation();
|
onPress={(e) => {
|
||||||
onOpen(host);
|
e.stopPropagation();
|
||||||
}}
|
onOpen(host);
|
||||||
>
|
}}
|
||||||
<Icons name="pencil" size={16} />
|
>
|
||||||
</Button>
|
<Icons name="pencil" size={16} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</XStack>
|
</XStack>
|
||||||
</Card>
|
</Card>
|
||||||
</MultiTapPressable>
|
</MultiTapPressable>
|
||||||
|
57
frontend/pages/hosts/components/incus.tsx
Normal file
57
frontend/pages/hosts/components/incus.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import FormField from "@/components/ui/form";
|
||||||
|
import { MiscFormFieldProps } from "../types";
|
||||||
|
import { InputField } from "@/components/ui/input";
|
||||||
|
import { SelectField } from "@/components/ui/select";
|
||||||
|
import { incusTypes } from "../schema/form";
|
||||||
|
import CredentialsSection from "./credentials-section";
|
||||||
|
import { useKeychainsOptions } from "../hooks/query";
|
||||||
|
|
||||||
|
export const IncusFormFields = ({ form }: MiscFormFieldProps) => {
|
||||||
|
const keys = useKeychainsOptions();
|
||||||
|
const type = form.watch("metadata.type");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormField label="Type">
|
||||||
|
<SelectField
|
||||||
|
form={form}
|
||||||
|
name="metadata.type"
|
||||||
|
placeholder="Select Type"
|
||||||
|
items={incusTypes}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Instance ID">
|
||||||
|
<InputField
|
||||||
|
form={form}
|
||||||
|
name="metadata.instance"
|
||||||
|
placeholder="myinstance"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
{type === "lxc" && (
|
||||||
|
<>
|
||||||
|
<FormField label="User ID">
|
||||||
|
<InputField
|
||||||
|
form={form}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
name="metadata.user"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Shell">
|
||||||
|
<InputField form={form} name="metadata.shell" placeholder="bash" />
|
||||||
|
</FormField>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CredentialsSection />
|
||||||
|
|
||||||
|
<FormField label="Client Certificate">
|
||||||
|
<SelectField
|
||||||
|
form={form}
|
||||||
|
name="keyId"
|
||||||
|
placeholder="Select Certificate"
|
||||||
|
items={keys.filter((i) => i.type === "cert")}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
46
frontend/pages/hosts/components/pve.tsx
Normal file
46
frontend/pages/hosts/components/pve.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import FormField from "@/components/ui/form";
|
||||||
|
import { MiscFormFieldProps } from "../types";
|
||||||
|
import { InputField } from "@/components/ui/input";
|
||||||
|
import { SelectField } from "@/components/ui/select";
|
||||||
|
import { pveTypes } from "../schema/form";
|
||||||
|
import { useKeychainsOptions } from "../hooks/query";
|
||||||
|
import CredentialsSection from "./credentials-section";
|
||||||
|
|
||||||
|
export const PVEFormFields = ({ form }: MiscFormFieldProps) => {
|
||||||
|
const keys = useKeychainsOptions();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormField label="Node">
|
||||||
|
<InputField form={form} name="metadata.node" placeholder="pve" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Type">
|
||||||
|
<SelectField
|
||||||
|
form={form}
|
||||||
|
name="metadata.type"
|
||||||
|
placeholder="Select Type"
|
||||||
|
items={pveTypes}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="VMID">
|
||||||
|
<InputField
|
||||||
|
form={form}
|
||||||
|
name="metadata.vmid"
|
||||||
|
keyboardType="number-pad"
|
||||||
|
placeholder="VMID"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<CredentialsSection />
|
||||||
|
|
||||||
|
<FormField label="Account">
|
||||||
|
<SelectField
|
||||||
|
form={form}
|
||||||
|
name="keyId"
|
||||||
|
placeholder="Select Account"
|
||||||
|
items={keys.filter((i) => i.type === "pve")}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
33
frontend/pages/hosts/components/ssh.tsx
Normal file
33
frontend/pages/hosts/components/ssh.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import FormField from "@/components/ui/form";
|
||||||
|
import { MiscFormFieldProps } from "../types";
|
||||||
|
import { SelectField } from "@/components/ui/select";
|
||||||
|
import CredentialsSection from "./credentials-section";
|
||||||
|
import { useKeychainsOptions } from "../hooks/query";
|
||||||
|
|
||||||
|
export const SSHFormFields = ({ form }: MiscFormFieldProps) => {
|
||||||
|
const keys = useKeychainsOptions();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CredentialsSection />
|
||||||
|
|
||||||
|
<FormField label="User">
|
||||||
|
<SelectField
|
||||||
|
form={form}
|
||||||
|
name="keyId"
|
||||||
|
placeholder="Select User"
|
||||||
|
items={keys.filter((i) => i.type === "user")}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Private Key">
|
||||||
|
<SelectField
|
||||||
|
form={form}
|
||||||
|
name="altKeyId"
|
||||||
|
placeholder="Select Private Key"
|
||||||
|
items={keys.filter((i) => i.type === "rsa")}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -1,6 +1,7 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { FormSchema } from "../schema/form";
|
import { FormSchema } from "../schema/form";
|
||||||
import api, { queryClient } from "@/lib/api";
|
import api, { queryClient } from "@/lib/api";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
export const useKeychains = () => {
|
export const useKeychains = () => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@ -10,6 +11,22 @@ export const useKeychains = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useKeychainsOptions = () => {
|
||||||
|
const keys = useKeychains();
|
||||||
|
|
||||||
|
const data = useMemo(() => {
|
||||||
|
const items: any[] = keys.data || [];
|
||||||
|
|
||||||
|
return items.map((key: any) => ({
|
||||||
|
type: key.type,
|
||||||
|
label: key.label,
|
||||||
|
value: key.id,
|
||||||
|
}));
|
||||||
|
}, [keys.data]);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
export const useSaveHost = () => {
|
export const useSaveHost = () => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (body: FormSchema) => {
|
mutationFn: async (body: FormSchema) => {
|
||||||
|
6
frontend/pages/hosts/types.ts
Normal file
6
frontend/pages/hosts/types.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { UseZFormReturn } from "@/hooks/useZForm";
|
||||||
|
import { FormSchema } from "./schema/form";
|
||||||
|
|
||||||
|
export type MiscFormFieldProps = {
|
||||||
|
form: UseZFormReturn<FormSchema>;
|
||||||
|
};
|
@ -35,7 +35,7 @@ const TerminalPage = () => {
|
|||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
page={curSession}
|
page={curSession}
|
||||||
onChangePage={setSession}
|
onChangePage={setSession}
|
||||||
EmptyComponent={HostsList}
|
EmptyComponent={() => <HostsList allowEdit={false} />}
|
||||||
>
|
>
|
||||||
{sessions.map((session) => (
|
{sessions.map((session) => (
|
||||||
<InteractiveSession key={session.id} {...session} />
|
<InteractiveSession key={session.id} {...session} />
|
||||||
|
@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
type Hosts struct{ db *gorm.DB }
|
type Hosts struct{ db *gorm.DB }
|
||||||
|
|
||||||
func NewHostsRepository() *Hosts {
|
func NewRepository() *Hosts {
|
||||||
return &Hosts{db: db.Get()}
|
return &Hosts{db: db.Get()}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -19,13 +19,7 @@ func (r *Hosts) GetAll() ([]*models.Host, error) {
|
|||||||
return rows, ret.Error
|
return rows, ret.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetHostResult struct {
|
func (r *Hosts) Get(id string) (*models.HostDecrypted, error) {
|
||||||
Host *models.Host
|
|
||||||
Key map[string]interface{}
|
|
||||||
AltKey map[string]interface{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Hosts) Get(id string) (*GetHostResult, error) {
|
|
||||||
var host models.Host
|
var host models.Host
|
||||||
ret := r.db.Joins("Key").Joins("AltKey").Where("hosts.id = ?", id).First(&host)
|
ret := r.db.Joins("Key").Joins("AltKey").Where("hosts.id = ?", id).First(&host)
|
||||||
|
|
||||||
@ -33,17 +27,9 @@ func (r *Hosts) Get(id string) (*GetHostResult, error) {
|
|||||||
return nil, ret.Error
|
return nil, ret.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
res := &GetHostResult{Host: &host}
|
res, err := host.DecryptKeys()
|
||||||
|
if err != nil {
|
||||||
if host.Key.Data != "" {
|
return nil, err
|
||||||
if err := host.Key.DecryptData(&res.Key); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if host.AltKey.Data != "" {
|
|
||||||
if err := host.AltKey.DecryptData(&res.AltKey); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return res, ret.Error
|
return res, ret.Error
|
||||||
|
@ -19,7 +19,7 @@ func Router(app *fiber.App) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getAll(c *fiber.Ctx) error {
|
func getAll(c *fiber.Ctx) error {
|
||||||
repo := NewHostsRepository()
|
repo := NewRepository()
|
||||||
rows, err := repo.GetAll()
|
rows, err := repo.GetAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return utils.ResponseError(c, err, 500)
|
return utils.ResponseError(c, err, 500)
|
||||||
@ -36,7 +36,7 @@ func create(c *fiber.Ctx) error {
|
|||||||
return utils.ResponseError(c, err, 500)
|
return utils.ResponseError(c, err, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
repo := NewHostsRepository()
|
repo := NewRepository()
|
||||||
item := &models.Host{
|
item := &models.Host{
|
||||||
Type: body.Type,
|
Type: body.Type,
|
||||||
Label: body.Label,
|
Label: body.Label,
|
||||||
@ -47,6 +47,13 @@ func create(c *fiber.Ctx) error {
|
|||||||
KeyID: body.KeyID,
|
KeyID: body.KeyID,
|
||||||
AltKeyID: body.AltKeyID,
|
AltKeyID: body.AltKeyID,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
osName, err := tryConnect(item)
|
||||||
|
if err != nil {
|
||||||
|
return utils.ResponseError(c, fmt.Errorf("cannot connect to the host: %s", err), 500)
|
||||||
|
}
|
||||||
|
item.OS = osName
|
||||||
|
|
||||||
if err := repo.Create(item); err != nil {
|
if err := repo.Create(item); err != nil {
|
||||||
return utils.ResponseError(c, err, 500)
|
return utils.ResponseError(c, err, 500)
|
||||||
}
|
}
|
||||||
@ -60,7 +67,7 @@ func update(c *fiber.Ctx) error {
|
|||||||
return utils.ResponseError(c, err, 500)
|
return utils.ResponseError(c, err, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
repo := NewHostsRepository()
|
repo := NewRepository()
|
||||||
|
|
||||||
id := c.Params("id")
|
id := c.Params("id")
|
||||||
exist, _ := repo.Exists(id)
|
exist, _ := repo.Exists(id)
|
||||||
@ -79,6 +86,13 @@ func update(c *fiber.Ctx) error {
|
|||||||
KeyID: body.KeyID,
|
KeyID: body.KeyID,
|
||||||
AltKeyID: body.AltKeyID,
|
AltKeyID: body.AltKeyID,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
osName, err := tryConnect(item)
|
||||||
|
if err != nil {
|
||||||
|
return utils.ResponseError(c, fmt.Errorf("cannot connect to the host: %s", err), 500)
|
||||||
|
}
|
||||||
|
item.OS = osName
|
||||||
|
|
||||||
if err := repo.Update(item); err != nil {
|
if err := repo.Update(item); err != nil {
|
||||||
return utils.ResponseError(c, err, 500)
|
return utils.ResponseError(c, err, 500)
|
||||||
}
|
}
|
||||||
@ -87,7 +101,7 @@ func update(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func delete(c *fiber.Ctx) error {
|
func delete(c *fiber.Ctx) error {
|
||||||
repo := NewHostsRepository()
|
repo := NewRepository()
|
||||||
|
|
||||||
id := c.Params("id")
|
id := c.Params("id")
|
||||||
exist, _ := repo.Exists(id)
|
exist, _ := repo.Exists(id)
|
||||||
|
54
server/app/hosts/utils.go
Normal file
54
server/app/hosts/utils.go
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
package hosts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"rul.sh/vaulterm/app/keychains"
|
||||||
|
"rul.sh/vaulterm/lib"
|
||||||
|
"rul.sh/vaulterm/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func tryConnect(host *models.Host) (string, error) {
|
||||||
|
keyRepo := keychains.NewRepository()
|
||||||
|
|
||||||
|
var key map[string]interface{}
|
||||||
|
var altKey map[string]interface{}
|
||||||
|
|
||||||
|
if host.KeyID != nil {
|
||||||
|
keychain, _ := keyRepo.Get(*host.KeyID)
|
||||||
|
if keychain == nil {
|
||||||
|
return "", fmt.Errorf("key %s not found", *host.KeyID)
|
||||||
|
}
|
||||||
|
keychain.DecryptData(&key)
|
||||||
|
}
|
||||||
|
if host.AltKeyID != nil {
|
||||||
|
keychain, _ := keyRepo.Get(*host.AltKeyID)
|
||||||
|
if keychain == nil {
|
||||||
|
return "", fmt.Errorf("key %s not found", *host.KeyID)
|
||||||
|
}
|
||||||
|
keychain.DecryptData(&altKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
if host.Type == "ssh" {
|
||||||
|
c := lib.NewSSHClient(&lib.SSHClientConfig{
|
||||||
|
HostName: host.Host,
|
||||||
|
Port: host.Port,
|
||||||
|
Key: key,
|
||||||
|
AltKey: altKey,
|
||||||
|
})
|
||||||
|
|
||||||
|
con, err := c.Connect()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
os, err := c.GetOS(c, con)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil
|
||||||
|
}
|
@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
type Keychains struct{ db *gorm.DB }
|
type Keychains struct{ db *gorm.DB }
|
||||||
|
|
||||||
func NewKeychainsRepository() *Keychains {
|
func NewRepository() *Keychains {
|
||||||
return &Keychains{db: db.Get()}
|
return &Keychains{db: db.Get()}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -22,3 +22,31 @@ func (r *Keychains) GetAll() ([]*models.Keychain, error) {
|
|||||||
func (r *Keychains) Create(item *models.Keychain) error {
|
func (r *Keychains) Create(item *models.Keychain) error {
|
||||||
return r.db.Create(item).Error
|
return r.db.Create(item).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Keychains) Get(id string) (*models.Keychain, error) {
|
||||||
|
var keychain models.Keychain
|
||||||
|
if err := r.db.Where("id = ?", id).First(&keychain).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &keychain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type KeychainDecrypted struct {
|
||||||
|
models.Keychain
|
||||||
|
Data map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Keychains) GetDecrypted(id string) (*KeychainDecrypted, error) {
|
||||||
|
keychain, err := r.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var data map[string]interface{}
|
||||||
|
if err := keychain.DecryptData(&data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &KeychainDecrypted{Keychain: *keychain, Data: data}, nil
|
||||||
|
}
|
||||||
|
@ -16,7 +16,7 @@ func Router(app *fiber.App) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getAll(c *fiber.Ctx) error {
|
func getAll(c *fiber.Ctx) error {
|
||||||
repo := NewKeychainsRepository()
|
repo := NewRepository()
|
||||||
rows, err := repo.GetAll()
|
rows, err := repo.GetAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return utils.ResponseError(c, err, 500)
|
return utils.ResponseError(c, err, 500)
|
||||||
@ -33,7 +33,7 @@ func create(c *fiber.Ctx) error {
|
|||||||
return utils.ResponseError(c, err, 500)
|
return utils.ResponseError(c, err, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
repo := NewKeychainsRepository()
|
repo := NewRepository()
|
||||||
|
|
||||||
item := &models.Keychain{
|
item := &models.Keychain{
|
||||||
Type: body.Type,
|
Type: body.Type,
|
||||||
|
@ -6,13 +6,14 @@ import (
|
|||||||
"github.com/gofiber/contrib/websocket"
|
"github.com/gofiber/contrib/websocket"
|
||||||
"rul.sh/vaulterm/app/hosts"
|
"rul.sh/vaulterm/app/hosts"
|
||||||
"rul.sh/vaulterm/lib"
|
"rul.sh/vaulterm/lib"
|
||||||
|
"rul.sh/vaulterm/models"
|
||||||
"rul.sh/vaulterm/utils"
|
"rul.sh/vaulterm/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func HandleTerm(c *websocket.Conn) {
|
func HandleTerm(c *websocket.Conn) {
|
||||||
hostId := c.Query("hostId")
|
hostId := c.Query("hostId")
|
||||||
|
|
||||||
hostRepo := hosts.NewHostsRepository()
|
hostRepo := hosts.NewRepository()
|
||||||
data, err := hostRepo.Get(hostId)
|
data, err := hostRepo.Get(hostId)
|
||||||
|
|
||||||
if data == nil {
|
if data == nil {
|
||||||
@ -33,30 +34,27 @@ func HandleTerm(c *websocket.Conn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sshHandler(c *websocket.Conn, data *hosts.GetHostResult) {
|
func sshHandler(c *websocket.Conn, data *models.HostDecrypted) {
|
||||||
username, _ := data.Key["username"].(string)
|
cfg := lib.NewSSHClient(&lib.SSHClientConfig{
|
||||||
password, _ := data.Key["password"].(string)
|
|
||||||
|
|
||||||
cfg := &SSHConfig{
|
|
||||||
HostName: data.Host.Host,
|
HostName: data.Host.Host,
|
||||||
Port: data.Host.Port,
|
Port: data.Port,
|
||||||
User: username,
|
Key: data.Key,
|
||||||
Password: password,
|
AltKey: data.AltKey,
|
||||||
}
|
})
|
||||||
|
|
||||||
if err := NewSSHWebsocketSession(c, cfg); err != nil {
|
if err := NewSSHWebsocketSession(c, cfg); err != nil {
|
||||||
c.WriteMessage(websocket.TextMessage, []byte(err.Error()))
|
c.WriteMessage(websocket.TextMessage, []byte(err.Error()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func pveHandler(c *websocket.Conn, data *hosts.GetHostResult) {
|
func pveHandler(c *websocket.Conn, data *models.HostDecrypted) {
|
||||||
client := c.Query("client")
|
client := c.Query("client")
|
||||||
username, _ := data.Key["username"].(string)
|
username, _ := data.Key["username"].(string)
|
||||||
password, _ := data.Key["password"].(string)
|
password, _ := data.Key["password"].(string)
|
||||||
|
|
||||||
pve := &lib.PVEServer{
|
pve := &lib.PVEServer{
|
||||||
HostName: data.Host.Host,
|
HostName: data.Host.Host,
|
||||||
Port: data.Host.Port,
|
Port: data.Port,
|
||||||
Username: username,
|
Username: username,
|
||||||
Password: password,
|
Password: password,
|
||||||
}
|
}
|
||||||
@ -84,7 +82,7 @@ func pveHandler(c *websocket.Conn, data *hosts.GetHostResult) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func incusHandler(c *websocket.Conn, data *hosts.GetHostResult) {
|
func incusHandler(c *websocket.Conn, data *models.HostDecrypted) {
|
||||||
shell := c.Query("shell")
|
shell := c.Query("shell")
|
||||||
|
|
||||||
cert, _ := data.Key["cert"].(string)
|
cert, _ := data.Key["cert"].(string)
|
||||||
@ -97,7 +95,7 @@ func incusHandler(c *websocket.Conn, data *hosts.GetHostResult) {
|
|||||||
|
|
||||||
incus := &lib.IncusServer{
|
incus := &lib.IncusServer{
|
||||||
HostName: data.Host.Host,
|
HostName: data.Host.Host,
|
||||||
Port: data.Host.Port,
|
Port: data.Port,
|
||||||
ClientCert: cert,
|
ClientCert: cert,
|
||||||
ClientKey: key,
|
ClientKey: key,
|
||||||
}
|
}
|
||||||
|
@ -1,101 +1,37 @@
|
|||||||
package ws
|
package ws
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gofiber/contrib/websocket"
|
"github.com/gofiber/contrib/websocket"
|
||||||
"golang.org/x/crypto/ssh"
|
"rul.sh/vaulterm/lib"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SSHConfig struct {
|
func NewSSHWebsocketSession(c *websocket.Conn, client *lib.SSHClient) error {
|
||||||
HostName string
|
con, err := client.Connect()
|
||||||
User string
|
|
||||||
Password string
|
|
||||||
Port int
|
|
||||||
PrivateKey string
|
|
||||||
PrivateKeyPassphrase string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewSSHWebsocketSession(c *websocket.Conn, cfg *SSHConfig) error {
|
|
||||||
// Set up SSH client configuration
|
|
||||||
port := cfg.Port
|
|
||||||
if port == 0 {
|
|
||||||
port = 22
|
|
||||||
}
|
|
||||||
auth := []ssh.AuthMethod{
|
|
||||||
ssh.Password(cfg.Password),
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.PrivateKey != "" {
|
|
||||||
var err error
|
|
||||||
var signer ssh.Signer
|
|
||||||
|
|
||||||
if cfg.PrivateKeyPassphrase != "" {
|
|
||||||
signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(cfg.PrivateKey), []byte(cfg.PrivateKeyPassphrase))
|
|
||||||
} else {
|
|
||||||
signer, err = ssh.ParsePrivateKey([]byte(cfg.PrivateKey))
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to parse private key: %v", err)
|
|
||||||
}
|
|
||||||
auth = append(auth, ssh.PublicKeys(signer))
|
|
||||||
}
|
|
||||||
|
|
||||||
sshConfig := &ssh.ClientConfig{
|
|
||||||
User: cfg.User,
|
|
||||||
Auth: auth,
|
|
||||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect to SSH server
|
|
||||||
hostName := fmt.Sprintf("%s:%d", cfg.HostName, port)
|
|
||||||
sshConn, err := ssh.Dial("tcp", hostName, sshConfig)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("error connecting to SSH: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer sshConn.Close()
|
defer con.Close()
|
||||||
|
|
||||||
// Start an SSH shell session
|
shell, err := client.StartPtyShell(con)
|
||||||
session, err := sshConn.NewSession()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("error starting SSH shell: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
session := shell.Session
|
||||||
defer session.Close()
|
defer session.Close()
|
||||||
|
|
||||||
stdoutPipe, err := session.StdoutPipe()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
stderrPipe, err := session.StderrPipe()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
stdinPipe, err := session.StdinPipe()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = session.RequestPty("xterm-256color", 80, 24, ssh.TerminalModes{})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := session.Shell(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Goroutine to send SSH stdout to WebSocket
|
// Goroutine to send SSH stdout to WebSocket
|
||||||
go func() {
|
go func() {
|
||||||
buf := make([]byte, 1024)
|
buf := make([]byte, 1024)
|
||||||
for {
|
for {
|
||||||
n, err := stdoutPipe.Read(buf)
|
n, err := shell.Stdout.Read(buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err != io.EOF {
|
if err != io.EOF {
|
||||||
log.Printf("error reading from SSH stdout: %v", err)
|
log.Printf("error reading from SSH stdout: %v", err)
|
||||||
@ -114,7 +50,7 @@ func NewSSHWebsocketSession(c *websocket.Conn, cfg *SSHConfig) error {
|
|||||||
go func() {
|
go func() {
|
||||||
buf := make([]byte, 1024)
|
buf := make([]byte, 1024)
|
||||||
for {
|
for {
|
||||||
n, err := stderrPipe.Read(buf)
|
n, err := shell.Stderr.Read(buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err != io.EOF {
|
if err != io.EOF {
|
||||||
log.Printf("error reading from SSH stderr: %v", err)
|
log.Printf("error reading from SSH stderr: %v", err)
|
||||||
@ -135,6 +71,7 @@ func NewSSHWebsocketSession(c *websocket.Conn, cfg *SSHConfig) error {
|
|||||||
for {
|
for {
|
||||||
_, msg, err := c.ReadMessage()
|
_, msg, err := c.ReadMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("error reading from websocket: %v", err)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -148,8 +85,10 @@ func NewSSHWebsocketSession(c *websocket.Conn, cfg *SSHConfig) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
stdinPipe.Write(msg)
|
shell.Stdin.Write(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Println("SSH session closed")
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Wait for the SSH session to close
|
// Wait for the SSH session to close
|
||||||
@ -158,6 +97,5 @@ func NewSSHWebsocketSession(c *websocket.Conn, cfg *SSHConfig) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("SSH session ended normally")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -114,6 +114,10 @@ func Decrypt(encrypted string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(data) < 16 {
|
||||||
|
return "", fmt.Errorf("invalid encrypted data")
|
||||||
|
}
|
||||||
|
|
||||||
block, err := aes.NewCipher(keyDec)
|
block, err := aes.NewCipher(keyDec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
32
server/lib/os.go
Normal file
32
server/lib/os.go
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
package lib
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// Map of OS identifiers and their corresponding names
|
||||||
|
var osMap = map[string]string{
|
||||||
|
"arch": "arch",
|
||||||
|
"ubuntu": "ubuntu",
|
||||||
|
"kali": "kali",
|
||||||
|
"raspbian": "raspbian",
|
||||||
|
"pop": "pop",
|
||||||
|
"debian": "debian",
|
||||||
|
"fedora": "fedora",
|
||||||
|
"centos": "centos",
|
||||||
|
"alpine": "alpine",
|
||||||
|
"mint": "mint",
|
||||||
|
"suse": "suse",
|
||||||
|
"darwin": "macos",
|
||||||
|
"windows": "windows",
|
||||||
|
"msys": "windows",
|
||||||
|
"linux": "linux",
|
||||||
|
}
|
||||||
|
|
||||||
|
func DetectOS(str string) string {
|
||||||
|
str = strings.ToLower(str)
|
||||||
|
for keyword, osName := range osMap {
|
||||||
|
if strings.Contains(str, keyword) {
|
||||||
|
return osName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
154
server/lib/ssh.go
Normal file
154
server/lib/ssh.go
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
package lib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SSHClient struct {
|
||||||
|
HostName string
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
Port int
|
||||||
|
PrivateKey string
|
||||||
|
PrivateKeyPassphrase string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SSHClientConfig struct {
|
||||||
|
HostName string
|
||||||
|
Port int
|
||||||
|
Key map[string]interface{}
|
||||||
|
AltKey map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSSHClient(cfg *SSHClientConfig) *SSHClient {
|
||||||
|
username, _ := cfg.Key["username"].(string)
|
||||||
|
password, _ := cfg.Key["password"].(string)
|
||||||
|
privateKey, _ := cfg.AltKey["private"].(string)
|
||||||
|
passphrase, _ := cfg.AltKey["passphrase"].(string)
|
||||||
|
|
||||||
|
return &SSHClient{
|
||||||
|
HostName: cfg.HostName,
|
||||||
|
User: username,
|
||||||
|
Password: password,
|
||||||
|
Port: cfg.Port,
|
||||||
|
PrivateKey: privateKey,
|
||||||
|
PrivateKeyPassphrase: passphrase,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSHClient) Connect() (*ssh.Client, error) {
|
||||||
|
// Set up SSH client configuration
|
||||||
|
port := s.Port
|
||||||
|
if port == 0 {
|
||||||
|
port = 22
|
||||||
|
}
|
||||||
|
auth := []ssh.AuthMethod{
|
||||||
|
ssh.Password(s.Password),
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.PrivateKey != "" {
|
||||||
|
var err error
|
||||||
|
var signer ssh.Signer
|
||||||
|
|
||||||
|
if s.PrivateKeyPassphrase != "" {
|
||||||
|
signer, err = ssh.ParsePrivateKeyWithPassphrase([]byte(s.PrivateKey), []byte(s.PrivateKeyPassphrase))
|
||||||
|
} else {
|
||||||
|
signer, err = ssh.ParsePrivateKey([]byte(s.PrivateKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to parse private key: %v", err)
|
||||||
|
}
|
||||||
|
auth = append(auth, ssh.PublicKeys(signer))
|
||||||
|
}
|
||||||
|
|
||||||
|
sshConfig := &ssh.ClientConfig{
|
||||||
|
User: s.User,
|
||||||
|
Auth: auth,
|
||||||
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to SSH server
|
||||||
|
hostName := fmt.Sprintf("%s:%d", s.HostName, port)
|
||||||
|
sshConn, err := ssh.Dial("tcp", hostName, sshConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return sshConn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type PtyShellRes struct {
|
||||||
|
Stdout io.Reader
|
||||||
|
Stderr io.Reader
|
||||||
|
Stdin io.WriteCloser
|
||||||
|
Session *ssh.Session
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSHClient) StartPtyShell(sshConn *ssh.Client) (res *PtyShellRes, err error) {
|
||||||
|
// Start an SSH shell session
|
||||||
|
session, err := sshConn.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
stdoutPipe, err := session.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
stderrPipe, err := session.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
stdinPipe, err := session.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = session.RequestPty("xterm-256color", 80, 24, ssh.TerminalModes{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := session.Shell(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &PtyShellRes{
|
||||||
|
Stdout: stdoutPipe,
|
||||||
|
Stderr: stderrPipe,
|
||||||
|
Stdin: stdinPipe,
|
||||||
|
Session: session,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSHClient) Exec(sshConn *ssh.Client, command string) (string, error) {
|
||||||
|
// Start an SSH shell session
|
||||||
|
session, err := sshConn.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
// Execute the command
|
||||||
|
output, err := session.CombinedOutput(command)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SSHClient) GetOS(client *SSHClient, con *ssh.Client) (string, error) {
|
||||||
|
out, err := client.Exec(con, "cat /etc/os-release || uname -a || systeminfo")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return DetectOS(out), nil
|
||||||
|
}
|
@ -18,6 +18,7 @@ type Host struct {
|
|||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
Host string `json:"host" gorm:"type:varchar(64)"`
|
Host string `json:"host" gorm:"type:varchar(64)"`
|
||||||
Port int `json:"port" gorm:"type:smallint"`
|
Port int `json:"port" gorm:"type:smallint"`
|
||||||
|
OS string `json:"os" gorm:"type:varchar(32)"`
|
||||||
Metadata datatypes.JSONMap `json:"metadata"`
|
Metadata datatypes.JSONMap `json:"metadata"`
|
||||||
|
|
||||||
ParentID *string `json:"parentId" gorm:"index:hosts_parent_id_idx;type:varchar(26)"`
|
ParentID *string `json:"parentId" gorm:"index:hosts_parent_id_idx;type:varchar(26)"`
|
||||||
@ -30,3 +31,26 @@ type Host struct {
|
|||||||
Timestamps
|
Timestamps
|
||||||
SoftDeletes
|
SoftDeletes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HostDecrypted struct {
|
||||||
|
Host
|
||||||
|
Key map[string]interface{}
|
||||||
|
AltKey map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Host) DecryptKeys() (*HostDecrypted, error) {
|
||||||
|
res := &HostDecrypted{Host: *h}
|
||||||
|
|
||||||
|
if h.Key.Data != "" {
|
||||||
|
if err := h.Key.DecryptData(&res.Key); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if h.AltKey.Data != "" {
|
||||||
|
if err := h.AltKey.DecryptData(&res.AltKey); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
KeychainTypeUserPass = "user"
|
KeychainTypeUserPass = "user"
|
||||||
|
KeychainTypePVE = "pve"
|
||||||
KeychainTypeRSA = "rsa"
|
KeychainTypeRSA = "rsa"
|
||||||
KeychainTypeCertificate = "cert"
|
KeychainTypeCertificate = "cert"
|
||||||
)
|
)
|
||||||
|
@ -30,7 +30,16 @@ func TestKeychainsCreate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// data := map[string]interface{}{
|
// data := map[string]interface{}{
|
||||||
// "type": "user",
|
// "type": "rsa",
|
||||||
|
// "label": "RSA Key",
|
||||||
|
// "data": map[string]interface{}{
|
||||||
|
// "private": "",
|
||||||
|
// "passphrase": "",
|
||||||
|
// },
|
||||||
|
// }
|
||||||
|
|
||||||
|
// data := map[string]interface{}{
|
||||||
|
// "type": "pve",
|
||||||
// "label": "PVE Key",
|
// "label": "PVE Key",
|
||||||
// "data": map[string]interface{}{
|
// "data": map[string]interface{}{
|
||||||
// "username": "root@pam",
|
// "username": "root@pam",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user