From 138f708a8703a7f5dd22107d340824a13a70de62 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Fri, 17 Jul 2026 11:24:24 +0100 Subject: [PATCH] feat: console connect + guacd websocket tunnel endpoints --- server/go.mod | 2 +- server/internal/api/console.go | 131 ++++++++++++++++++++++++++++++++ server/internal/api/handlers.go | 3 + 3 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 server/internal/api/console.go diff --git a/server/go.mod b/server/go.mod index 3eaf6f0..757e4b2 100644 --- a/server/go.mod +++ b/server/go.mod @@ -7,6 +7,7 @@ require ( github.com/gin-gonic/gin v1.10.0 github.com/google/uuid v1.6.0 github.com/redis/go-redis/v9 v9.20.1 + github.com/wwt/guac v1.3.2 go.mongodb.org/mongo-driver/v2 v2.2.2 golang.org/x/oauth2 v0.36.0 google.golang.org/grpc v1.64.0 @@ -39,7 +40,6 @@ require ( github.com/sirupsen/logrus v1.4.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect - github.com/wwt/guac v1.3.2 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect diff --git a/server/internal/api/console.go b/server/internal/api/console.go new file mode 100644 index 0000000..0d534e6 --- /dev/null +++ b/server/internal/api/console.go @@ -0,0 +1,131 @@ +package api + +import ( + "net" + "net/http" + "os" + "time" + + "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/server/internal/services" + "github.com/wwt/guac" +) + +// POST /api/console/connect +// Body: { server_id, protocol, key_id?, rdp_username?, rdp_password? } +// Returns: { session_id, token, ws_path } +func consoleConnect(c *gin.Context) { + var body struct { + ServerID string `json:"server_id" binding:"required"` + Protocol string `json:"protocol" binding:"required"` + KeyID string `json:"key_id"` + RDPUsername string `json:"rdp_username"` + RDPPassword string `json:"rdp_password"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + srv, err := services.GetServer(body.ServerID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) + return + } + + sess, err := services.CreateConsoleSession(body.ServerID, body.Protocol, body.KeyID, actorFromCtx(c), c.ClientIP()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + token, err := services.SignSessionToken(sess.SessionID, time.Minute) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + services.LogEvent("console.opened", actorFromCtx(c), srv.ServerID, "", + "console session opened ("+body.Protocol+")") + + c.JSON(http.StatusOK, gin.H{ + "session_id": sess.SessionID, + "token": token, + "ws_path": "/api/console/tunnel", + }) +} + +// GET /api/console/tunnel?token=... (WebSocket upgrade) +func consoleTunnel(c *gin.Context) { + token := c.Query("token") + sessionID, err := services.VerifySessionToken(token) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + sess, err := services.GetConsoleSession(sessionID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) + return + } + srv, err := services.GetServer(sess.ServerID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) + return + } + + // Decrypt private key in-memory only (ssh). + var privKey string + if sess.Protocol == "ssh" && sess.KeyID != "" { + privKey, err = services.GetPrivateKey(sess.KeyID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "selected key has no private material"}) + return + } + } + + // RDP creds are single-use, passed via the connect step into the session + // document is avoided; instead they are re-supplied here as query params + // over the already-authenticated WS token. For ssh they are empty. + gp, err := services.BuildGuacParams(srv, sess.Protocol, privKey, c.Query("u"), c.Query("p")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + guacdAddr := os.Getenv("GUACD_ADDR") + if guacdAddr == "" { + guacdAddr = "guacd:4822" + } + + // Build a guac tunnel config from our params. + connect := func(r *http.Request) (guac.Tunnel, error) { + config := guac.NewGuacamoleConfiguration() + config.Protocol = gp.Protocol + for k, v := range gp.Params { + config.Parameters[k] = v + } + config.OptimalScreenWidth = 1024 + config.OptimalScreenHeight = 768 + config.OptimalResolution = 96 + + addr, err := net.ResolveTCPAddr("tcp", guacdAddr) + if err != nil { + return nil, err + } + conn, err := net.DialTCP("tcp", nil, addr) + if err != nil { + return nil, err + } + stream := guac.NewStream(conn, guac.SocketTimeout) + if err := stream.Handshake(config); err != nil { + return nil, err + } + return guac.NewSimpleTunnel(stream), nil + } + + wsServer := guac.NewWebsocketServer(connect) + wsServer.OnDisconnect = func(id string, r *http.Request, t guac.Tunnel) { + _ = services.EndConsoleSession(sessionID) + } + wsServer.ServeHTTP(c.Writer, c.Request) +} diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 8ee93a7..1c0c953 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -73,6 +73,9 @@ func RegisterRoutes(r *gin.Engine) { apiGroup.DELETE("/keys/:id", deleteKey) apiGroup.POST("/keys/:id/assign", assignKey) apiGroup.DELETE("/keys/:id/assign/:serverId", revokeAssignment) + + apiGroup.POST("/console/connect", consoleConnect) + apiGroup.GET("/console/tunnel", consoleTunnel) } }