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 (
+
+
+
+
+
+
+
+
+
+
+
+ }
+ onPress={onSubmit}
+ isLoading={setRole.isPending}
+ >
+ Update Role
+
+
+
+ );
+};
+
+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
+}