Files
aiops/backend/controller/rdp_file.go
T
LoganandClaude 76ec9387f0 feat(rdp): 新增 RDP 远程桌面/SMB/账单同步能力 + 清理编译产物
主要变更:
- 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>
2026-07-04 01:50:36 +08:00

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
}