362 lines
12 KiB
Go
362 lines
12 KiB
Go
package api
|
||
|
||
import (
|
||
"context"
|
||
"encoding/base64"
|
||
"errors"
|
||
"path"
|
||
"strconv"
|
||
"time"
|
||
|
||
"next-terminal/server/config"
|
||
"next-terminal/server/constant"
|
||
"next-terminal/server/global/session"
|
||
"next-terminal/server/guacd"
|
||
"next-terminal/server/log"
|
||
"next-terminal/server/model"
|
||
"next-terminal/server/utils"
|
||
|
||
"github.com/gorilla/websocket"
|
||
"github.com/labstack/echo/v4"
|
||
)
|
||
|
||
const (
|
||
TunnelClosed int = -1
|
||
Normal int = 0
|
||
NotFoundSession int = 800
|
||
NewTunnelError int = 801
|
||
ForcedDisconnect int = 802
|
||
AccessGatewayUnAvailable int = 803
|
||
AccessGatewayCreateError int = 804
|
||
AssetNotActive int = 805
|
||
)
|
||
|
||
func TunEndpoint(c echo.Context) error {
|
||
|
||
ws, err := UpGrader.Upgrade(c.Response().Writer, c.Request(), nil)
|
||
if err != nil {
|
||
log.Errorf("升级为WebSocket协议失败:%v", err.Error())
|
||
return err
|
||
}
|
||
|
||
width := c.QueryParam("width")
|
||
height := c.QueryParam("height")
|
||
dpi := c.QueryParam("dpi")
|
||
sessionId := c.QueryParam("sessionId")
|
||
connectionId := c.QueryParam("connectionId")
|
||
|
||
intWidth, _ := strconv.Atoi(width)
|
||
intHeight, _ := strconv.Atoi(height)
|
||
|
||
configuration := guacd.NewConfiguration()
|
||
|
||
propertyMap := propertyRepository.FindAllMap()
|
||
|
||
var s model.Session
|
||
|
||
if len(connectionId) > 0 {
|
||
s, err = sessionRepository.FindByConnectionId(connectionId)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if s.Status != constant.Connected {
|
||
return errors.New("会话未在线")
|
||
}
|
||
configuration.ConnectionID = connectionId
|
||
sessionId = s.ID
|
||
configuration.SetParameter("width", strconv.Itoa(s.Width))
|
||
configuration.SetParameter("height", strconv.Itoa(s.Height))
|
||
configuration.SetParameter("dpi", "96")
|
||
} else {
|
||
configuration.SetParameter("width", width)
|
||
configuration.SetParameter("height", height)
|
||
configuration.SetParameter("dpi", dpi)
|
||
s, err = sessionRepository.FindByIdAndDecrypt(sessionId)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
setConfig(propertyMap, s, configuration)
|
||
var (
|
||
ip = s.IP
|
||
port = s.Port
|
||
)
|
||
if s.AccessGatewayId != "" && s.AccessGatewayId != "-" {
|
||
g, err := accessGatewayService.GetGatewayAndReconnectById(s.AccessGatewayId)
|
||
if err != nil {
|
||
disconnect(ws, AccessGatewayUnAvailable, "获取接入网关失败:"+err.Error())
|
||
return nil
|
||
}
|
||
if !g.Connected {
|
||
disconnect(ws, AccessGatewayUnAvailable, "接入网关不可用:"+g.Message)
|
||
return nil
|
||
}
|
||
exposedIP, exposedPort, err := g.OpenSshTunnel(s.ID, ip, port)
|
||
if err != nil {
|
||
disconnect(ws, AccessGatewayCreateError, "创建SSH隧道失败:"+err.Error())
|
||
return nil
|
||
}
|
||
defer g.CloseSshTunnel(s.ID)
|
||
ip = exposedIP
|
||
port = exposedPort
|
||
}
|
||
active, err := utils.Tcping(ip, port)
|
||
if !active {
|
||
disconnect(ws, AssetNotActive, "目标资产不在线: "+err.Error())
|
||
return nil
|
||
}
|
||
|
||
configuration.SetParameter("hostname", ip)
|
||
configuration.SetParameter("port", strconv.Itoa(port))
|
||
|
||
// 加载资产配置的属性,优先级比全局配置的高,因此最后加载,覆盖掉全局配置
|
||
attributes, err := assetRepository.FindAssetAttrMapByAssetId(s.AssetId)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(attributes) > 0 {
|
||
setAssetConfig(attributes, s, configuration)
|
||
}
|
||
}
|
||
for name := range configuration.Parameters {
|
||
// 替换数据库空格字符串占位符为真正的空格
|
||
if configuration.Parameters[name] == "-" {
|
||
configuration.Parameters[name] = ""
|
||
}
|
||
}
|
||
|
||
addr := config.GlobalCfg.Guacd.Hostname + ":" + strconv.Itoa(config.GlobalCfg.Guacd.Port)
|
||
log.Debugf("[%v:%v] 创建guacd隧道[%v]", sessionId, connectionId, addr)
|
||
|
||
guacdTunnel, err := guacd.NewTunnel(addr, configuration)
|
||
if err != nil {
|
||
if connectionId == "" {
|
||
disconnect(ws, NewTunnelError, err.Error())
|
||
}
|
||
log.Printf("[%v:%v] 建立连接失败: %v", sessionId, connectionId, err.Error())
|
||
return err
|
||
}
|
||
|
||
nextSession := &session.Session{
|
||
ID: sessionId,
|
||
Protocol: s.Protocol,
|
||
Mode: s.Mode,
|
||
WebSocket: ws,
|
||
GuacdTunnel: guacdTunnel,
|
||
}
|
||
|
||
if len(s.ConnectionId) == 0 {
|
||
if configuration.Protocol == constant.SSH {
|
||
nextTerminal, err := CreateNextTerminalBySession(s)
|
||
if err == nil {
|
||
nextSession.NextTerminal = nextTerminal
|
||
}
|
||
}
|
||
|
||
nextSession.Observer = session.NewObserver(sessionId)
|
||
session.GlobalSessionManager.Add <- nextSession
|
||
go nextSession.Observer.Run()
|
||
sess := model.Session{
|
||
ConnectionId: guacdTunnel.UUID,
|
||
Width: intWidth,
|
||
Height: intHeight,
|
||
Status: constant.Connecting,
|
||
Recording: configuration.GetParameter(guacd.RecordingPath),
|
||
}
|
||
if sess.Recording == "" {
|
||
// 未录屏时无需审计
|
||
sess.Reviewed = true
|
||
}
|
||
// 创建新会话
|
||
log.Debugf("[%v:%v] 创建新会话: %v", sessionId, connectionId, sess.ConnectionId)
|
||
if err := sessionRepository.UpdateById(&sess, sessionId); err != nil {
|
||
return err
|
||
}
|
||
} else {
|
||
// 要监控会话
|
||
forObsSession := session.GlobalSessionManager.GetById(sessionId)
|
||
if forObsSession == nil {
|
||
disconnect(ws, NotFoundSession, "获取会话失败")
|
||
return nil
|
||
}
|
||
nextSession.ID = utils.UUID()
|
||
forObsSession.Observer.Add <- nextSession
|
||
log.Debugf("[%v:%v] 观察者[%v]加入会话[%v]", sessionId, connectionId, nextSession.ID, s.ConnectionId)
|
||
}
|
||
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
tick := time.NewTicker(time.Millisecond * time.Duration(60))
|
||
defer tick.Stop()
|
||
var buf []byte
|
||
dataChan := make(chan []byte)
|
||
|
||
go func() {
|
||
GuacdLoop:
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
log.Debugf("[%v:%v] WebSocket 已关闭,即将关闭 Guacd 连接...", sessionId, connectionId)
|
||
break GuacdLoop
|
||
default:
|
||
instruction, err := guacdTunnel.Read()
|
||
if err != nil {
|
||
log.Debugf("[%v:%v] Guacd 读取失败,即将退出循环...", sessionId, connectionId)
|
||
disconnect(ws, TunnelClosed, "远程连接已关闭")
|
||
break GuacdLoop
|
||
}
|
||
if len(instruction) == 0 {
|
||
continue
|
||
}
|
||
dataChan <- instruction
|
||
}
|
||
}
|
||
log.Debugf("[%v:%v] Guacd 连接已关闭,退出 Guacd 循环。", sessionId, connectionId)
|
||
}()
|
||
|
||
go func() {
|
||
tickLoop:
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
break tickLoop
|
||
case <-tick.C:
|
||
if len(buf) > 0 {
|
||
err = ws.WriteMessage(websocket.TextMessage, buf)
|
||
if err != nil {
|
||
log.Debugf("[%v:%v] WebSocket写入失败,即将关闭Guacd连接...", sessionId, connectionId)
|
||
break tickLoop
|
||
}
|
||
buf = []byte{}
|
||
}
|
||
case data := <-dataChan:
|
||
buf = append(buf, data...)
|
||
}
|
||
}
|
||
log.Debugf("[%v:%v] Guacd连接已关闭,退出定时器循环。", sessionId, connectionId)
|
||
}()
|
||
|
||
for {
|
||
_, message, err := ws.ReadMessage()
|
||
if err != nil {
|
||
log.Debugf("[%v:%v] WebSocket已关闭", sessionId, connectionId)
|
||
// guacdTunnel.Read() 会阻塞,所以要先把guacdTunnel客户端关闭,才能退出Guacd循环
|
||
_ = guacdTunnel.Close()
|
||
|
||
if connectionId != "" {
|
||
observerId := nextSession.ID
|
||
forObsSession := session.GlobalSessionManager.GetById(sessionId)
|
||
if forObsSession != nil {
|
||
// 移除会话中保存的观察者信息
|
||
forObsSession.Observer.Del <- observerId
|
||
log.Debugf("[%v:%v] 观察者[%v]退出会话", sessionId, connectionId, observerId)
|
||
}
|
||
} else {
|
||
CloseSessionById(sessionId, Normal, "用户正常退出")
|
||
}
|
||
cancel()
|
||
break
|
||
}
|
||
_, err = guacdTunnel.WriteAndFlush(message)
|
||
if err != nil {
|
||
CloseSessionById(sessionId, TunnelClosed, "远程连接已关闭")
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func setAssetConfig(attributes map[string]string, s model.Session, configuration *guacd.Configuration) {
|
||
for key, value := range attributes {
|
||
if guacd.DrivePath == key {
|
||
// 忽略该参数
|
||
continue
|
||
}
|
||
if guacd.EnableDrive == key && value == "true" {
|
||
storageId := attributes[guacd.DrivePath]
|
||
if storageId == "" || storageId == "-" {
|
||
// 默认空间ID和用户ID相同
|
||
storageId = s.Creator
|
||
}
|
||
realPath := path.Join(storageService.GetBaseDrivePath(), storageId)
|
||
configuration.SetParameter(guacd.EnableDrive, "true")
|
||
configuration.SetParameter(guacd.DriveName, "Next Terminal Filesystem")
|
||
configuration.SetParameter(guacd.DrivePath, realPath)
|
||
log.Debugf("[%v] 会话 %v:%v 映射目录地址为 %v", s.ID, s.IP, s.Port, realPath)
|
||
} else {
|
||
configuration.SetParameter(key, value)
|
||
}
|
||
}
|
||
}
|
||
|
||
func setConfig(propertyMap map[string]string, s model.Session, configuration *guacd.Configuration) {
|
||
if propertyMap[guacd.EnableRecording] == "true" {
|
||
configuration.SetParameter(guacd.RecordingPath, path.Join(config.GlobalCfg.Guacd.Recording, s.ID))
|
||
configuration.SetParameter(guacd.CreateRecordingPath, propertyMap[guacd.CreateRecordingPath])
|
||
} else {
|
||
configuration.SetParameter(guacd.RecordingPath, "")
|
||
}
|
||
|
||
configuration.Protocol = s.Protocol
|
||
switch configuration.Protocol {
|
||
case "rdp":
|
||
configuration.SetParameter("username", s.Username)
|
||
configuration.SetParameter("password", s.Password)
|
||
|
||
configuration.SetParameter("security", "any")
|
||
configuration.SetParameter("ignore-cert", "true")
|
||
configuration.SetParameter("create-drive-path", "true")
|
||
configuration.SetParameter("resize-method", "reconnect")
|
||
configuration.SetParameter(guacd.EnableWallpaper, propertyMap[guacd.EnableWallpaper])
|
||
configuration.SetParameter(guacd.EnableTheming, propertyMap[guacd.EnableTheming])
|
||
configuration.SetParameter(guacd.EnableFontSmoothing, propertyMap[guacd.EnableFontSmoothing])
|
||
configuration.SetParameter(guacd.EnableFullWindowDrag, propertyMap[guacd.EnableFullWindowDrag])
|
||
configuration.SetParameter(guacd.EnableDesktopComposition, propertyMap[guacd.EnableDesktopComposition])
|
||
configuration.SetParameter(guacd.EnableMenuAnimations, propertyMap[guacd.EnableMenuAnimations])
|
||
configuration.SetParameter(guacd.DisableBitmapCaching, propertyMap[guacd.DisableBitmapCaching])
|
||
configuration.SetParameter(guacd.DisableOffscreenCaching, propertyMap[guacd.DisableOffscreenCaching])
|
||
configuration.SetParameter(guacd.DisableGlyphCaching, propertyMap[guacd.DisableGlyphCaching])
|
||
case "ssh":
|
||
if len(s.PrivateKey) > 0 && s.PrivateKey != "-" {
|
||
configuration.SetParameter("username", s.Username)
|
||
configuration.SetParameter("private-key", s.PrivateKey)
|
||
configuration.SetParameter("passphrase", s.Passphrase)
|
||
} else {
|
||
configuration.SetParameter("username", s.Username)
|
||
configuration.SetParameter("password", s.Password)
|
||
}
|
||
|
||
configuration.SetParameter(guacd.FontSize, propertyMap[guacd.FontSize])
|
||
configuration.SetParameter(guacd.FontName, propertyMap[guacd.FontName])
|
||
configuration.SetParameter(guacd.ColorScheme, propertyMap[guacd.ColorScheme])
|
||
configuration.SetParameter(guacd.Backspace, propertyMap[guacd.Backspace])
|
||
configuration.SetParameter(guacd.TerminalType, propertyMap[guacd.TerminalType])
|
||
case "vnc":
|
||
configuration.SetParameter("username", s.Username)
|
||
configuration.SetParameter("password", s.Password)
|
||
case "telnet":
|
||
configuration.SetParameter("username", s.Username)
|
||
configuration.SetParameter("password", s.Password)
|
||
|
||
configuration.SetParameter(guacd.FontSize, propertyMap[guacd.FontSize])
|
||
configuration.SetParameter(guacd.FontName, propertyMap[guacd.FontName])
|
||
configuration.SetParameter(guacd.ColorScheme, propertyMap[guacd.ColorScheme])
|
||
configuration.SetParameter(guacd.Backspace, propertyMap[guacd.Backspace])
|
||
configuration.SetParameter(guacd.TerminalType, propertyMap[guacd.TerminalType])
|
||
case "kubernetes":
|
||
configuration.SetParameter(guacd.FontSize, propertyMap[guacd.FontSize])
|
||
configuration.SetParameter(guacd.FontName, propertyMap[guacd.FontName])
|
||
configuration.SetParameter(guacd.ColorScheme, propertyMap[guacd.ColorScheme])
|
||
configuration.SetParameter(guacd.Backspace, propertyMap[guacd.Backspace])
|
||
configuration.SetParameter(guacd.TerminalType, propertyMap[guacd.TerminalType])
|
||
default:
|
||
|
||
}
|
||
}
|
||
|
||
func disconnect(ws *websocket.Conn, code int, reason string) {
|
||
// guacd 无法处理中文字符,所以进行了base64编码。
|
||
encodeReason := base64.StdEncoding.EncodeToString([]byte(reason))
|
||
err := guacd.NewInstruction("error", encodeReason, strconv.Itoa(code))
|
||
_ = ws.WriteMessage(websocket.TextMessage, []byte(err.String()))
|
||
disconnect := guacd.NewInstruction("disconnect")
|
||
_ = ws.WriteMessage(websocket.TextMessage, []byte(disconnect.String()))
|
||
}
|