feat: update

This commit is contained in:
Khairul Hidayat 2024-11-09 14:37:09 +00:00
parent 3ef0c93c8f
commit b50abccae0
26 changed files with 728 additions and 254 deletions

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

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

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

View File

@ -6,6 +6,12 @@ export const BASE_WS_URL = BASE_API_URL.replace("http", "ws");
const api = ofetch.create({
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();

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

View File

@ -1,22 +1,19 @@
import Icons from "@/components/ui/icons";
import Modal from "@/components/ui/modal";
import { SelectField } from "@/components/ui/select";
import { useZForm, UseZFormReturn } from "@/hooks/useZForm";
import api from "@/lib/api";
import { useZForm } from "@/hooks/useZForm";
import { createDisclosure } from "@/lib/utils";
import { useQuery } from "@tanstack/react-query";
import React from "react";
import { Button, Label, ScrollView, XStack } from "tamagui";
import {
FormSchema,
formSchema,
incusTypes,
pveTypes,
typeOptions,
} from "../schema/form";
import { ScrollView, XStack } from "tamagui";
import { FormSchema, formSchema, typeOptions } from "../schema/form";
import { InputField } from "@/components/ui/input";
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>();
@ -26,7 +23,6 @@ const HostForm = () => {
const isEditing = data?.id != null;
const type = form.watch("type");
const keys = useKeychains();
const saveMutation = useSaveHost();
const onSubmit = form.handleSubmit((values) => {
@ -44,6 +40,8 @@ const HostForm = () => {
title="Host"
description={`${isEditing ? "Edit" : "Add new"} host.`}
>
<ErrorAlert mx="$4" mb="$4" error={saveMutation.error} />
<ScrollView contentContainerStyle={{ padding: "$4", pt: 0, gap: "$4" }}>
<FormField label="Label">
<InputField f={1} form={form} name="label" placeholder="Label..." />
@ -62,47 +60,17 @@ const HostForm = () => {
form={form}
name="port"
keyboardType="number-pad"
placeholder="SSH Port"
placeholder="Port"
/>
</FormField>
{type === "pve" && <PVEFormFields form={form} />}
{type === "incus" && <IncusFormFields form={form} />}
<XStack gap="$3">
<Label flex={1} h="$3">
Credentials
</Label>
<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>
)}
{type === "ssh" ? (
<SSHFormFields form={form} />
) : type === "pve" ? (
<PVEFormFields form={form} />
) : type === "incus" ? (
<IncusFormFields form={form} />
) : null}
</ScrollView>
<XStack p="$4" gap="$4">
@ -113,6 +81,7 @@ const HostForm = () => {
flex={1}
icon={<Icons name="content-save" size={18} />}
onPress={onSubmit}
isLoading={saveMutation.isPending}
>
Save
</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;

View File

@ -8,8 +8,13 @@ import Icons from "@/components/ui/icons";
import SearchInput from "@/components/ui/search-input";
import { useTermSession } from "@/stores/terminal-sessions";
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 navigation = useNavigation();
const [search, setSearch] = useState("");
@ -37,6 +42,7 @@ const HostsList = () => {
}, [hosts.data, search]);
const onOpen = (host: any) => {
if (!allowEdit) return;
hostFormModal.onOpen(host);
};
@ -88,9 +94,9 @@ const HostsList = () => {
flexBasis="100%"
cursor="pointer"
$gtXs={{ flexBasis: "50%" }}
$gtSm={{ flexBasis: "33.3%" }}
$gtMd={{ flexBasis: "25%" }}
$gtLg={{ flexBasis: "20%" }}
$gtMd={{ flexBasis: "33.3%" }}
$gtLg={{ flexBasis: "25%" }}
$gtXl={{ flexBasis: "20%" }}
p="$2"
group
numberOfTaps={2}
@ -99,6 +105,13 @@ const HostsList = () => {
>
<Card bordered p="$4">
<XStack>
<OSIcons
name={host.os}
size={18}
mr="$2"
fallback="desktop-classic"
/>
<View flex={1}>
<Text>{host.label}</Text>
<Text fontSize="$3" mt="$2">
@ -106,6 +119,7 @@ const HostsList = () => {
</Text>
</View>
{allowEdit && (
<Button
circular
display="none"
@ -118,6 +132,7 @@ const HostsList = () => {
>
<Icons name="pencil" size={16} />
</Button>
)}
</XStack>
</Card>
</MultiTapPressable>

View 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>
</>
);
};

View 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>
</>
);
};

View 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>
</>
);
};

View File

@ -1,6 +1,7 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { FormSchema } from "../schema/form";
import api, { queryClient } from "@/lib/api";
import { useMemo } from "react";
export const useKeychains = () => {
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 = () => {
return useMutation({
mutationFn: async (body: FormSchema) => {

View File

@ -0,0 +1,6 @@
import { UseZFormReturn } from "@/hooks/useZForm";
import { FormSchema } from "./schema/form";
export type MiscFormFieldProps = {
form: UseZFormReturn<FormSchema>;
};

View File

@ -35,7 +35,7 @@ const TerminalPage = () => {
style={{ flex: 1 }}
page={curSession}
onChangePage={setSession}
EmptyComponent={HostsList}
EmptyComponent={() => <HostsList allowEdit={false} />}
>
{sessions.map((session) => (
<InteractiveSession key={session.id} {...session} />

View File

@ -8,7 +8,7 @@ import (
type Hosts struct{ db *gorm.DB }
func NewHostsRepository() *Hosts {
func NewRepository() *Hosts {
return &Hosts{db: db.Get()}
}
@ -19,13 +19,7 @@ func (r *Hosts) GetAll() ([]*models.Host, error) {
return rows, ret.Error
}
type GetHostResult struct {
Host *models.Host
Key map[string]interface{}
AltKey map[string]interface{}
}
func (r *Hosts) Get(id string) (*GetHostResult, error) {
func (r *Hosts) Get(id string) (*models.HostDecrypted, error) {
var host models.Host
ret := r.db.Joins("Key").Joins("AltKey").Where("hosts.id = ?", id).First(&host)
@ -33,18 +27,10 @@ func (r *Hosts) Get(id string) (*GetHostResult, error) {
return nil, ret.Error
}
res := &GetHostResult{Host: &host}
if host.Key.Data != "" {
if err := host.Key.DecryptData(&res.Key); err != nil {
res, err := host.DecryptKeys()
if 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
}

View File

@ -19,7 +19,7 @@ func Router(app *fiber.App) {
}
func getAll(c *fiber.Ctx) error {
repo := NewHostsRepository()
repo := NewRepository()
rows, err := repo.GetAll()
if err != nil {
return utils.ResponseError(c, err, 500)
@ -36,7 +36,7 @@ func create(c *fiber.Ctx) error {
return utils.ResponseError(c, err, 500)
}
repo := NewHostsRepository()
repo := NewRepository()
item := &models.Host{
Type: body.Type,
Label: body.Label,
@ -47,6 +47,13 @@ func create(c *fiber.Ctx) error {
KeyID: body.KeyID,
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 {
return utils.ResponseError(c, err, 500)
}
@ -60,7 +67,7 @@ func update(c *fiber.Ctx) error {
return utils.ResponseError(c, err, 500)
}
repo := NewHostsRepository()
repo := NewRepository()
id := c.Params("id")
exist, _ := repo.Exists(id)
@ -79,6 +86,13 @@ func update(c *fiber.Ctx) error {
KeyID: body.KeyID,
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 {
return utils.ResponseError(c, err, 500)
}
@ -87,7 +101,7 @@ func update(c *fiber.Ctx) error {
}
func delete(c *fiber.Ctx) error {
repo := NewHostsRepository()
repo := NewRepository()
id := c.Params("id")
exist, _ := repo.Exists(id)

54
server/app/hosts/utils.go Normal file
View 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
}

View File

@ -8,7 +8,7 @@ import (
type Keychains struct{ db *gorm.DB }
func NewKeychainsRepository() *Keychains {
func NewRepository() *Keychains {
return &Keychains{db: db.Get()}
}
@ -22,3 +22,31 @@ func (r *Keychains) GetAll() ([]*models.Keychain, error) {
func (r *Keychains) Create(item *models.Keychain) 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
}

View File

@ -16,7 +16,7 @@ func Router(app *fiber.App) {
}
func getAll(c *fiber.Ctx) error {
repo := NewKeychainsRepository()
repo := NewRepository()
rows, err := repo.GetAll()
if err != nil {
return utils.ResponseError(c, err, 500)
@ -33,7 +33,7 @@ func create(c *fiber.Ctx) error {
return utils.ResponseError(c, err, 500)
}
repo := NewKeychainsRepository()
repo := NewRepository()
item := &models.Keychain{
Type: body.Type,

View File

@ -6,13 +6,14 @@ import (
"github.com/gofiber/contrib/websocket"
"rul.sh/vaulterm/app/hosts"
"rul.sh/vaulterm/lib"
"rul.sh/vaulterm/models"
"rul.sh/vaulterm/utils"
)
func HandleTerm(c *websocket.Conn) {
hostId := c.Query("hostId")
hostRepo := hosts.NewHostsRepository()
hostRepo := hosts.NewRepository()
data, err := hostRepo.Get(hostId)
if data == nil {
@ -33,30 +34,27 @@ func HandleTerm(c *websocket.Conn) {
}
}
func sshHandler(c *websocket.Conn, data *hosts.GetHostResult) {
username, _ := data.Key["username"].(string)
password, _ := data.Key["password"].(string)
cfg := &SSHConfig{
func sshHandler(c *websocket.Conn, data *models.HostDecrypted) {
cfg := lib.NewSSHClient(&lib.SSHClientConfig{
HostName: data.Host.Host,
Port: data.Host.Port,
User: username,
Password: password,
}
Port: data.Port,
Key: data.Key,
AltKey: data.AltKey,
})
if err := NewSSHWebsocketSession(c, cfg); err != nil {
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")
username, _ := data.Key["username"].(string)
password, _ := data.Key["password"].(string)
pve := &lib.PVEServer{
HostName: data.Host.Host,
Port: data.Host.Port,
Port: data.Port,
Username: username,
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")
cert, _ := data.Key["cert"].(string)
@ -97,7 +95,7 @@ func incusHandler(c *websocket.Conn, data *hosts.GetHostResult) {
incus := &lib.IncusServer{
HostName: data.Host.Host,
Port: data.Host.Port,
Port: data.Port,
ClientCert: cert,
ClientKey: key,
}

View File

@ -1,101 +1,37 @@
package ws
import (
"fmt"
"io"
"log"
"strconv"
"strings"
"github.com/gofiber/contrib/websocket"
"golang.org/x/crypto/ssh"
"rul.sh/vaulterm/lib"
)
type SSHConfig struct {
HostName string
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)
func NewSSHWebsocketSession(c *websocket.Conn, client *lib.SSHClient) error {
con, err := client.Connect()
if err != nil {
log.Printf("error connecting to SSH: %v", err)
return err
}
defer sshConn.Close()
defer con.Close()
// Start an SSH shell session
session, err := sshConn.NewSession()
shell, err := client.StartPtyShell(con)
if err != nil {
log.Printf("error starting SSH shell: %v", err)
return err
}
session := shell.Session
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
go func() {
buf := make([]byte, 1024)
for {
n, err := stdoutPipe.Read(buf)
n, err := shell.Stdout.Read(buf)
if err != nil {
if err != io.EOF {
log.Printf("error reading from SSH stdout: %v", err)
@ -114,7 +50,7 @@ func NewSSHWebsocketSession(c *websocket.Conn, cfg *SSHConfig) error {
go func() {
buf := make([]byte, 1024)
for {
n, err := stderrPipe.Read(buf)
n, err := shell.Stderr.Read(buf)
if err != nil {
if err != io.EOF {
log.Printf("error reading from SSH stderr: %v", err)
@ -135,6 +71,7 @@ func NewSSHWebsocketSession(c *websocket.Conn, cfg *SSHConfig) error {
for {
_, msg, err := c.ReadMessage()
if err != nil {
log.Printf("error reading from websocket: %v", err)
break
}
@ -148,8 +85,10 @@ func NewSSHWebsocketSession(c *websocket.Conn, cfg *SSHConfig) error {
continue
}
stdinPipe.Write(msg)
shell.Stdin.Write(msg)
}
log.Println("SSH session closed")
}()
// Wait for the SSH session to close
@ -158,6 +97,5 @@ func NewSSHWebsocketSession(c *websocket.Conn, cfg *SSHConfig) error {
return err
}
log.Println("SSH session ended normally")
return nil
}

View File

@ -114,6 +114,10 @@ func Decrypt(encrypted string) (string, error) {
return "", err
}
if len(data) < 16 {
return "", fmt.Errorf("invalid encrypted data")
}
block, err := aes.NewCipher(keyDec)
if err != nil {
return "", err

32
server/lib/os.go Normal file
View 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
View 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
}

View File

@ -18,6 +18,7 @@ type Host struct {
Label string `json:"label"`
Host string `json:"host" gorm:"type:varchar(64)"`
Port int `json:"port" gorm:"type:smallint"`
OS string `json:"os" gorm:"type:varchar(32)"`
Metadata datatypes.JSONMap `json:"metadata"`
ParentID *string `json:"parentId" gorm:"index:hosts_parent_id_idx;type:varchar(26)"`
@ -30,3 +31,26 @@ type Host struct {
Timestamps
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
}

View File

@ -8,6 +8,7 @@ import (
const (
KeychainTypeUserPass = "user"
KeychainTypePVE = "pve"
KeychainTypeRSA = "rsa"
KeychainTypeCertificate = "cert"
)

View File

@ -30,7 +30,16 @@ func TestKeychainsCreate(t *testing.T) {
}
// 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",
// "data": map[string]interface{}{
// "username": "root@pam",