From 33dda374c736ae7f46948a0fd334f413f9726814 Mon Sep 17 00:00:00 2001 From: Khairul Hidayat Date: Thu, 14 Nov 2024 16:28:44 +0000 Subject: [PATCH] feat: add team member role change & removal, add uptime server stats, etc --- frontend/app/_providers.tsx | 2 + .../components/containers/dialog-message.tsx | 36 +++++ .../containers/server-stats-bar.tsx | 34 +++-- .../components/containers/theme-switcher.tsx | 2 +- frontend/components/ui/modal.tsx | 4 +- frontend/hooks/useDialog.ts | 14 ++ frontend/lib/utils.ts | 17 +++ .../team/components/change-role-form.tsx | 75 ++++++++++ .../pages/team/components/member-list.tsx | 42 +++++- frontend/pages/team/hooks/query.ts | 33 ++++- frontend/pages/team/page.tsx | 2 + frontend/pages/team/schema/team-form.ts | 14 +- server/app/router.go | 18 +-- server/app/teams/members/repository.go | 39 +++++ server/app/teams/members/router.go | 140 ++++++++++++++++++ server/app/teams/members/schema.go | 11 ++ server/app/teams/repository.go | 12 -- server/app/teams/router.go | 37 +---- server/app/teams/schema.go | 5 - server/app/users/repository.go | 7 + server/app/ws/stats/ssh.go | 32 +++- server/app/ws/term/ssh.go | 47 +++--- server/app/ws/term/term.go | 33 ++++- server/db/models.go | 1 + server/models/term_session.go | 19 +++ 25 files changed, 566 insertions(+), 110 deletions(-) create mode 100644 frontend/components/containers/dialog-message.tsx create mode 100644 frontend/hooks/useDialog.ts create mode 100644 frontend/pages/team/components/change-role-form.tsx create mode 100644 server/app/teams/members/repository.go create mode 100644 server/app/teams/members/router.go create mode 100644 server/app/teams/members/schema.go create mode 100644 server/models/term_session.go diff --git a/frontend/app/_providers.tsx b/frontend/app/_providers.tsx index 6fdb7b9..4e49f74 100644 --- a/frontend/app/_providers.tsx +++ b/frontend/app/_providers.tsx @@ -13,6 +13,7 @@ import { useAuthStore } from "@/stores/auth"; import { PortalProvider } from "tamagui"; import { useServer } from "@/stores/app"; import queryClient from "@/lib/queryClient"; +import DialogMessageProvider from "@/components/containers/dialog-message"; type Props = PropsWithChildren; @@ -45,6 +46,7 @@ const Providers = ({ children }: Props) => { {children} + diff --git a/frontend/components/containers/dialog-message.tsx b/frontend/components/containers/dialog-message.tsx new file mode 100644 index 0000000..dc7c880 --- /dev/null +++ b/frontend/components/containers/dialog-message.tsx @@ -0,0 +1,36 @@ +import { View, Text } from "react-native"; +import React from "react"; +import Modal from "../ui/modal"; +import { dialogStore } from "@/hooks/useDialog"; +import { Button, XStack } from "tamagui"; + +const DialogMessageProvider = () => { + const { data, onClose } = dialogStore.use(); + + return ( + + + + + + + + ); +}; + +export default DialogMessageProvider; diff --git a/frontend/components/containers/server-stats-bar.tsx b/frontend/components/containers/server-stats-bar.tsx index e6a64bc..7f0cde5 100644 --- a/frontend/components/containers/server-stats-bar.tsx +++ b/frontend/components/containers/server-stats-bar.tsx @@ -1,7 +1,8 @@ -import { View, Text, XStack, Separator } from "tamagui"; +import { View, Text, XStack, Separator, ScrollView } from "tamagui"; import React, { useState } from "react"; import { useWebSocket } from "@/hooks/useWebsocket"; import Icons from "../ui/icons"; +import { formatDuration } from "@/lib/utils"; type Props = { url: string; @@ -12,6 +13,7 @@ const ServerStatsBar = ({ url }: Props) => { const [memory, setMemory] = useState({ total: 0, used: 0, available: 0 }); const [disk, setDisk] = useState({ total: "0", used: "0", percent: "0%" }); const [network, setNetwork] = useState({ tx: 0, rx: 0 }); + const [uptime, setUptime] = useState(0); const { isConnected } = useWebSocket(url, { onMessage: (msg) => { @@ -44,6 +46,10 @@ const ServerStatsBar = ({ url }: Props) => { rx: parseInt(values[1]) || 0, }); break; + + case "\x05": + setUptime(parseInt(value) || 0); + break; } }, }); @@ -53,7 +59,15 @@ const ServerStatsBar = ({ url }: Props) => { } return ( - + @@ -61,21 +75,18 @@ const ServerStatsBar = ({ url }: Props) => { - - + {memory.used} MB / {memory.total} MB ( {Math.round((memory.used / memory.total) * 100) || 0}%) - - + {disk.used} / {disk.total} ({disk.percent}) - - + {network.rx} MB @@ -83,7 +94,12 @@ const ServerStatsBar = ({ url }: Props) => { {network.tx} MB - + + + + {formatDuration(uptime)} + + ); }; diff --git a/frontend/components/containers/theme-switcher.tsx b/frontend/components/containers/theme-switcher.tsx index 2d401cd..ba6ef25 100644 --- a/frontend/components/containers/theme-switcher.tsx +++ b/frontend/components/containers/theme-switcher.tsx @@ -18,7 +18,7 @@ const ThemeSwitcher = ({ iconSize = 18, ...props }: Props) => { size={iconSize} /> { const { open, onOpenChange } = disclosure.use(); @@ -65,7 +67,7 @@ const Modal = ({ p="$1" width="90%" maxWidth={width} - height="90%" + height={height} maxHeight={maxHeight} > diff --git a/frontend/hooks/useDialog.ts b/frontend/hooks/useDialog.ts new file mode 100644 index 0000000..1b33922 --- /dev/null +++ b/frontend/hooks/useDialog.ts @@ -0,0 +1,14 @@ +import { createDisclosure } from "@/lib/utils"; + +export type DialogData = { + title: string; + description?: string; + onConfirm?: () => void; + onCancel?: () => void; +}; + +export const dialogStore = createDisclosure(); + +export const showDialog = (data: DialogData) => { + dialogStore.onOpen(data); +}; diff --git a/frontend/lib/utils.ts b/frontend/lib/utils.ts index 8f416a3..6c25764 100644 --- a/frontend/lib/utils.ts +++ b/frontend/lib/utils.ts @@ -36,3 +36,20 @@ export const isHostnameOrIP = (value?: string | null) => { export const hostnameShape = (message: string = "Invalid hostname") => z.string().refine(isHostnameOrIP, { message }); + +export const formatDuration = (seconds: number) => { + const days = Math.floor(seconds / (24 * 3600)); + seconds %= 24 * 3600; + const hours = Math.floor(seconds / 3600); + seconds %= 3600; + const minutes = Math.floor(seconds / 60); + seconds = Math.floor(seconds % 60); + + const parts = []; + if (days > 0) parts.push(`${days} day${days > 1 ? "s" : ""}`); + if (hours > 0) parts.push(`${hours} hr${hours > 1 ? "s" : ""}`); + if (minutes > 0) parts.push(`${minutes} min`); + if (seconds > 0) parts.push(`${seconds} sec`); + + return parts.join(" ") || "0 seconds"; +}; diff --git a/frontend/pages/team/components/change-role-form.tsx b/frontend/pages/team/components/change-role-form.tsx new file mode 100644 index 0000000..97fa39f --- /dev/null +++ b/frontend/pages/team/components/change-role-form.tsx @@ -0,0 +1,75 @@ +import Icons from "@/components/ui/icons"; +import Modal from "@/components/ui/modal"; +import { useZForm } from "@/hooks/useZForm"; +import { createDisclosure } from "@/lib/utils"; +import React from "react"; +import { ScrollView, XStack } from "tamagui"; +import FormField from "@/components/ui/form"; +import { ErrorAlert } from "@/components/ui/alert"; +import Button from "@/components/ui/button"; +import { + SetRoleSchema, + setRoleSchema, + teamMemberRoles, +} from "../schema/team-form"; +import { SelectField } from "@/components/ui/select"; +import { useSetRoleMutation } from "../hooks/query"; + +export const changeRoleModal = createDisclosure(); + +const ChangeRoleForm = () => { + const { data } = changeRoleModal.use(); + const form = useZForm(setRoleSchema, data); + const setRole = useSetRoleMutation(data?.teamId || ""); + + const onSubmit = form.handleSubmit((values) => { + setRole.mutate(values, { + onSuccess: () => { + changeRoleModal.onClose(); + form.reset(); + }, + }); + }); + + return ( + + + + + + + + + + + + + + + ); +}; + +export default ChangeRoleForm; diff --git a/frontend/pages/team/components/member-list.tsx b/frontend/pages/team/components/member-list.tsx index a0f3890..1c9942c 100644 --- a/frontend/pages/team/components/member-list.tsx +++ b/frontend/pages/team/components/member-list.tsx @@ -3,6 +3,10 @@ import { Avatar, Button, ListItem, View, YGroup } from "tamagui"; import MenuButton from "@/components/ui/menu-button"; import Icons from "@/components/ui/icons"; import SearchInput from "@/components/ui/search-input"; +import { useTeamId } from "@/stores/auth"; +import { changeRoleModal } from "./change-role-form"; +import { useRemoveMemberMutation } from "../hooks/query"; +import { showDialog } from "@/hooks/useDialog"; type Props = { members?: any[]; @@ -10,7 +14,17 @@ type Props = { }; const MemberList = ({ members, allowWrite }: Props) => { + const teamId = useTeamId(); const [search, setSearch] = useState(""); + const remove = useRemoveMemberMutation(teamId); + + const onRemove = (member: any) => { + showDialog({ + title: "Remove Member", + description: "Are you sure you want to remove this member?", + onConfirm: () => remove.mutate(member.userId), + }); + }; const memberList = useMemo(() => { let items = members || []; @@ -51,7 +65,12 @@ const MemberList = ({ members, allowWrite }: Props) => { } iconAfter={ - allowWrite ? : undefined + allowWrite ? ( + onRemove(member)} + /> + ) : undefined } /> @@ -63,9 +82,10 @@ const MemberList = ({ members, allowWrite }: Props) => { type MemberActionButtonProps = { member: any; + onRemove: () => void; }; -const MemberActionButton = ({ member }: MemberActionButtonProps) => ( +const MemberActionButton = ({ member, onRemove }: MemberActionButtonProps) => ( ( /> } > - }> + } + onPress={() => + changeRoleModal.onOpen({ + teamId: member.teamId, + userId: member.userId, + role: member.role, + }) + } + > Change Role - }> + + } + onPress={onRemove} + > Remove Member diff --git a/frontend/pages/team/hooks/query.ts b/frontend/pages/team/hooks/query.ts index 1fbf0c4..1e0b288 100644 --- a/frontend/pages/team/hooks/query.ts +++ b/frontend/pages/team/hooks/query.ts @@ -1,6 +1,10 @@ import api from "@/lib/api"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { InviteSchema, TeamFormSchema } from "../schema/team-form"; +import { + InviteSchema, + SetRoleSchema, + TeamFormSchema, +} from "../schema/team-form"; import queryClient from "@/lib/queryClient"; import { setTeam, useTeamId } from "@/stores/auth"; import { router } from "expo-router"; @@ -28,7 +32,6 @@ export const useSaveTeam = () => { ? api(`/teams/${body.id}`, { method: "PUT", body }) : api(`/teams`, { method: "POST", body }); }, - onError: (e) => console.error(e), onSuccess: (res, body) => { queryClient.invalidateQueries({ queryKey: ["teams"] }); @@ -43,9 +46,31 @@ export const useSaveTeam = () => { export const useInviteMutation = (teamId: string | null) => { return useMutation({ mutationFn: async (body: InviteSchema) => { - return api(`/teams/${teamId}/invite`, { method: "POST", body }); + return api(`/teams/${teamId}/members`, { method: "POST", body }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["teams", teamId] }); + }, + }); +}; + +export const useSetRoleMutation = (teamId: string | null) => { + return useMutation({ + mutationFn: async (body: SetRoleSchema) => { + const url = `/teams/${teamId}/members/${body.userId}/role`; + return api(url, { method: "PUT", body }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["teams", teamId] }); + }, + }); +}; + +export const useRemoveMemberMutation = (teamId: string | null) => { + return useMutation({ + mutationFn: async (id: string) => { + return api(`/teams/${teamId}/members/${id}`, { method: "DELETE" }); }, - onError: (e) => console.error(e), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["teams", teamId] }); }, diff --git a/frontend/pages/team/page.tsx b/frontend/pages/team/page.tsx index 070c99d..08283e3 100644 --- a/frontend/pages/team/page.tsx +++ b/frontend/pages/team/page.tsx @@ -20,6 +20,7 @@ import tamaguiConfig from "@/tamagui.config"; import MemberList from "./components/member-list"; import { useUser } from "@/hooks/useUser"; import InviteForm, { inviteFormModal } from "./components/invite-form"; +import ChangeRoleForm from "./components/change-role-form"; export default function TeamPage() { const teamId = useTeamId(); @@ -71,6 +72,7 @@ export default function TeamPage() { + ); diff --git a/frontend/pages/team/schema/team-form.ts b/frontend/pages/team/schema/team-form.ts index ef362b7..ba40cc0 100644 --- a/frontend/pages/team/schema/team-form.ts +++ b/frontend/pages/team/schema/team-form.ts @@ -9,10 +9,12 @@ export const teamFormSchema = z.object({ export type TeamFormSchema = z.infer; +const teamRoles = ["owner", "admin", "member"] as const; + export const inviteSchema = z.object({ teamId: z.string().ulid(), username: z.string().min(1, { message: "Username/email is required" }), - role: z.enum(["owner", "admin", "member"], { + role: z.enum(teamRoles, { errorMap: () => ({ message: "Role is required" }), }), }); @@ -24,3 +26,13 @@ export const teamMemberRoles: SelectItem[] = [ ]; export type InviteSchema = z.infer; + +export const setRoleSchema = z.object({ + teamId: z.string().ulid(), + userId: z.string().ulid(), + role: z.enum(teamRoles, { + errorMap: () => ({ message: "Role is required" }), + }), +}); + +export type SetRoleSchema = z.infer; diff --git a/server/app/router.go b/server/app/router.go index 517de11..76f2b3c 100644 --- a/server/app/router.go +++ b/server/app/router.go @@ -5,21 +5,15 @@ import ( "rul.sh/vaulterm/app/hosts" "rul.sh/vaulterm/app/keychains" "rul.sh/vaulterm/app/teams" + "rul.sh/vaulterm/app/teams/members" "rul.sh/vaulterm/app/ws" ) func InitRouter(app *fiber.App) { // App route list - routes := []Router{ - hosts.Router, - keychains.Router, - teams.Router, - ws.Router, - } - - for _, route := range routes { - route(app) - } + hosts.Router(app) + keychains.Router(app) + teams := teams.Router(app) + members.Router(teams) + ws.Router(app) } - -type Router func(app fiber.Router) diff --git a/server/app/teams/members/repository.go b/server/app/teams/members/repository.go new file mode 100644 index 0000000..a1092af --- /dev/null +++ b/server/app/teams/members/repository.go @@ -0,0 +1,39 @@ +package members + +import ( + "gorm.io/gorm" + "gorm.io/gorm/clause" + "rul.sh/vaulterm/db" + "rul.sh/vaulterm/models" + "rul.sh/vaulterm/utils" +) + +type TeamMembers struct { + db *gorm.DB + User *utils.UserContext +} + +func NewRepository(r *TeamMembers) *TeamMembers { + if r == nil { + r = &TeamMembers{} + } + r.db = db.Get() + return r +} + +func (r *TeamMembers) Add(data *models.TeamMembers) error { + ret := r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(data) + return ret.Error +} + +func (r *TeamMembers) SetRole(data *models.TeamMembers) error { + ret := r.db. + Where("team_id = ? AND user_id = ?", data.TeamID, data.UserID). + Updates(&models.TeamMembers{Role: data.Role}) + return ret.Error +} + +func (r *TeamMembers) Remove(data *models.TeamMembers) error { + ret := r.db.Delete(&models.TeamMembers{TeamID: data.TeamID, UserID: data.UserID}) + return ret.Error +} diff --git a/server/app/teams/members/router.go b/server/app/teams/members/router.go new file mode 100644 index 0000000..5e33e83 --- /dev/null +++ b/server/app/teams/members/router.go @@ -0,0 +1,140 @@ +package members + +import ( + "errors" + + "github.com/gofiber/fiber/v2" + "rul.sh/vaulterm/app/teams" + "rul.sh/vaulterm/app/users" + "rul.sh/vaulterm/models" + "rul.sh/vaulterm/utils" +) + +func Router(app fiber.Router) { + router := app.Group("/:id/members") + + // router.Get("/", getAll) + router.Post("/", invite) + router.Put("/:userId/role", setRole) + router.Delete("/:userId", remove) +} + +// func getAll(c *fiber.Ctx) error { +// user := utils.GetUser(c) +// repo := NewRepository(&TeamMembers{User: user}) + +// rows, err := repo.GetAll() +// if err != nil { +// return utils.ResponseError(c, err, 500) +// } + +// return c.JSON(fiber.Map{"rows": rows}) +// } + +func invite(c *fiber.Ctx) error { + var body InviteSchema + if err := c.BodyParser(&body); err != nil { + return utils.ResponseError(c, err, 500) + } + + user := utils.GetUser(c) + teamRepo := teams.NewRepository(&teams.Teams{User: user}) + repo := NewRepository(&TeamMembers{User: user}) + + id := c.Params("id") + exist, _ := teamRepo.Exists(id) + if !exist { + return utils.ResponseError(c, errors.New("team not found"), 404) + } + if !user.TeamCanWrite(&id) { + return utils.ResponseError(c, errors.New("no access"), 403) + } + + userRepo := users.NewRepository(&users.Users{User: user}) + userData, _ := userRepo.Find(body.Username) + if userData.ID == "" { + return utils.ResponseError(c, errors.New("user not found"), 404) + } + + err := repo.Add(&models.TeamMembers{TeamID: id, UserID: userData.ID, Role: body.Role}) + if err != nil { + return utils.ResponseError(c, err, 500) + } + + return c.JSON(true) +} + +func setRole(c *fiber.Ctx) error { + var body PutRoleSchema + if err := c.BodyParser(&body); err != nil { + return utils.ResponseError(c, err, 500) + } + + user := utils.GetUser(c) + teamRepo := teams.NewRepository(&teams.Teams{User: user}) + repo := NewRepository(&TeamMembers{User: user}) + + id := c.Params("id") + userId := c.Params("userId") + + exist, _ := teamRepo.Exists(id) + if !exist { + return utils.ResponseError(c, errors.New("team not found"), 404) + } + if !user.TeamCanWrite(&id) { + return utils.ResponseError(c, errors.New("no access"), 403) + } + + userRepo := users.NewRepository(nil) + userData, _ := userRepo.Get(userId) + if userData == nil || userData.ID == "" { + return utils.ResponseError(c, errors.New("user not found"), 404) + } + if !userData.IsInTeam(&id) { + return utils.ResponseError(c, errors.New("user not in team"), 400) + } + + err := repo.SetRole(&models.TeamMembers{TeamID: id, UserID: userData.ID, Role: body.Role}) + if err != nil { + return utils.ResponseError(c, err, 500) + } + + return c.JSON(true) +} + +func remove(c *fiber.Ctx) error { + user := utils.GetUser(c) + teamRepo := teams.NewRepository(&teams.Teams{User: user}) + repo := NewRepository(&TeamMembers{User: user}) + + id := c.Params("id") + userId := c.Params("userId") + + exist, _ := teamRepo.Exists(id) + if !exist { + return utils.ResponseError(c, errors.New("team not found"), 404) + } + if !user.TeamCanWrite(&id) { + return utils.ResponseError(c, errors.New("no access"), 403) + } + + userRepo := users.NewRepository(&users.Users{User: user}) + userData, _ := userRepo.Get(userId) + if userData.ID == "" { + return utils.ResponseError(c, errors.New("user not found"), 404) + } + userRole := userData.GetTeamRole(&id) + if userRole == "" { + return utils.ResponseError(c, errors.New("user not in team"), 400) + } + if userRole == models.TeamRoleOwner { + return utils.ResponseError(c, errors.New("cannot remove owner"), 400) + } + + err := repo.Remove(&models.TeamMembers{TeamID: id, UserID: userData.ID}) + if err != nil { + return utils.ResponseError(c, err, 500) + } + + return c.JSON(true) +} diff --git a/server/app/teams/members/schema.go b/server/app/teams/members/schema.go new file mode 100644 index 0000000..e201ebf --- /dev/null +++ b/server/app/teams/members/schema.go @@ -0,0 +1,11 @@ +package members + +type InviteSchema struct { + Username string `json:"username"` + Role string `json:"role"` +} + +type PutRoleSchema struct { + UserID string `json:"username"` + Role string `json:"role"` +} diff --git a/server/app/teams/repository.go b/server/app/teams/repository.go index fb84366..cc024f5 100644 --- a/server/app/teams/repository.go +++ b/server/app/teams/repository.go @@ -2,7 +2,6 @@ package teams import ( "gorm.io/gorm" - "gorm.io/gorm/clause" "rul.sh/vaulterm/db" "rul.sh/vaulterm/models" "rul.sh/vaulterm/utils" @@ -94,14 +93,3 @@ func (r *Teams) Delete(id string) error { return nil }) } - -func (r *Teams) Invite(teamId string, userId string, role string) error { - ret := r.db. - Clauses(clause.OnConflict{DoNothing: true}). - Create(&models.TeamMembers{ - TeamID: teamId, - UserID: userId, - Role: role, - }) - return ret.Error -} diff --git a/server/app/teams/router.go b/server/app/teams/router.go index 980ee38..45d9284 100644 --- a/server/app/teams/router.go +++ b/server/app/teams/router.go @@ -5,12 +5,11 @@ import ( "net/http" "github.com/gofiber/fiber/v2" - "rul.sh/vaulterm/app/users" "rul.sh/vaulterm/models" "rul.sh/vaulterm/utils" ) -func Router(app fiber.Router) { +func Router(app fiber.Router) fiber.Router { router := app.Group("/teams") router.Get("/", getAll) @@ -18,7 +17,8 @@ func Router(app fiber.Router) { router.Post("/", create) router.Put("/:id", update) router.Delete("/:id", delete) - router.Post("/:id/invite", invite) + + return router } func getAll(c *fiber.Ctx) error { @@ -116,34 +116,3 @@ func delete(c *fiber.Ctx) error { return c.JSON(true) } - -func invite(c *fiber.Ctx) error { - var body InviteTeamSchema - if err := c.BodyParser(&body); err != nil { - return utils.ResponseError(c, err, 500) - } - - user := utils.GetUser(c) - repo := NewRepository(&Teams{User: user}) - - id := c.Params("id") - exist, _ := repo.Exists(id) - if !exist { - return utils.ResponseError(c, errors.New("team not found"), 404) - } - if !user.TeamCanWrite(&id) { - return utils.ResponseError(c, errors.New("no access"), 403) - } - - userRepo := users.NewRepository(&users.Users{User: user}) - userData, _ := userRepo.Find(body.Username) - if userData.ID == "" { - return utils.ResponseError(c, errors.New("user not found"), 404) - } - - if err := repo.Invite(id, userData.ID, body.Role); err != nil { - return utils.ResponseError(c, err, 500) - } - - return c.JSON(true) -} diff --git a/server/app/teams/schema.go b/server/app/teams/schema.go index 9dd3af9..31ec538 100644 --- a/server/app/teams/schema.go +++ b/server/app/teams/schema.go @@ -9,8 +9,3 @@ type GetOptions struct { ID string WithMembers bool } - -type InviteTeamSchema struct { - Username string `json:"username"` - Role string `json:"role"` -} diff --git a/server/app/users/repository.go b/server/app/users/repository.go index e3ebb1d..c13ad39 100644 --- a/server/app/users/repository.go +++ b/server/app/users/repository.go @@ -26,3 +26,10 @@ func (r *Users) Find(username string) (*models.User, error) { return &user, ret.Error } + +func (r *Users) Get(id string) (*models.User, error) { + var user models.User + ret := r.db.Preload("Teams").Where("id = ?", id).First(&user) + + return &user, ret.Error +} diff --git a/server/app/ws/stats/ssh.go b/server/app/ws/stats/ssh.go index 6800adc..9a383fa 100644 --- a/server/app/ws/stats/ssh.go +++ b/server/app/ws/stats/ssh.go @@ -32,11 +32,12 @@ func HandleSSHStats(c *websocket.Conn, client *lib.SSHClient) error { return default: wg := &sync.WaitGroup{} - wg.Add(4) + wg.Add(5) go getCPUUsage(client, wg, msgCh) go getMemoryUsage(client, wg, msgCh) go getDiskUsage(client, wg, msgCh) go getNetworkUsage(client, wg, msgCh) + go getUptime(client, wg, msgCh) wg.Wait() } } @@ -63,7 +64,8 @@ func HandleSSHStats(c *websocket.Conn, client *lib.SSHClient) error { func getCPUUsage(client *lib.SSHClient, wg *sync.WaitGroup, result chan<- string) { defer wg.Done() - cpuData, err := client.Exec("cat /proc/stat | grep '^cpu '") + cmd := "cat /proc/stat | grep '^cpu '" + cpuData, err := client.Exec(cmd) if err != nil { return } @@ -73,8 +75,7 @@ func getCPUUsage(client *lib.SSHClient, wg *sync.WaitGroup, result chan<- string } time.Sleep(time.Second) - - cpuData, err = client.Exec("cat /proc/stat | grep '^cpu '") + cpuData, err = client.Exec(cmd) if err != nil { return } @@ -207,3 +208,26 @@ func parseNetwork(data string) (int, int) { return txBytes, rxBytes } + +func getUptime(client *lib.SSHClient, wg *sync.WaitGroup, result chan<- string) { + defer wg.Done() + + // Try to read uptime from /proc/uptime + data, err := client.Exec("cat /proc/uptime") + if err != nil { + return + } + + data = strings.TrimSpace(data) + uptimeParts := strings.Split(data, " ") + if len(uptimeParts) < 1 { + return + } + + uptimeSeconds, err := strconv.ParseFloat(uptimeParts[0], 64) + if err != nil { + return + } + + result <- fmt.Sprintf("\x05%d", int(uptimeSeconds)) +} diff --git a/server/app/ws/term/ssh.go b/server/app/ws/term/ssh.go index 7cd6f7d..6c47c78 100644 --- a/server/app/ws/term/ssh.go +++ b/server/app/ws/term/ssh.go @@ -1,6 +1,7 @@ package term import ( + "bufio" "io" "log" "strconv" @@ -10,67 +11,75 @@ import ( "rul.sh/vaulterm/lib" ) -func NewSSHWebsocketSession(c *websocket.Conn, client *lib.SSHClient) error { +func NewSSHWebsocketSession(c *websocket.Conn, client *lib.SSHClient) ([]byte, error) { if err := client.Connect(); err != nil { log.Printf("error connecting to SSH: %v", err) - return err + return nil, err } defer client.Close() shell, err := client.StartPtyShell() if err != nil { log.Printf("error starting SSH shell: %v", err) - return err + return nil, err } session := shell.Session defer session.Close() - // Goroutine to send SSH stdout to WebSocket + sessionCapture, sessionCaptureWriter := io.Pipe() + defer sessionCapture.Close() + sessionLog := []byte{} + + // Capture SSH session output + go func() { + reader := bufio.NewReader(sessionCapture) + for { + b, err := reader.ReadBytes('\n') + if err != nil { + break + } + sessionLog = append(sessionLog, b...) + } + }() + + // Pass SSH stdout to WebSocket go func() { buf := make([]byte, 1024) for { n, err := shell.Stdout.Read(buf) if err != nil { - if err != io.EOF { - log.Printf("error reading from SSH stdout: %v", err) - } break } - + sessionCaptureWriter.Write(buf[:n]) if err := c.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil { - log.Printf("error writing to websocket: %v", err) break } } }() - // Goroutine to handle SSH stderr + // Pass SSH stderr to WebSocket go func() { buf := make([]byte, 1024) for { n, err := shell.Stderr.Read(buf) if err != nil { - if err != io.EOF { - log.Printf("error reading from SSH stderr: %v", err) - } break } + sessionCaptureWriter.Write(buf[:n]) if err := c.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil { - log.Printf("error writing to websocket: %v", err) break } } }() - // Handle WebSocket to SSH data streaming + // Handle user input go func() { defer session.Close() for { _, msg, err := c.ReadMessage() if err != nil { - log.Printf("error reading from websocket: %v", err) break } @@ -86,15 +95,13 @@ func NewSSHWebsocketSession(c *websocket.Conn, client *lib.SSHClient) error { shell.Stdin.Write(msg) } - - log.Println("SSH session closed") }() // Wait for the SSH session to close if err := session.Wait(); err != nil { log.Printf("SSH session ended with error: %v", err) - return err + return sessionLog, err } - return nil + return sessionLog, nil } diff --git a/server/app/ws/term/term.go b/server/app/ws/term/term.go index 776ee52..c3c012a 100644 --- a/server/app/ws/term/term.go +++ b/server/app/ws/term/term.go @@ -2,9 +2,11 @@ package term import ( "log" + "time" "github.com/gofiber/contrib/websocket" "rul.sh/vaulterm/app/hosts" + "rul.sh/vaulterm/db" "rul.sh/vaulterm/lib" "rul.sh/vaulterm/models" "rul.sh/vaulterm/utils" @@ -23,9 +25,19 @@ func HandleTerm(c *websocket.Conn) { return } + log := "" + term := &models.TermSession{ + UserID: user.ID, + HostID: hostId, + Reason: c.Query("reason"), + } + if err := db.Get().Create(term).Error; err != nil { + return + } + switch data.Host.Type { case "ssh": - sshHandler(c, data) + sshHandler(c, data, &log) case "pve": pveHandler(c, data) case "incus": @@ -33,9 +45,18 @@ func HandleTerm(c *websocket.Conn) { default: c.WriteMessage(websocket.TextMessage, []byte("Invalid host type")) } + + // save session log + endsAt := time.Now() + db.Get(). + Where("id = ?", term.ID). + Updates(&models.TermSession{ + EndsAt: &endsAt, + Log: log, + }) } -func sshHandler(c *websocket.Conn, data *models.HostDecrypted) { +func sshHandler(c *websocket.Conn, data *models.HostDecrypted, log *string) { cfg := lib.NewSSHClient(&lib.SSHClientConfig{ HostName: data.Host.Host, Port: data.Port, @@ -43,9 +64,15 @@ func sshHandler(c *websocket.Conn, data *models.HostDecrypted) { AltKey: data.AltKey, }) - if err := NewSSHWebsocketSession(c, cfg); err != nil { + out, err := NewSSHWebsocketSession(c, cfg) + if err != nil { c.WriteMessage(websocket.TextMessage, []byte(err.Error())) } + + // copy output + if log != nil { + *log = string(out) + } } func pveHandler(c *websocket.Conn, data *models.HostDecrypted) { diff --git a/server/db/models.go b/server/db/models.go index fe3867d..9c2766e 100644 --- a/server/db/models.go +++ b/server/db/models.go @@ -11,4 +11,5 @@ var Models = []interface{}{ &models.Host{}, &models.Team{}, &models.TeamMembers{}, + &models.TermSession{}, } diff --git a/server/models/term_session.go b/server/models/term_session.go new file mode 100644 index 0000000..682de21 --- /dev/null +++ b/server/models/term_session.go @@ -0,0 +1,19 @@ +package models + +import "time" + +type TermSession struct { + Model + + UserID string `json:"userId" gorm:"type:varchar(26)"` + User User `json:"user" gorm:"foreignKey:UserID"` + HostID string `json:"hostId" gorm:"type:varchar(26)"` + Host Host `json:"host" gorm:"foreignKey:HostID"` + + Reason string `json:"reason" gorm:"type:varchar(255)"` + Log string `json:"log" gorm:"type:text"` + + EndsAt *time.Time `json:"endsAt"` + + Timestamps +}