主要变更: - RDP 远程桌面:新增 backend/pkg/rdp(RDP 引擎 + credssp/display/rle/trace)、 backend/controller/rdp*(连接/凭证/文件/会话/实时)、frontend RDP 组件与 API - Windows SMB 文件传输:backend/pkg/winsmb - 云账单同步:backend/pkg/billingsync + billing_sync/aliyun_billing,前端账单详情 - Agent CLI Kubernetes 工具:kubernetes_action/get、pod_execute/logs - 服务器导入助手:server_import_helpers、ImportCredentialSettings - 国际化与组件重构(cloud/deploy/monitor/server/system 等多模块) - 清理编译产物二进制:移除 backend/server、backend/agent/bin/aiops-agent-* - .gitignore:新增 backend/agent/ 与 .cache/(避免 334MB go-build 缓存误入库) Co-Authored-By: Claude <noreply@anthropic.com>
242 lines
7.0 KiB
Go
242 lines
7.0 KiB
Go
package controller
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"ezops/model"
|
|
"ezops/pkg/crypto"
|
|
"ezops/pkg/rdpfilestage"
|
|
"ezops/pkg/winsmb"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// RDPFileHandler 通过 SMB 管理共享实现 RDP 虚拟 Download 盘文件传输 // Logan.Li
|
|
type RDPFileHandler struct {
|
|
db *gorm.DB
|
|
crypt *crypto.SecretBox
|
|
}
|
|
|
|
func NewRDPFileHandler(db *gorm.DB, masterKey string) *RDPFileHandler {
|
|
return &RDPFileHandler{db: db, crypt: crypto.NewSecretBox(masterKey)}
|
|
}
|
|
|
|
func (h *RDPFileHandler) loadAuth(serverID uint) (winsmb.Auth, error) {
|
|
var server model.Server
|
|
if err := h.db.First(&server, serverID).Error; err != nil {
|
|
return winsmb.Auth{}, err
|
|
}
|
|
normalizeServerRemoteAccess(&server)
|
|
if !isRDPServer(server) {
|
|
return winsmb.Auth{}, errors.New("该资产未配置为 RDP/Windows")
|
|
}
|
|
cred, err := loadRDPCredential(h.crypt, server)
|
|
if err != nil {
|
|
return winsmb.Auth{}, err
|
|
}
|
|
return winsmb.Auth{
|
|
Host: server.IP,
|
|
Domain: cred.Domain,
|
|
Username: cred.Username,
|
|
Password: cred.Password,
|
|
}, nil
|
|
}
|
|
|
|
func (h *RDPFileHandler) ListFiles(c *gin.Context) {
|
|
serverID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的服务器ID"})
|
|
return
|
|
}
|
|
auth, err := h.loadAuth(uint(serverID))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
|
return
|
|
}
|
|
entries, err := winsmb.ListDownload(auth)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadGateway, model.Response{Code: 502, Message: err.Error()})
|
|
return
|
|
}
|
|
type item struct {
|
|
Name string `json:"name"`
|
|
Size int64 `json:"size"`
|
|
IsDir bool `json:"is_dir"`
|
|
ModTime string `json:"mod_time"`
|
|
}
|
|
list := make([]item, 0, len(entries))
|
|
for _, ent := range entries {
|
|
if ent.IsDir {
|
|
continue
|
|
}
|
|
list = append(list, item{
|
|
Name: ent.Name,
|
|
Size: ent.Size,
|
|
IsDir: ent.IsDir,
|
|
ModTime: ent.ModTime.Format(time.RFC3339),
|
|
})
|
|
}
|
|
c.JSON(http.StatusOK, model.Response{
|
|
Code: 200,
|
|
Data: gin.H{
|
|
"root": winsmb.DownloadFullPath,
|
|
"list": list,
|
|
"total": len(list),
|
|
},
|
|
})
|
|
}
|
|
|
|
func (h *RDPFileHandler) UploadFile(c *gin.Context) {
|
|
serverID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的服务器ID"})
|
|
return
|
|
}
|
|
fileHeader, err := c.FormFile("file")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请选择文件"})
|
|
return
|
|
}
|
|
if fileHeader.Size > 100<<20 {
|
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "单文件不能超过 100MB"})
|
|
return
|
|
}
|
|
f, err := fileHeader.Open()
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "读取上传文件失败"})
|
|
return
|
|
}
|
|
defer f.Close()
|
|
data, err := io.ReadAll(f)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "读取上传文件失败"})
|
|
return
|
|
}
|
|
auth, err := h.loadAuth(uint(serverID))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
|
return
|
|
}
|
|
if err := winsmb.Upload(auth, fileHeader.Filename, data); err != nil {
|
|
c.JSON(http.StatusBadGateway, model.Response{Code: 502, Message: err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, model.Response{
|
|
Code: 200,
|
|
Message: "上传成功",
|
|
Data: gin.H{
|
|
"name": fileHeader.Filename,
|
|
"path": winsmb.DownloadFullPath + `\` + fileHeader.Filename,
|
|
"size": fileHeader.Size,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (h *RDPFileHandler) DownloadFile(c *gin.Context) {
|
|
serverID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的服务器ID"})
|
|
return
|
|
}
|
|
name := c.Query("name")
|
|
if name == "" {
|
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "缺少文件名"})
|
|
return
|
|
}
|
|
auth, err := h.loadAuth(uint(serverID))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
|
return
|
|
}
|
|
data, err := winsmb.Download(auth, name)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadGateway, model.Response{Code: 502, Message: err.Error()})
|
|
return
|
|
}
|
|
c.Header("Content-Disposition", "attachment; filename=\""+name+"\"")
|
|
c.Data(http.StatusOK, "application/octet-stream", data)
|
|
}
|
|
|
|
// StageUpload SMB 不可用时:文件暂存 AIOps,返回可由 Windows 拉取的一次性 URL // Logan.Li
|
|
func (h *RDPFileHandler) StageUpload(c *gin.Context) {
|
|
serverID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的服务器ID"})
|
|
return
|
|
}
|
|
fileHeader, err := c.FormFile("file")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请选择文件"})
|
|
return
|
|
}
|
|
if fileHeader.Size > 50<<20 {
|
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "回退模式单文件不能超过 50MB"})
|
|
return
|
|
}
|
|
f, err := fileHeader.Open()
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "读取上传文件失败"})
|
|
return
|
|
}
|
|
defer f.Close()
|
|
data, err := io.ReadAll(f)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "读取上传文件失败"})
|
|
return
|
|
}
|
|
if _, err := h.loadAuth(uint(serverID)); err != nil {
|
|
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
|
return
|
|
}
|
|
token := rdpfilestage.Put(fileHeader.Filename, data, 30*time.Minute)
|
|
base := strings.TrimRight(publicHTTPBase(c), "/")
|
|
downloadURL := fmt.Sprintf("%s/api/v1/rdp-staged/%s", base, token)
|
|
remotePath := winsmb.DownloadFullPath + `\` + fileHeader.Filename
|
|
deployCmd := fmt.Sprintf(
|
|
`powershell -NoProfile -WindowStyle Hidden -Command "$d='%s';if(!(Test-Path $d)){ni -ItemType Directory -Path $d -Force|Out-Null};iwr -UseBasicParsing -Uri '%s' -OutFile (Join-Path $d '%s')"`,
|
|
strings.ReplaceAll(winsmb.DownloadFullPath, `'`, `''`),
|
|
downloadURL,
|
|
strings.ReplaceAll(fileHeader.Filename, `'`, `''`),
|
|
)
|
|
c.JSON(http.StatusOK, model.Response{
|
|
Code: 200,
|
|
Data: gin.H{
|
|
"token": token,
|
|
"download_url": downloadURL,
|
|
"remote_path": remotePath,
|
|
"deploy_command": deployCmd,
|
|
"mode": "stage",
|
|
},
|
|
})
|
|
}
|
|
|
|
// ServeStaged 供 Windows 主机拉取暂存文件(token 即密钥)// Logan.Li
|
|
func (h *RDPFileHandler) ServeStaged(c *gin.Context) {
|
|
token := c.Param("token")
|
|
item, ok := rdpfilestage.Get(token)
|
|
if !ok {
|
|
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "链接已过期或不存在"})
|
|
return
|
|
}
|
|
c.Header("Content-Disposition", "attachment; filename=\""+item.Name+"\"")
|
|
c.Data(http.StatusOK, "application/octet-stream", item.Data)
|
|
}
|
|
|
|
func publicHTTPBase(c *gin.Context) string {
|
|
scheme := "http"
|
|
if c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
|
|
scheme = "https"
|
|
}
|
|
host := c.Request.Host
|
|
if fwd := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); fwd != "" {
|
|
host = strings.Split(fwd, ",")[0]
|
|
}
|
|
return scheme + "://" + host
|
|
} |