package api import ( "encoding/json" "io" "net/http" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" "oci-portal/internal/service" ) // consoleHandler 提供网页控制台(串行 / VNC):创建会话 + WebSocket 数据面。 type consoleHandler struct { svc *service.ConsoleService auth *service.AuthService } type createConsoleSessionRequest struct { Type string `json:"type"` Region string `json:"region"` } // create 创建控制台会话(内部清理残留连接并创建 OCI 控制台连接,约 30-90 秒)。 // // @Summary 创建网页控制台会话 // @Tags 网页控制台 // @Param id path int true "配置 ID" // @Param instanceId path string true "实例 OCID" // @Param body body object true "{type: serial|vnc}" // @Success 200 {object} map[string]any "sessionId 与 wsPath" // @Security BearerAuth // @Router /api/v1/oci-configs/{id}/instances/{instanceId}/console-sessions [post] func (h *consoleHandler) create(c *gin.Context) { id, ok := pathID(c) if !ok { return } var req createConsoleSessionRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } sess, err := h.svc.CreateSession(c.Request.Context(), id, c.Param("instanceId"), req.Region, req.Type) if err != nil { respondError(c, err) return } c.JSON(http.StatusCreated, gin.H{"sessionId": sess.ID, "type": sess.Type}) } // remove 结束会话并删除云端控制台连接。 // // @Summary 关闭网页控制台会话 // @Tags 网页控制台 // @Param sessionId path string true "会话 ID" // @Success 204 "无内容" // @Security BearerAuth // @Router /api/v1/console-sessions/{sessionId} [delete] func (h *consoleHandler) remove(c *gin.Context) { h.svc.CloseSession(c.Param("sessionId")) c.Status(http.StatusNoContent) } // wsUpgrader 同源校验交给 token(浏览器 WebSocket 无法自定义头,token 走 query)。 var wsUpgrader = websocket.Upgrader{ ReadBufferSize: 32 * 1024, WriteBufferSize: 32 * 1024, CheckOrigin: func(*http.Request) bool { return true }, } // ws 是控制台数据面:校验 token 后建立两跳 SSH 隧道,按会话类型分派数据泵。 // // @Summary 控制台 WebSocket 数据面 // @Tags 网页控制台 // @Param sessionId path string true "会话 ID" // @Param token query string true "JWT(浏览器 WebSocket 无法带头,经 query 传递)" // @Success 101 "升级为 WebSocket" // @Router /api/v1/console-sessions/{sessionId}/ws [get] func (h *consoleHandler) ws(c *gin.Context) { if _, err := h.auth.ParseToken(c.Query("token")); err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"}) return } sess := h.svc.Get(c.Param("sessionId")) if sess == nil { c.JSON(http.StatusNotFound, gin.H{"error": "console session not found or expired"}) return } wsConn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { return } defer wsConn.Close() sess.MarkUse(true) defer sess.MarkUse(false) if sess.Type == service.ConsoleTypeSerial { h.serveSerial(c, sess, wsConn) return } h.serveVnc(c, sess, wsConn) } func (h *consoleHandler) serveVnc(c *gin.Context, sess *service.ConsoleSession, wsConn *websocket.Conn) { vnc, err := sess.DialVNC(c.Request.Context()) if err != nil { closeWs(wsConn, "tunnel: "+err.Error()) return } defer vnc.Close() proxyVnc(wsConn, vnc) } // serialQuery 读取 ws 建连时的初始终端尺寸(缺省 80x24)。 func serialQuery(c *gin.Context, key string, def int) int { v := 0 for _, ch := range c.Query(key) { if ch < '0' || ch > '9' { return def } v = v*10 + int(ch-'0') } if v < 1 || v > 1000 { return def } return v } func (h *consoleHandler) serveSerial(c *gin.Context, sess *service.ConsoleSession, wsConn *websocket.Conn) { term, err := sess.OpenSerial(c.Request.Context(), serialQuery(c, "rows", 24), serialQuery(c, "cols", 80)) if err != nil { closeWs(wsConn, "tunnel: "+err.Error()) return } defer term.Close() proxySerial(wsConn, term) } func closeWs(wsConn *websocket.Conn, reason string) { // CloseMessage 载荷上限 125 字节,超长原因截断避免发送失败 if len(reason) > 120 { reason = reason[:120] } _ = wsConn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.ClosePolicyViolation, reason)) } // serialControl 是串行会话的文本帧控制消息(二进制帧为终端原始字节)。 type serialControl struct { Type string `json:"type"` Cols int `json:"cols"` Rows int `json:"rows"` } // proxySerial 串行数据泵:二进制帧 ↔ 终端字节流,文本帧解析 resize 控制消息; // ws 写只发生在本 goroutine,无并发写。 func proxySerial(wsConn *websocket.Conn, term *service.SerialTerminal) { go func() { defer term.Close() for { t, data, err := wsConn.ReadMessage() if err != nil { return } switch t { case websocket.BinaryMessage: if _, err := term.Write(data); err != nil { return } case websocket.TextMessage: var ctl serialControl if json.Unmarshal(data, &ctl) == nil && ctl.Type == "resize" { _ = term.Resize(ctl.Rows, ctl.Cols) } } } }() pumpToWs(wsConn, term) } // proxyVnc VNC 数据泵:双向透传 RFB 字节流直到任一侧断开。 func proxyVnc(wsConn *websocket.Conn, vnc io.ReadWriteCloser) { go func() { defer vnc.Close() for { t, data, err := wsConn.ReadMessage() if err != nil { return } if t == websocket.BinaryMessage || t == websocket.TextMessage { if _, err := vnc.Write(data); err != nil { return } } } }() pumpToWs(wsConn, vnc) } // pumpToWs 把数据源持续泵入 ws 二进制帧,直到任一侧断开。 func pumpToWs(wsConn *websocket.Conn, src io.Reader) { buf := make([]byte, 32*1024) for { n, err := src.Read(buf) if n > 0 { if werr := wsConn.WriteMessage(websocket.BinaryMessage, buf[:n]); werr != nil { return } } if err != nil { return } } }