From 11b063c2fa5ee78714a8880be1c6f3aba90c6a24 Mon Sep 17 00:00:00 2001 From: Khairul Hidayat Date: Thu, 7 Nov 2024 19:07:41 +0000 Subject: [PATCH] feat: init api, db, app --- frontend/app/index.tsx | 38 +++-- .../containers/interactive-session.tsx | 24 +-- frontend/components/containers/terminal.tsx | 4 +- frontend/components/containers/vncviewer.tsx | 6 + server/.gitignore | 1 + server/Makefile | 15 ++ server/app/app.go | 37 +++++ server/app/auth/repository.go | 50 ++++++ server/app/auth/router.go | 93 +++++++++++ server/app/auth/schema.go | 6 + server/app/error_handler.go | 25 +++ server/app/hosts/repository.go | 54 ++++++ server/app/hosts/router.go | 53 ++++++ server/app/hosts/schema.go | 15 ++ server/app/keychains/repository.go | 24 +++ server/app/keychains/router.go | 52 ++++++ server/app/keychains/schema.go | 7 + server/app/ws/router.go | 19 +++ server/app/ws/term.go | 115 +++++++++++++ .../incus_session.go => app/ws/term_incus.go} | 16 +- .../pve_session.go => app/ws/term_pve.go} | 20 +-- .../ssh_session.go => app/ws/term_ssh.go} | 2 +- server/db/database.go | 60 +++++++ server/db/models.go | 10 ++ server/db/seeders.go | 63 +++++++ server/go.mod | 31 +++- server/go.sum | 54 ++++++ server/lib/crypto.go | 99 +++++++++++ server/lib/incus.go | 3 + server/lib/pve.go | 7 +- server/main.go | 87 +--------- server/models/base_model.go | 31 ++++ server/models/host.go | 32 ++++ server/models/keychain.go | 53 ++++++ server/models/user.go | 28 ++++ server/tests/app_test.go | 17 ++ server/tests/auth_test.go | 37 +++++ server/tests/hosts_test.go | 74 +++++++++ server/tests/keychains_test.go | 55 ++++++ server/tests/setup_test.go | 24 +++ server/tests/utils.go | 156 ++++++++++++++++++ server/utils/http.go | 14 ++ server/utils/parser.go | 15 ++ 43 files changed, 1488 insertions(+), 138 deletions(-) create mode 100644 server/Makefile create mode 100644 server/app/app.go create mode 100644 server/app/auth/repository.go create mode 100644 server/app/auth/router.go create mode 100644 server/app/auth/schema.go create mode 100644 server/app/error_handler.go create mode 100644 server/app/hosts/repository.go create mode 100644 server/app/hosts/router.go create mode 100644 server/app/hosts/schema.go create mode 100644 server/app/keychains/repository.go create mode 100644 server/app/keychains/router.go create mode 100644 server/app/keychains/schema.go create mode 100644 server/app/ws/router.go create mode 100644 server/app/ws/term.go rename server/{lib/incus_session.go => app/ws/term_incus.go} (83%) rename server/{lib/pve_session.go => app/ws/term_pve.go} (89%) rename server/{lib/ssh_session.go => app/ws/term_ssh.go} (99%) create mode 100644 server/db/database.go create mode 100644 server/db/models.go create mode 100644 server/db/seeders.go create mode 100644 server/models/base_model.go create mode 100644 server/models/host.go create mode 100644 server/models/keychain.go create mode 100644 server/models/user.go create mode 100644 server/tests/app_test.go create mode 100644 server/tests/auth_test.go create mode 100644 server/tests/hosts_test.go create mode 100644 server/tests/keychains_test.go create mode 100644 server/tests/setup_test.go create mode 100644 server/tests/utils.go create mode 100644 server/utils/http.go create mode 100644 server/utils/parser.go diff --git a/frontend/app/index.tsx b/frontend/app/index.tsx index a93e325..7de956a 100644 --- a/frontend/app/index.tsx +++ b/frontend/app/index.tsx @@ -12,24 +12,28 @@ const HomePage = () => { const [sessions, setSessions] = useState([ { id: "1", - type: "incus", - params: { client: "xtermjs", serverId: "1", shell: "bash" }, + type: "ssh", + params: { hostId: "01jc3v9w609f8e2wzw60amv195" }, + }, + { + id: "2", + type: "pve", + params: { client: "vnc", hostId: "01jc3wp2b3zvgr777f4e3caw4w" }, + }, + { + id: "3", + type: "pve", + params: { client: "xtermjs", hostId: "01jc3z3yyn2fgb77tyfxc1tkfy" }, + }, + { + id: "4", + type: "incus", + params: { + client: "xtermjs", + hostId: "01jc3xz9db0v54dg10hk70a13b", + shell: "fish", + }, }, - // { - // id: "1", - // type: "ssh", - // params: { serverId: "1" }, - // }, - // { - // id: "2", - // type: "pve", - // params: { client: "vnc", serverId: "2" }, - // }, - // { - // id: "3", - // type: "pve", - // params: { client: "xtermjs", serverId: "3" }, - // }, ]); const [curSession, setSession] = useState(0); diff --git a/frontend/components/containers/interactive-session.tsx b/frontend/components/containers/interactive-session.tsx index bc37e05..8262db5 100644 --- a/frontend/components/containers/interactive-session.tsx +++ b/frontend/components/containers/interactive-session.tsx @@ -6,7 +6,7 @@ import VNCViewer from "./vncviewer"; type SSHSessionProps = { type: "ssh"; params: { - serverId: string; + hostId: string; }; }; @@ -14,7 +14,7 @@ type PVESessionProps = { type: "pve"; params: { client: "vnc" | "xtermjs"; - serverId: string; + hostId: string; }; }; @@ -22,7 +22,7 @@ type IncusSessionProps = { type: "incus"; params: { client: "vnc" | "xtermjs"; - serverId: string; + hostId: string; shell?: string; }; }; @@ -33,29 +33,19 @@ export type InteractiveSessionProps = | IncusSessionProps; const InteractiveSession = ({ type, params }: InteractiveSessionProps) => { - let url = ""; - const query = new URLSearchParams({ - ...params, - }); + const query = new URLSearchParams(params); + const url = `${BASE_WS_URL}/ws/term?${query}`; switch (type) { case "ssh": - return ; + return ; case "pve": - url = `${BASE_WS_URL}/ws/pve?${query}`; - return params.client === "vnc" ? ( - - ) : ( - - ); - case "incus": - url = `${BASE_WS_URL}/ws/incus?${query}`; return params.client === "vnc" ? ( ) : ( - + ); default: diff --git a/frontend/components/containers/terminal.tsx b/frontend/components/containers/terminal.tsx index 5953abf..7429cec 100644 --- a/frontend/components/containers/terminal.tsx +++ b/frontend/components/containers/terminal.tsx @@ -28,7 +28,7 @@ const Keys = { type XTermJsProps = { client?: "xtermjs"; - wsUrl: string; + url: string; }; type TerminalProps = ComponentPropsWithoutRef & XTermJsProps; @@ -50,7 +50,7 @@ const Terminal = ({ client = "xtermjs", style, ...props }: TerminalProps) => { )} diff --git a/frontend/components/containers/vncviewer.tsx b/frontend/components/containers/vncviewer.tsx index ea5c648..381d872 100644 --- a/frontend/components/containers/vncviewer.tsx +++ b/frontend/components/containers/vncviewer.tsx @@ -19,6 +19,12 @@ const VNCViewer = ({ ...props }: VNCViewerProps) => { const rfb = new RFB(screenRef.current!, props.url); rfb.scaleViewport = true; + const canvas: HTMLCanvasElement | null = + rfb._target?.querySelector("canvas"); + if (canvas) { + canvas.style.cursor = "default"; + } + // @ts-ignore const ws: WebSocket = rfb._sock._websocket; let password: string | null = null; diff --git a/server/.gitignore b/server/.gitignore index 47760cd..33ca294 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -1,2 +1,3 @@ tmp/ .env +/data.db* diff --git a/server/Makefile b/server/Makefile new file mode 100644 index 0000000..3a6a6b9 --- /dev/null +++ b/server/Makefile @@ -0,0 +1,15 @@ +# Makefile + +.PHONY: test build run + +# Run tests sequentially with verbose output +test: + go test -count 1 -p 1 -v rul.sh/vaulterm/tests + +# Build the Go application +build: + go build -o myapp . + +# Run the built application +run: build + ./myapp diff --git a/server/app/app.go b/server/app/app.go new file mode 100644 index 0000000..498e194 --- /dev/null +++ b/server/app/app.go @@ -0,0 +1,37 @@ +package app + +import ( + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/joho/godotenv" + "rul.sh/vaulterm/app/auth" + "rul.sh/vaulterm/app/hosts" + "rul.sh/vaulterm/app/keychains" + "rul.sh/vaulterm/app/ws" + "rul.sh/vaulterm/db" +) + +func NewApp() *fiber.App { + // Load deps + godotenv.Load() + db.Init() + + // Create fiber app + app := fiber.New(fiber.Config{ErrorHandler: ErrorHandler}) + + // Middlewares + app.Use(cors.New()) + + // Init app routes + auth.Router(app) + hosts.Router(app) + keychains.Router(app) + ws.Router(app) + + // Health check + app.Get("/health-check", func(c *fiber.Ctx) error { + return c.SendString("OK") + }) + + return app +} diff --git a/server/app/auth/repository.go b/server/app/auth/repository.go new file mode 100644 index 0000000..4c3c119 --- /dev/null +++ b/server/app/auth/repository.go @@ -0,0 +1,50 @@ +package auth + +import ( + "gorm.io/gorm" + "rul.sh/vaulterm/db" + "rul.sh/vaulterm/lib" + "rul.sh/vaulterm/models" +) + +type Auth struct{ db *gorm.DB } + +func NewAuthRepository() *Auth { + return &Auth{db: db.Get()} +} + +func (r *Auth) FindUser(username string) (*models.User, error) { + var user models.User + ret := r.db.Where("username = ? OR email = ?", username, username).First(&user) + + return &user, ret.Error +} + +func (r *Auth) CreateUserSession(user *models.User) (string, error) { + sessionId, err := lib.GenerateSessionID(20) + if err != nil { + return "", err + } + + if ret := r.db.Create(&models.UserSession{ID: sessionId, UserID: user.ID}); ret.Error != nil { + return "", ret.Error + } + + return sessionId, nil +} + +func (r *Auth) GetSession(sessionId string) (*models.UserSession, error) { + var session models.UserSession + res := r.db.Joins("User").Where(&models.UserSession{ID: sessionId}).First(&session) + return &session, res.Error +} + +func (r *Auth) RemoveUserSession(sessionId string, force bool) error { + db := r.db + if force { + db = db.Unscoped() + } + + res := db.Delete(&models.UserSession{ID: sessionId}) + return res.Error +} diff --git a/server/app/auth/router.go b/server/app/auth/router.go new file mode 100644 index 0000000..823d4f9 --- /dev/null +++ b/server/app/auth/router.go @@ -0,0 +1,93 @@ +package auth + +import ( + "strings" + + "github.com/gofiber/fiber/v2" + "rul.sh/vaulterm/lib" + "rul.sh/vaulterm/utils" +) + +func Router(app *fiber.App) { + router := app.Group("/auth") + + router.Post("/login", login) + router.Get("/user", getUser) + router.Post("/logout", logout) +} + +func login(c *fiber.Ctx) error { + repo := NewAuthRepository() + + var body LoginSchema + if err := c.BodyParser(&body); err != nil { + return &fiber.Error{ + Code: fiber.StatusBadRequest, + Message: err.Error(), + } + } + + user, err := repo.FindUser(body.Username) + if err != nil { + return &fiber.Error{ + Code: fiber.StatusUnauthorized, + Message: "Username or password is invalid", + } + } + + if valid := lib.VerifyPassword(body.Password, user.Password); !valid { + return &fiber.Error{ + Code: fiber.StatusUnauthorized, + Message: "Username or password is invalid", + } + } + + sessionId, err := repo.CreateUserSession(user) + if err != nil { + return utils.ResponseError(c, err, 500) + } + + return c.JSON(fiber.Map{ + "user": user, + "sessionId": sessionId, + }) +} + +func getUser(c *fiber.Ctx) error { + auth := c.Get("Authorization") + var sessionId string + + if auth != "" { + sessionId = strings.Split(auth, " ")[1] + } + + repo := NewAuthRepository() + session, err := repo.GetSession(sessionId) + if err != nil { + return utils.ResponseError(c, err, 500) + } + + return c.JSON(session) +} + +func logout(c *fiber.Ctx) error { + auth := c.Get("Authorization") + force := c.Query("force") + var sessionId string + + if auth != "" { + sessionId = strings.Split(auth, " ")[1] + } + + repo := NewAuthRepository() + err := repo.RemoveUserSession(sessionId, force == "true") + + if err != nil { + return utils.ResponseError(c, err, 500) + } + + return c.JSON(fiber.Map{ + "status": "ok", + "message": "Successfully logged out", + }) +} diff --git a/server/app/auth/schema.go b/server/app/auth/schema.go new file mode 100644 index 0000000..9376ffa --- /dev/null +++ b/server/app/auth/schema.go @@ -0,0 +1,6 @@ +package auth + +type LoginSchema struct { + Username string `json:"username"` + Password string `json:"password"` +} diff --git a/server/app/error_handler.go b/server/app/error_handler.go new file mode 100644 index 0000000..e0489ee --- /dev/null +++ b/server/app/error_handler.go @@ -0,0 +1,25 @@ +package app + +import ( + "errors" + + "github.com/gofiber/fiber/v2" +) + +func ErrorHandler(ctx *fiber.Ctx, err error) error { + // Status code defaults to 500 + code := fiber.StatusInternalServerError + + // Retrieve the custom status code if it's a *fiber.Error + var e *fiber.Error + if errors.As(err, &e) { + code = e.Code + } + + // Return from handler + return ctx.Status(code).JSON(fiber.Map{ + "status": "error", + "code": code, + "message": err.Error(), + }) +} diff --git a/server/app/hosts/repository.go b/server/app/hosts/repository.go new file mode 100644 index 0000000..51cd60a --- /dev/null +++ b/server/app/hosts/repository.go @@ -0,0 +1,54 @@ +package hosts + +import ( + "gorm.io/gorm" + "rul.sh/vaulterm/db" + "rul.sh/vaulterm/models" +) + +type Hosts struct{ db *gorm.DB } + +func NewHostsRepository() *Hosts { + return &Hosts{db: db.Get()} +} + +func (r *Hosts) GetAll() ([]*models.Host, error) { + var rows []*models.Host + ret := r.db.Order("created_at DESC").Find(&rows) + + 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) { + var host models.Host + ret := r.db.Joins("Key").Joins("AltKey").Where("hosts.id = ?", id).First(&host) + + if ret.Error != nil { + return nil, ret.Error + } + + res := &GetHostResult{Host: &host} + + if host.Key.Data != "" { + 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 +} + +func (r *Hosts) Create(item *models.Host) error { + return r.db.Create(item).Error +} diff --git a/server/app/hosts/router.go b/server/app/hosts/router.go new file mode 100644 index 0000000..db6602b --- /dev/null +++ b/server/app/hosts/router.go @@ -0,0 +1,53 @@ +package hosts + +import ( + "net/http" + + "github.com/gofiber/fiber/v2" + "rul.sh/vaulterm/models" + "rul.sh/vaulterm/utils" +) + +func Router(app *fiber.App) { + router := app.Group("/hosts") + + router.Get("/", getAll) + router.Post("/", create) +} + +func getAll(c *fiber.Ctx) error { + repo := NewHostsRepository() + rows, err := repo.GetAll() + if err != nil { + return utils.ResponseError(c, err, 500) + } + + return c.JSON(fiber.Map{ + "rows": rows, + }) +} + +func create(c *fiber.Ctx) error { + var body CreateHostSchema + if err := c.BodyParser(&body); err != nil { + return utils.ResponseError(c, err, 500) + } + + repo := NewHostsRepository() + + item := &models.Host{ + Type: body.Type, + Label: body.Label, + Host: body.Host, + Port: body.Port, + Metadata: body.Metadata, + ParentID: body.ParentID, + KeyID: body.KeyID, + AltKeyID: body.AltKeyID, + } + if err := repo.Create(item); err != nil { + return utils.ResponseError(c, err, 500) + } + + return c.Status(http.StatusCreated).JSON(item) +} diff --git a/server/app/hosts/schema.go b/server/app/hosts/schema.go new file mode 100644 index 0000000..511d9df --- /dev/null +++ b/server/app/hosts/schema.go @@ -0,0 +1,15 @@ +package hosts + +import "gorm.io/datatypes" + +type CreateHostSchema struct { + Type string `json:"type"` + Label string `json:"label"` + Host string `json:"host"` + Port int `json:"port"` + Metadata datatypes.JSONMap `json:"metadata"` + + ParentID *string `json:"parentId"` + KeyID *string `json:"keyId"` + AltKeyID *string `json:"altKeyId"` +} diff --git a/server/app/keychains/repository.go b/server/app/keychains/repository.go new file mode 100644 index 0000000..07a2020 --- /dev/null +++ b/server/app/keychains/repository.go @@ -0,0 +1,24 @@ +package keychains + +import ( + "gorm.io/gorm" + "rul.sh/vaulterm/db" + "rul.sh/vaulterm/models" +) + +type Keychains struct{ db *gorm.DB } + +func NewKeychainsRepository() *Keychains { + return &Keychains{db: db.Get()} +} + +func (r *Keychains) GetAll() ([]*models.Keychain, error) { + var rows []*models.Keychain + ret := r.db.Order("created_at DESC").Find(&rows) + + return rows, ret.Error +} + +func (r *Keychains) Create(item *models.Keychain) error { + return r.db.Create(item).Error +} diff --git a/server/app/keychains/router.go b/server/app/keychains/router.go new file mode 100644 index 0000000..c02266e --- /dev/null +++ b/server/app/keychains/router.go @@ -0,0 +1,52 @@ +package keychains + +import ( + "net/http" + + "github.com/gofiber/fiber/v2" + "rul.sh/vaulterm/models" + "rul.sh/vaulterm/utils" +) + +func Router(app *fiber.App) { + router := app.Group("/keychains") + + router.Get("/", getAll) + router.Post("/", create) +} + +func getAll(c *fiber.Ctx) error { + repo := NewKeychainsRepository() + rows, err := repo.GetAll() + if err != nil { + return utils.ResponseError(c, err, 500) + } + + return c.JSON(fiber.Map{ + "rows": rows, + }) +} + +func create(c *fiber.Ctx) error { + var body CreateKeychainSchema + if err := c.BodyParser(&body); err != nil { + return utils.ResponseError(c, err, 500) + } + + repo := NewKeychainsRepository() + + item := &models.Keychain{ + Type: body.Type, + Label: body.Label, + } + + if err := item.EncryptData(body.Data); err != nil { + return utils.ResponseError(c, err, 500) + } + + if err := repo.Create(item); err != nil { + return utils.ResponseError(c, err, 500) + } + + return c.Status(http.StatusCreated).JSON(item) +} diff --git a/server/app/keychains/schema.go b/server/app/keychains/schema.go new file mode 100644 index 0000000..c7f7086 --- /dev/null +++ b/server/app/keychains/schema.go @@ -0,0 +1,7 @@ +package keychains + +type CreateKeychainSchema struct { + Type string `json:"type"` + Label string `json:"label"` + Data interface{} `json:"data"` +} diff --git a/server/app/ws/router.go b/server/app/ws/router.go new file mode 100644 index 0000000..9891caf --- /dev/null +++ b/server/app/ws/router.go @@ -0,0 +1,19 @@ +package ws + +import ( + "github.com/gofiber/contrib/websocket" + "github.com/gofiber/fiber/v2" +) + +func Router(app *fiber.App) { + router := app.Group("/ws") + + router.Use(func(c *fiber.Ctx) error { + if websocket.IsWebSocketUpgrade(c) { + return c.Next() + } + return fiber.ErrUpgradeRequired + }) + + router.Get("/term", websocket.New(HandleTerm)) +} diff --git a/server/app/ws/term.go b/server/app/ws/term.go new file mode 100644 index 0000000..4370b57 --- /dev/null +++ b/server/app/ws/term.go @@ -0,0 +1,115 @@ +package ws + +import ( + "github.com/gofiber/contrib/websocket" + "rul.sh/vaulterm/app/hosts" + "rul.sh/vaulterm/lib" + "rul.sh/vaulterm/utils" +) + +func HandleTerm(c *websocket.Conn) { + hostId := c.Query("hostId") + + hostRepo := hosts.NewHostsRepository() + data, _ := hostRepo.Get(hostId) + + if data == nil { + c.WriteMessage(websocket.TextMessage, []byte("Host not found")) + return + } + + switch data.Host.Type { + case "ssh": + sshHandler(c, data) + case "pve": + pveHandler(c, data) + case "incus": + incusHandler(c, data) + default: + c.WriteMessage(websocket.TextMessage, []byte("Invalid host type")) + } +} + +func sshHandler(c *websocket.Conn, data *hosts.GetHostResult) { + username, _ := data.Key["username"].(string) + password, _ := data.Key["password"].(string) + + cfg := &SSHConfig{ + HostName: data.Host.Host, + Port: data.Host.Port, + User: username, + Password: password, + } + + if err := NewSSHWebsocketSession(c, cfg); err != nil { + c.WriteMessage(websocket.TextMessage, []byte(err.Error())) + } +} + +func pveHandler(c *websocket.Conn, data *hosts.GetHostResult) { + 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, + Username: username, + Password: password, + } + + var i *lib.PVEInstance + if err := utils.ParseMapInterface(data.Host.Metadata, &i); err != nil { + c.WriteMessage(websocket.TextMessage, []byte(err.Error())) + return + } + + if i == nil || i.Type == "" || i.Node == "" || i.VMID == "" { + c.WriteMessage(websocket.TextMessage, []byte("Invalid pve instance metadata")) + return + } + + var err error + if client == "vnc" { + err = NewVNCSession(c, pve, i) + } else { + err = NewTerminalSession(c, pve, i) + } + + if err != nil { + c.WriteMessage(websocket.TextMessage, []byte(err.Error())) + } +} + +func incusHandler(c *websocket.Conn, data *hosts.GetHostResult) { + shell := c.Query("shell") + + cert, _ := data.Key["cert"].(string) + key, _ := data.Key["key"].(string) + + if cert == "" || key == "" { + c.WriteMessage(websocket.TextMessage, []byte("Missing certificate or key")) + return + } + + incus := &lib.IncusServer{ + HostName: data.Host.Host, + Port: data.Host.Port, + ClientCert: cert, + ClientKey: key, + } + + session := &IncusWebsocketSession{} + if err := utils.ParseMapInterface(data.Host.Metadata, session); err != nil { + c.WriteMessage(websocket.TextMessage, []byte(err.Error())) + return + } + + if shell != "" { + session.Shell = shell + } + + if err := session.NewTerminal(c, incus); err != nil { + c.WriteMessage(websocket.TextMessage, []byte(err.Error())) + } +} diff --git a/server/lib/incus_session.go b/server/app/ws/term_incus.go similarity index 83% rename from server/lib/incus_session.go rename to server/app/ws/term_incus.go index 3c7253a..84ceab4 100644 --- a/server/lib/incus_session.go +++ b/server/app/ws/term_incus.go @@ -1,4 +1,4 @@ -package lib +package ws import ( "crypto/tls" @@ -9,10 +9,20 @@ import ( fastWs "github.com/fasthttp/websocket" "github.com/gofiber/contrib/websocket" + "rul.sh/vaulterm/lib" ) -func NewIncusWebsocketSession(c *websocket.Conn, incus *IncusServer) error { - exec, err := incus.InstanceExec("test", []string{"/bin/sh"}, true) +type IncusWebsocketSession struct { + Instance string `json:"instance"` + Shell string `json:"shell"` +} + +func (i *IncusWebsocketSession) NewTerminal(c *websocket.Conn, incus *lib.IncusServer) error { + if i.Shell == "" { + i.Shell = "/bin/sh" + } + + exec, err := incus.InstanceExec(i.Instance, []string{i.Shell}, true) if err != nil { return err } diff --git a/server/lib/pve_session.go b/server/app/ws/term_pve.go similarity index 89% rename from server/lib/pve_session.go rename to server/app/ws/term_pve.go index 51c6b21..80de70a 100644 --- a/server/lib/pve_session.go +++ b/server/app/ws/term_pve.go @@ -1,4 +1,4 @@ -package lib +package ws import ( "crypto/tls" @@ -11,27 +11,21 @@ import ( fastWs "github.com/fasthttp/websocket" "github.com/gofiber/contrib/websocket" + "rul.sh/vaulterm/lib" ) -type PVEConfig struct { - HostName string - User string - Password string - Port int - PrivateKey string - PrivateKeyPassphrase string -} - // https://github.com/proxmox/pve-xtermjs/blob/master/README -func (pve *PVEServer) NewTerminalSession(c *websocket.Conn, instance *PVEInstance) error { +func NewTerminalSession(c *websocket.Conn, pve *lib.PVEServer, instance *lib.PVEInstance) error { access, err := pve.GetAccessTicket() if err != nil { + log.Println("Error getting access ticket:", err) return err } ticket, err := pve.GetVNCTicket(access, instance, false) if err != nil { + log.Println("Error getting vnc ticket:", err) return err } @@ -103,14 +97,16 @@ func (pve *PVEServer) NewTerminalSession(c *websocket.Conn, instance *PVEInstanc return nil } -func (pve *PVEServer) NewVNCSession(c *websocket.Conn, instance *PVEInstance) error { +func NewVNCSession(c *websocket.Conn, pve *lib.PVEServer, instance *lib.PVEInstance) error { access, err := pve.GetAccessTicket() if err != nil { + log.Println("Error getting access ticket:", err) return err } ticket, err := pve.GetVNCTicket(access, instance, true) if err != nil { + log.Println("Error getting vnc ticket:", err) return err } diff --git a/server/lib/ssh_session.go b/server/app/ws/term_ssh.go similarity index 99% rename from server/lib/ssh_session.go rename to server/app/ws/term_ssh.go index dffca97..9827d76 100644 --- a/server/lib/ssh_session.go +++ b/server/app/ws/term_ssh.go @@ -1,4 +1,4 @@ -package lib +package ws import ( "fmt" diff --git a/server/db/database.go b/server/db/database.go new file mode 100644 index 0000000..a44e976 --- /dev/null +++ b/server/db/database.go @@ -0,0 +1,60 @@ +package db + +import ( + "log" + "os" + "strings" + + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +var dbInstance *gorm.DB + +func Get() *gorm.DB { + if dbInstance == nil { + log.Fatal("database not initialized") + } + return dbInstance +} + +func Init() { + // log.Println("Initializing database...") + + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { + dsn = "file:data.db?cache=shared&mode=rwc&_journal_mode=WAL" + } + + // Open db connection + var con gorm.Dialector + if strings.HasPrefix(dsn, "postgres:") { + con = postgres.Open(dsn) + } else { + con = sqlite.Open(dsn) + } + + db, err := gorm.Open(con, &gorm.Config{ + SkipDefaultTransaction: true, + }) + if err != nil { + log.Fatal(err) + } + dbInstance = db + + // Migrate the schema + db.AutoMigrate(Models...) + runSeeders(db) +} + +func Close() error { + con, err := dbInstance.DB() + if err != nil { + return err + } + if err := con.Close(); err != nil { + return err + } + return nil +} diff --git a/server/db/models.go b/server/db/models.go new file mode 100644 index 0000000..a84fba5 --- /dev/null +++ b/server/db/models.go @@ -0,0 +1,10 @@ +package db + +import "rul.sh/vaulterm/models" + +var Models = []interface{}{ + &models.User{}, + &models.UserSession{}, + &models.Keychain{}, + &models.Host{}, +} diff --git a/server/db/seeders.go b/server/db/seeders.go new file mode 100644 index 0000000..adc2887 --- /dev/null +++ b/server/db/seeders.go @@ -0,0 +1,63 @@ +package db + +import ( + "gorm.io/gorm" + "rul.sh/vaulterm/lib" + "rul.sh/vaulterm/models" +) + +type SeedFn func(*gorm.DB) error + +var seeders = []SeedFn{ + seedUsers, +} + +func seedUsers(tx *gorm.DB) error { + var userCount int64 + if res := tx.Model(&models.User{}).Count(&userCount); res.Error != nil { + return res.Error + } + + // skip seeder if users already exist + if userCount > 0 { + return nil + } + + testPasswd, err := lib.HashPassword("123456") + if err != nil { + return err + } + + userList := []models.User{ + { + Name: "Admin", + Username: "admin", + Password: testPasswd, + Email: "admin@mail.com", + Role: models.UserRoleAdmin, + }, + { + Name: "John Doe", + Username: "user", + Password: testPasswd, + Email: "user@mail.com", + }, + } + + if res := tx.Create(&userList); res.Error != nil { + return res.Error + } + + return nil +} + +func runSeeders(db *gorm.DB) { + db.Transaction(func(tx *gorm.DB) error { + for _, seed := range seeders { + if err := seed(db); err != nil { + return err + } + } + return nil + }) +} diff --git a/server/go.mod b/server/go.mod index e5cf4c4..03ba620 100644 --- a/server/go.mod +++ b/server/go.mod @@ -1,12 +1,19 @@ module rul.sh/vaulterm -go 1.21.1 +go 1.22 + +toolchain go1.23.0 require ( github.com/gofiber/contrib/websocket v1.3.2 github.com/gofiber/fiber/v2 v2.52.5 github.com/joho/godotenv v1.5.1 + github.com/oklog/ulid/v2 v2.1.0 + github.com/stretchr/testify v1.9.0 golang.org/x/crypto v0.28.0 + gorm.io/driver/postgres v1.5.9 + gorm.io/driver/sqlite v1.5.6 + gorm.io/gorm v1.25.12 ) require ( @@ -25,4 +32,24 @@ require ( golang.org/x/net v0.26.0 // indirect ) -require golang.org/x/sys v0.26.0 // indirect +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect + github.com/jackc/pgx/v5 v5.5.5 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.13.1 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/text v0.19.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gorm.io/datatypes v1.2.4 // indirect + gorm.io/driver/mysql v1.5.6 // indirect +) diff --git a/server/go.sum b/server/go.sum index ab8e194..7a78e3c 100644 --- a/server/go.sum +++ b/server/go.sum @@ -1,19 +1,44 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fasthttp/websocket v1.5.10 h1:bc7NIGyrg1L6sd5pRzCIbXpro54SZLEluZCu0rOpcN4= github.com/fasthttp/websocket v1.5.10/go.mod h1:BwHeuXGWzCW1/BIKUKD3+qfCl+cTdsHu/f243NcAI/Q= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/gofiber/contrib/websocket v1.3.2 h1:AUq5PYeKwK50s0nQrnluuINYeep1c4nRCJ0NWsV3cvg= github.com/gofiber/contrib/websocket v1.3.2/go.mod h1:07u6QGMsvX+sx7iGNCl5xhzuUVArWwLQ3tBIH24i+S8= github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo= github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= +github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -21,12 +46,22 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU= +github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 h1:D0vL7YNisV2yqE55+q0lFuGse6U8lxlg7fYTctlT5Gc= github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= @@ -39,11 +74,30 @@ golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/datatypes v1.2.4 h1:uZmGAcK/QZ0uyfCuVg0VQY1ZmV9h1fuG0tMwKByO1z4= +gorm.io/datatypes v1.2.4/go.mod h1:f4BsLcFAX67szSv8svwLRjklArSHAvHLeE3pXAS5DZI= +gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8= +gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8= +gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= +gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE= +gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= diff --git a/server/lib/crypto.go b/server/lib/crypto.go index 8f53c5e..0453845 100644 --- a/server/lib/crypto.go +++ b/server/lib/crypto.go @@ -1,10 +1,19 @@ package lib import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" "crypto/tls" "crypto/x509" + "encoding/base64" + "encoding/hex" "encoding/pem" "fmt" + "io" + "os" + + "golang.org/x/crypto/bcrypt" ) func LoadClientCertificate(clientCert string, clientKey string) (*tls.Certificate, error) { @@ -35,3 +44,93 @@ func LoadClientCertificate(clientCert string, clientKey string) (*tls.Certificat PrivateKey: key, }, nil } + +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), 10) + return string(bytes), err +} + +func VerifyPassword(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +func GenerateSessionID(size int) (string, error) { + sessionID := make([]byte, size) + + // Read random bytes into sessionID + _, err := rand.Read(sessionID) + if err != nil { + return "", err + } + + // Encode as hex string + return hex.EncodeToString(sessionID), nil +} + +func Encrypt(data string) (string, error) { + key := os.Getenv("ENCRYPTION_KEY") + if key == "" { + return "", fmt.Errorf("ENCRYPTION_KEY is not set") + } + + keyDec, err := hex.DecodeString(key) + if err != nil { + return "", err + } + + block, err := aes.NewCipher(keyDec) + if err != nil { + return "", err + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonce := make([]byte, aesGCM.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + + ciphertext := aesGCM.Seal(nonce, nonce, []byte(data), nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +func Decrypt(encrypted string) (string, error) { + key := os.Getenv("ENCRYPTION_KEY") + if key == "" { + return "", fmt.Errorf("ENCRYPTION_KEY is not set") + } + + keyDec, err := hex.DecodeString(key) + if err != nil { + return "", err + } + + data, err := base64.StdEncoding.DecodeString(encrypted) + if err != nil { + return "", err + } + + block, err := aes.NewCipher(keyDec) + if err != nil { + return "", err + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonceSize := aesGCM.NonceSize() + nonce, ciphertext := data[:nonceSize], data[nonceSize:] + + res, err := aesGCM.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", err + } + + return string(res), nil +} diff --git a/server/lib/incus.go b/server/lib/incus.go index e3df440..d75cfc9 100644 --- a/server/lib/incus.go +++ b/server/lib/incus.go @@ -82,6 +82,9 @@ func (i *IncusServer) InstanceExec(instance string, command []string, interactiv "command": command, "interactive": interactive, "wait-for-websocket": true, + "environment": map[string]string{ + "TERM": "xterm-256color", + }, }, }) if err != nil { diff --git a/server/lib/pve.go b/server/lib/pve.go index d9ea0d8..01da9be 100644 --- a/server/lib/pve.go +++ b/server/lib/pve.go @@ -70,6 +70,7 @@ type PVEAccessTicket struct { func (pve *PVEServer) GetAccessTicket() (*PVEAccessTicket, error) { url := fmt.Sprintf("https://%s:%d/api2/json/access/ticket", pve.HostName, pve.Port) + // note for myself: don't forget the realm body, err := fetch("POST", url, &PVERequestInit{Body: map[string]string{ "username": pve.Username, "password": pve.Password, @@ -89,9 +90,9 @@ func (pve *PVEServer) GetAccessTicket() (*PVEAccessTicket, error) { } type PVEInstance struct { - Type string // "qemu" | "lxc" - Node string - VMID string + Type string `json:"type"` // "qemu" | "lxc" + Node string `json:"node"` + VMID string `json:"vmid"` } type PVEVNCTicketData struct { diff --git a/server/main.go b/server/main.go index 12357d8..fda0744 100644 --- a/server/main.go +++ b/server/main.go @@ -3,91 +3,16 @@ package main import ( "os" - "github.com/gofiber/contrib/websocket" - "github.com/gofiber/fiber/v2" - "github.com/joho/godotenv" - "rul.sh/vaulterm/lib" + "rul.sh/vaulterm/app" ) func main() { - godotenv.Load() - app := fiber.New() + app := app.NewApp() - var pve = &lib.PVEServer{ - HostName: "10.0.0.1", - Port: 8006, - Username: os.Getenv("PVE_USERNAME"), - Password: os.Getenv("PVE_PASSWORD"), + port := os.Getenv("PORT") + if port == "" { + port = "3000" } - app.Get("/", func(c *fiber.Ctx) error { - return c.SendString("Hello, World!") - }) - - app.Use("/ws", func(c *fiber.Ctx) error { - if websocket.IsWebSocketUpgrade(c) { - return c.Next() - } - return fiber.ErrUpgradeRequired - }) - - app.Get("/ws/ssh", websocket.New(func(c *websocket.Conn) { - cfg := &lib.SSHConfig{ - HostName: "10.0.0.102", - User: "root", - Password: "ausya2", - } - - if err := lib.NewSSHWebsocketSession(c, cfg); err != nil { - c.WriteMessage(websocket.TextMessage, []byte(err.Error())) - } - })) - - app.Get("/ws/pve", websocket.New(func(c *websocket.Conn) { - client := c.Query("client") - serverId := c.Query("serverId") - - var node *lib.PVEInstance - - switch serverId { - case "2": - node = &lib.PVEInstance{ - Type: "qemu", - Node: "pve", - VMID: "105", - } - case "3": - node = &lib.PVEInstance{ - Type: "lxc", - Node: "pve", - VMID: "102", - } - } - - var err error - if client == "vnc" { - err = pve.NewVNCSession(c, node) - } else { - err = pve.NewTerminalSession(c, node) - } - - if err != nil { - c.WriteMessage(websocket.TextMessage, []byte(err.Error())) - } - })) - - app.Get("/ws/incus", websocket.New(func(c *websocket.Conn) { - incus := &lib.IncusServer{ - HostName: "100.64.0.3", - Port: 8443, - ClientCert: "", - ClientKey: "", - } - - if err := lib.NewIncusWebsocketSession(c, incus); err != nil { - c.WriteMessage(websocket.TextMessage, []byte(err.Error())) - } - })) - - app.Listen(":3000") + app.Listen(":" + port) } diff --git a/server/models/base_model.go b/server/models/base_model.go new file mode 100644 index 0000000..5032a6d --- /dev/null +++ b/server/models/base_model.go @@ -0,0 +1,31 @@ +package models + +import ( + "strings" + "time" + + "github.com/oklog/ulid/v2" + "gorm.io/gorm" +) + +type BaseModel struct { + ID string `gorm:"primarykey;type:varchar(26)" json:"id"` +} + +func (m *BaseModel) BeforeCreate(tx *gorm.DB) error { + m.ID = m.GenerateID() + return nil +} + +func (m *BaseModel) GenerateID() string { + return strings.ToLower(ulid.Make().String()) +} + +type Timestamps struct { + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type SoftDeletes struct { + DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"` +} diff --git a/server/models/host.go b/server/models/host.go new file mode 100644 index 0000000..e289e99 --- /dev/null +++ b/server/models/host.go @@ -0,0 +1,32 @@ +package models + +import "gorm.io/datatypes" + +const ( + HostTypeSSH = "ssh" + HostTypePVE = "pve" + HostTypePVENode = "pve_node" + HostTypePVEHost = "pve_host" + HostTypeIncus = "incus" + HostTypeIncusHost = "incus_host" +) + +type Host struct { + BaseModel + + Type string `json:"type" gorm:"not null;index:hosts_type_idx;type:varchar(16)"` + Label string `json:"label"` + Host string `json:"host" gorm:"type:varchar(64)"` + Port int `json:"port" gorm:"type:smallint"` + Metadata datatypes.JSONMap `json:"metadata"` + + ParentID *string `json:"parentId" gorm:"index:hosts_parent_id_idx;type:varchar(26)"` + Parent *Host `json:"parent" gorm:"foreignKey:ParentID"` + KeyID *string `json:"keyId" gorm:"index:hosts_key_id_idx"` + Key Keychain `gorm:"foreignKey:KeyID"` + AltKeyID *string `json:"altKeyId" gorm:"index:hosts_altkey_id_idx"` + AltKey Keychain `gorm:"foreignKey:AltKeyID"` + + Timestamps + SoftDeletes +} diff --git a/server/models/keychain.go b/server/models/keychain.go new file mode 100644 index 0000000..f4cdaa6 --- /dev/null +++ b/server/models/keychain.go @@ -0,0 +1,53 @@ +package models + +import ( + "encoding/json" + + "rul.sh/vaulterm/lib" +) + +const ( + KeychainTypeUserPass = "user" + KeychainTypeRSA = "rsa" + KeychainTypeCertificate = "cert" +) + +type Keychain struct { + BaseModel + + Label string `json:"label"` + Type string `json:"type" gorm:"not null;index:keychains_type_idx;type:varchar(12)"` + Data string `json:"-" gorm:"type:text"` + + Timestamps + SoftDeletes +} + +func (k *Keychain) EncryptData(data interface{}) error { + // Encrypt data + jsonData, err := json.Marshal(data) + if err != nil { + return err + } + + enc, err := lib.Encrypt(string(jsonData)) + if err == nil { + k.Data = enc + } + return err +} + +func (k *Keychain) DecryptData(data interface{}) error { + // Decrypt stored data + dec, err := lib.Decrypt(k.Data) + if err != nil { + return err + } + + err = json.Unmarshal([]byte(dec), &data) + if err != nil { + return err + } + + return nil +} diff --git a/server/models/user.go b/server/models/user.go new file mode 100644 index 0000000..e97ce52 --- /dev/null +++ b/server/models/user.go @@ -0,0 +1,28 @@ +package models + +const ( + UserRoleUser = "user" + UserRoleAdmin = "admin" +) + +type User struct { + BaseModel + + Name string `json:"name"` + Username string `json:"username" gorm:"unique"` + Password string `json:"-"` + Email string `json:"email" gorm:"unique"` + Role string `json:"role" gorm:"default:user;not null;index:users_role_idx;type:varchar(8)"` + + Timestamps + SoftDeletes +} + +type UserSession struct { + ID string `json:"id" gorm:"primarykey;type:varchar(40)"` + UserID string `json:"userId" gorm:"type:varchar(26)"` + User User `json:"user"` + + Timestamps + SoftDeletes +} diff --git a/server/tests/app_test.go b/server/tests/app_test.go new file mode 100644 index 0000000..61ce3c8 --- /dev/null +++ b/server/tests/app_test.go @@ -0,0 +1,17 @@ +package tests + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHealthCheck(t *testing.T) { + test := NewTest(t) + res, status, err := test.Fetch("GET", "/health-check", nil) + + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, status) + assert.Equal(t, "OK", res["data"]) +} diff --git a/server/tests/auth_test.go b/server/tests/auth_test.go new file mode 100644 index 0000000..d8f375f --- /dev/null +++ b/server/tests/auth_test.go @@ -0,0 +1,37 @@ +package tests + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAuthLogin(t *testing.T) { + test := NewTest(t) + + sessionId := test.WithAuth() + assert.NotEmpty(t, sessionId) +} + +func TestAuthGetUser(t *testing.T) { + test := NewTestWithAuth(t) + + res, status, err := test.Fetch("GET", "/auth/user", nil) + + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, status) + assert.NotNil(t, res["user"]) + + user := res["user"].(map[string]interface{}) + assert.NotEmpty(t, user["id"]) +} + +func TestAuthLogout(t *testing.T) { + test := NewTestWithAuth(t) + _, status, err := test.Fetch("POST", "/auth/logout", nil) + + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, status) + test.SessionID = "" +} diff --git a/server/tests/hosts_test.go b/server/tests/hosts_test.go new file mode 100644 index 0000000..6389245 --- /dev/null +++ b/server/tests/hosts_test.go @@ -0,0 +1,74 @@ +package tests + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHostsGetAll(t *testing.T) { + test := NewTestWithAuth(t) + + res, status, err := test.Fetch("GET", "/hosts", nil) + + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, status) + assert.NotNil(t, res["rows"]) +} + +func TestHostsCreate(t *testing.T) { + test := NewTestWithAuth(t) + + data := map[string]interface{}{ + "type": "pve", + "label": "test ssh", + "host": "10.0.0.102", + "port": 22, + "keyId": "01jc3wkctzqrcz8qhwynr4p9pe", + } + + // data := map[string]interface{}{ + // "type": "pve", + // "label": "test pve qemu", + // "host": "10.0.0.1", + // "port": 8006, + // "keyId": "01jc3wkctzqrcz8qhwynr4p9pe", + // "metadata": map[string]interface{}{ + // "node": "pve", + // "type": "qemu", + // "vmid": "105", + // }, + // } + + // data := map[string]interface{}{ + // "type": "pve", + // "label": "test pve lxc", + // "host": "10.0.0.1", + // "port": 8006, + // "keyId": "01jc3xcn5qgybbpfppy9pe14ae", + // "metadata": map[string]interface{}{ + // "node": "pve", + // "type": "lxc", + // "vmid": "102", + // }, + // } + + // data := map[string]interface{}{ + // "type": "incus", + // "label": "test incus", + // "host": "100.64.0.3", + // "port": 8443, + // "keyId": "01jc3xjcm6ddt4zc0x7g69nv9q", + // "metadata": map[string]interface{}{ + // "instance": "test", + // "shell": "/bin/sh", + // }, + // } + + res, status, err := test.Fetch("POST", "/hosts", &FetchOptions{Body: data}) + + assert.NoError(t, err) + assert.Equal(t, http.StatusCreated, status) + assert.NotNil(t, res["id"]) +} diff --git a/server/tests/keychains_test.go b/server/tests/keychains_test.go new file mode 100644 index 0000000..0e96d6d --- /dev/null +++ b/server/tests/keychains_test.go @@ -0,0 +1,55 @@ +package tests + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestKeychainsGetAll(t *testing.T) { + test := NewTestWithAuth(t) + + res, status, err := test.Fetch("GET", "/keychains", nil) + + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, status) + assert.NotNil(t, res["rows"]) +} + +func TestKeychainsCreate(t *testing.T) { + test := NewTestWithAuth(t) + + data := map[string]interface{}{ + "type": "user", + "label": "SSH Key", + "data": map[string]interface{}{ + "username": "", + "password": "", + }, + } + + // data := map[string]interface{}{ + // "type": "user", + // "label": "PVE Key", + // "data": map[string]interface{}{ + // "username": "root@pam", + // "password": "", + // }, + // } + + // data := map[string]interface{}{ + // "type": "cert", + // "label": "Certificate Key", + // "data": map[string]interface{}{ + // "cert": "", + // "key": "", + // }, + // } + + res, status, err := test.Fetch("POST", "/keychains", &FetchOptions{Body: data}) + + assert.NoError(t, err) + assert.Equal(t, http.StatusCreated, status) + assert.NotNil(t, res["id"]) +} diff --git a/server/tests/setup_test.go b/server/tests/setup_test.go new file mode 100644 index 0000000..8d61144 --- /dev/null +++ b/server/tests/setup_test.go @@ -0,0 +1,24 @@ +package tests + +import ( + "log" + "os" + "testing" + + "rul.sh/vaulterm/db" +) + +func TestMain(m *testing.M) { + log.Println("Starting tests...") + test := NewTest(nil) + + // Run all tests + code := m.Run() + + log.Println("Cleaning up...") + + // Clean up + test.Close() + db.Close() + os.Exit(code) +} diff --git a/server/tests/utils.go b/server/tests/utils.go new file mode 100644 index 0000000..d5b6ba6 --- /dev/null +++ b/server/tests/utils.go @@ -0,0 +1,156 @@ +package tests + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path" + "runtime" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "rul.sh/vaulterm/app" +) + +type HTTPTest struct { + t *testing.T + app *fiber.App + + SessionID string +} + +var instance *HTTPTest + +func NewTest(t *testing.T) *HTTPTest { + if instance != nil { + return instance + } + instance = &HTTPTest{ + t: t, + app: app.NewApp(), + } + return instance +} + +func NewTestWithAuth(t *testing.T) *HTTPTest { + test := NewTest(t) + test.WithAuth() + return test +} + +func init() { + _, filename, _, _ := runtime.Caller(0) + dir := path.Join(path.Dir(filename), "..") + err := os.Chdir(dir) + if err != nil { + panic(err) + } +} + +type FetchOptions struct { + Headers map[string]string + Body interface{} + SessionID string +} + +type AuthOptions struct { + Username string `json:"username"` + Password string `json:"password"` +} + +func (h *HTTPTest) Login(options *AuthOptions) string { + body := options + if options == nil { + body = &AuthOptions{ + Username: "admin", + Password: "123456", + } + } + + res, status, err := h.Fetch("POST", "/auth/login", &FetchOptions{ + Body: body, + }) + + if h.t != nil { + assert.NoError(h.t, err) + assert.Equal(h.t, http.StatusOK, status) + assert.NotNil(h.t, res["user"]) + assert.NotEmpty(h.t, res["sessionId"]) + } + + return res["sessionId"].(string) +} + +func (h *HTTPTest) WithAuth() string { + if h.SessionID != "" { + return h.SessionID + } + + sessionId := h.Login(nil) + h.SessionID = sessionId + + return sessionId +} + +func (h *HTTPTest) Close() { + if h.SessionID != "" { + h.Fetch("POST", "/auth/logout?force=true", nil) + } + h.app.Shutdown() +} + +func (h *HTTPTest) Fetch(method string, path string, options *FetchOptions) (map[string]interface{}, int, error) { + var payload io.Reader + headers := map[string]string{} + if options != nil && options.Headers != nil { + headers = options.Headers + } + + if options != nil && options.Body != nil { + json, _ := json.Marshal(options.Body) + payload = bytes.NewBuffer(json) + headers["Content-Type"] = "application/json" + } + + sessionId := h.SessionID + if options != nil && options.SessionID != "" { + sessionId = options.SessionID + } + if sessionId != "" { + headers["Authorization"] = "Bearer " + sessionId + } + + req := httptest.NewRequest(method, path, payload) + for k, v := range headers { + req.Header.Set(k, v) + } + + resp, err := h.app.Test(req, -1) + if err != nil { + return nil, resp.StatusCode, err + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, err + } + + contentType := resp.Header.Get("Content-Type") + + if contentType == "application/json" { + var data map[string]interface{} + if err := json.Unmarshal(body, &data); err != nil { + return nil, resp.StatusCode, err + } + return data, resp.StatusCode, nil + } + + data := map[string]interface{}{ + "data": string(body), + } + return data, resp.StatusCode, err +} diff --git a/server/utils/http.go b/server/utils/http.go new file mode 100644 index 0000000..8f1aeba --- /dev/null +++ b/server/utils/http.go @@ -0,0 +1,14 @@ +package utils + +import "github.com/gofiber/fiber/v2" + +func ResponseError(c *fiber.Ctx, err error, status int) error { + if status == 0 { + status = fiber.StatusInternalServerError + } + + return &fiber.Error{ + Code: status, + Message: err.Error(), + } +} diff --git a/server/utils/parser.go b/server/utils/parser.go new file mode 100644 index 0000000..0212e0f --- /dev/null +++ b/server/utils/parser.go @@ -0,0 +1,15 @@ +package utils + +import "encoding/json" + +func ParseMapInterface(data interface{}, out interface{}) error { + // Marshal the map to JSON + jsonData, err := json.Marshal(data) + if err != nil { + return err + } + + // Unmarshal the JSON data into the struct + err = json.Unmarshal(jsonData, &out) + return err +}