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>
This commit is contained in:
LoganandClaude committed 2026-07-04 01:50:36 +08:00
1 parent 777d9e4e71
commit 76ec9387f0
202 files changed
+16555 -2543

No files matched your search

+128 -36
View File
@@ -1,5 +1,5 @@
// agent_cli.go - Agent CLI WebSocket 控制器 // Logan.Li
// 驱动 pkg/agentcli 引擎:关联主机(model.Server 凭据)→ 构建引擎 → ReAct 循环,
// 驱动 pkg/agentcli 引擎:主机模式(SSH)或 K8s 模式(集群查询/Pod 执行)→ ReAct 循环,
// 通过 WebSocket 实时推送思考/工具调用/输出/结果,高危命令经前端确认,过程持久化到 DB。
package controller
@@ -78,22 +78,37 @@ type agentConn struct {
conn *websocket.Conn
db *gorm.DB
userID uint
mode string
server model.Server
sshCfg remoteexec.SSHConfig
kubernetesCtx *kubernetesAgentContext
session *model.AgentCLISession
taskCtx context.Context
cancel context.CancelFunc
totalTokens int
tokenMu sync.Mutex
writeMu sync.Mutex
confirms map[string]chan bool
cfMu sync.Mutex
convHistory []llm.Message // 多轮追问对话历史(不含 system) // Logan.Li
running bool
runningMu sync.Mutex
confirms map[string]chan bool
cfMu sync.Mutex
convHistory []llm.Message
running bool
runningMu sync.Mutex
}
// Run WebSocket 入口:GET /ws/agent-cli?id=<serverID>&token=<jwt> // Logan.Li
// Run WebSocket 入口 // Logan.Li
// 主机: GET /ws/agent-cli?mode=host&id=<serverID>&token=<jwt>
// Kubernetes: GET /ws/agent-cli?mode=kubernetes&cluster=<code>&namespace=<ns>&pod=<可选>&container=<可选>&token=<jwt>
func (h *AgentCLIHandler) Run(c *gin.Context) {
mode := agentcli.NormalizeRunMode(c.DefaultQuery("mode", agentcli.RunModeHost))
if mode == agentcli.RunModeKubernetes {
h.runKubernetesWS(c)
return
}
h.runHostWS(c)
}
// runHostWS 主机模式 WebSocket // Logan.Li
func (h *AgentCLIHandler) runHostWS(c *gin.Context) {
serverID := c.Query("id")
if serverID == "" {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "缺少主机 id"})
@@ -105,21 +120,17 @@ func (h *AgentCLIHandler) Run(c *gin.Context) {
return
}
// 先升级 WebSocket:SSH 凭据 / LLM 配置预检放到升级之后,失败时通过 WS error 事件
// 把真实原因推给前端,避免握手 400 导致前端"一直转圈"看不到原因 // Logan.Li
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
// SSH 凭据预检
sshCfg, err := buildSSHConfig(h.db, server)
if err != nil {
writeWSCloseError(conn, "SSH 连接失败: "+err.Error())
return
}
// LLM 配置预检
var llmCfg model.LLMConfig
if err := h.db.First(&llmCfg).Error; err != nil || !llmCfg.Enabled {
writeWSCloseError(conn, "LLM 服务未配置或未启用,请先在「大模型设置」中配置")
@@ -127,18 +138,57 @@ func (h *AgentCLIHandler) Run(c *gin.Context) {
}
ac := &agentConn{
conn: conn, db: h.db,
userID: middleware.GetUserID(c), server: server,
taskCtx: context.Background(),
confirms: make(map[string]chan bool),
conn: conn, db: h.db, mode: agentcli.RunModeHost,
userID: middleware.GetUserID(c), server: server, sshCfg: sshCfg,
taskCtx: context.Background(), confirms: make(map[string]chan bool),
}
// 推送主机上下文
ac.write(wsEvent{Type: "session", SessionID: 0,
Message: fmt.Sprintf("已连接主机 %s(%s),LLM: %s。请描述要执行的任务。", server.Hostname, server.IP, llmCfg.Model)})
ac.wsReadLoop()
}
// 读循环:处理前端控制消息
// runKubernetesWS Kubernetes 模式 WebSocket // Logan.Li
func (h *AgentCLIHandler) runKubernetesWS(c *gin.Context) {
clusterCode, namespace, podName, container := parseKubernetesWSParams(c)
if clusterCode == "" || namespace == "" {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "缺少 cluster 或 namespace 参数"})
return
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
kCtx, err := loadKubernetesAgentContext(h.db, clusterCode, namespace, podName)
if err != nil {
writeWSCloseError(conn, "Kubernetes 连接失败: "+err.Error())
return
}
kCtx.container = container
var llmCfg model.LLMConfig
if err := h.db.First(&llmCfg).Error; err != nil || !llmCfg.Enabled {
writeWSCloseError(conn, "LLM 服务未配置或未启用,请先在「大模型设置」中配置")
return
}
ac := &agentConn{
conn: conn, db: h.db, mode: agentcli.RunModeKubernetes,
userID: middleware.GetUserID(c), kubernetesCtx: kCtx,
taskCtx: context.Background(), confirms: make(map[string]chan bool),
}
target := buildKubernetesTargetInfo(kCtx)
ac.write(wsEvent{Type: "session", SessionID: 0,
Message: fmt.Sprintf("已连接 Kubernetes 集群,LLM: %s。\n%s\n请描述要执行的任务。", llmCfg.Model, target)})
ac.wsReadLoop()
}
// wsReadLoop 处理前端控制消息 // Logan.Li
func (ac *agentConn) wsReadLoop() {
for {
_, data, err := conn.ReadMessage()
_, data, err := ac.conn.ReadMessage()
if err != nil {
ac.abort()
return
@@ -153,13 +203,13 @@ func (h *AgentCLIHandler) Run(c *gin.Context) {
ac.write(wsEvent{Type: "error", Message: "任务描述不能为空"})
continue
}
ac.startTask(sshCfg, ctrl.Query, ctrl.SafetyMode)
ac.startTask(ctrl.Query, ctrl.SafetyMode)
case "continue":
if ctrl.Query == "" {
ac.write(wsEvent{Type: "error", Message: "追问内容不能为空"})
continue
}
ac.continueTask(sshCfg, ctrl.Query, ctrl.SafetyMode)
ac.continueTask(ctrl.Query, ctrl.SafetyMode)
case "resume":
if ctrl.SessionID == 0 {
ac.write(wsEvent{Type: "error", Message: "缺少 session_id"})
@@ -189,7 +239,7 @@ func parseSafetyMode(mode string) safety.Mode {
}
// startTask 启动新任务(新会话,清空对话历史) // Logan.Li
func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, query, safetyMode string) {
func (ac *agentConn) startTask(query, safetyMode string) {
ac.abort()
var llmCfg model.LLMConfig
@@ -203,11 +253,20 @@ func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, query, safetyMode st
title = string(r[:60]) + "..."
}
sess := &model.AgentCLISession{
UserID: ac.userID, ServerID: ac.server.ID,
ServerName: ac.server.Hostname, ServerHost: ac.server.IP,
Title: title, Status: "running",
UserID: ac.userID, Mode: ac.mode, Title: title, Status: "running",
Provider: llmCfg.Provider, Model: llmCfg.Model,
}
if ac.mode == agentcli.RunModeKubernetes && ac.kubernetesCtx != nil {
sess.ClusterID = ac.kubernetesCtx.cluster.ID
sess.ClusterName = ac.kubernetesCtx.cluster.Name
sess.Namespace = ac.kubernetesCtx.namespace
sess.PodName = ac.kubernetesCtx.podName
sess.Container = ac.kubernetesCtx.container
} else {
sess.ServerID = ac.server.ID
sess.ServerName = ac.server.Hostname
sess.ServerHost = ac.server.IP
}
if err := ac.db.Create(sess).Error; err != nil {
ac.write(wsEvent{Type: "error", Message: "创建会话失败: " + err.Error()})
return
@@ -215,7 +274,7 @@ func (ac *agentConn) startTask(sshCfg remoteexec.SSHConfig, query, safetyMode st
ac.session = sess
ac.convHistory = nil
ac.setTotalTokens(0)
ac.runTask(sshCfg, sess, query, safetyMode, llmCfg, false)
ac.runTask(sess, query, safetyMode, llmCfg, false)
}
// resumeSession 从历史会话恢复上下文,供继续追问 // Logan.Li
@@ -225,8 +284,16 @@ func (ac *agentConn) resumeSession(sessionID uint) {
return
}
var sess model.AgentCLISession
if err := ac.db.Where("user_id = ? AND server_id = ?", ac.userID, ac.server.ID).
First(&sess, sessionID).Error; err != nil {
q := ac.db.Where("user_id = ?", ac.userID)
if ac.mode == agentcli.RunModeKubernetes {
q = q.Where("mode IN ?", []string{agentcli.RunModeKubernetes, "k8s"})
if ac.kubernetesCtx != nil {
q = q.Where("cluster_id = ? AND namespace = ?", ac.kubernetesCtx.cluster.ID, ac.kubernetesCtx.namespace)
}
} else {
q = q.Where("mode = ? AND server_id = ?", ac.mode, ac.server.ID)
}
if err := q.First(&sess, sessionID).Error; err != nil {
ac.write(wsEvent{Type: "error", Message: "会话不存在或无权访问"})
return
}
@@ -246,7 +313,7 @@ func (ac *agentConn) resumeSession(sessionID uint) {
}
// continueTask 在同一会话内追问(保留上下文) // Logan.Li
func (ac *agentConn) continueTask(sshCfg remoteexec.SSHConfig, query, safetyMode string) {
func (ac *agentConn) continueTask(query, safetyMode string) {
if ac.isRunning() {
ac.write(wsEvent{Type: "error", Message: "任务进行中,请等待完成或停止"})
return
@@ -265,11 +332,11 @@ func (ac *agentConn) continueTask(sshCfg remoteexec.SSHConfig, query, safetyMode
ac.write(wsEvent{Type: "error", Message: "LLM 服务未配置或未启用,请先在「大模型设置」中配置"})
return
}
ac.runTask(sshCfg, ac.session, query, safetyMode, llmCfg, true)
ac.runTask(ac.session, query, safetyMode, llmCfg, true)
}
// runTask 执行一轮 ReAct(新建或追问共用) // Logan.Li
func (ac *agentConn) runTask(sshCfg remoteexec.SSHConfig, sess *model.AgentCLISession, query, safetyMode string, llmCfg model.LLMConfig, isContinue bool) {
func (ac *agentConn) runTask(sess *model.AgentCLISession, query, safetyMode string, llmCfg model.LLMConfig, isContinue bool) {
if ac.isRunning() {
ac.write(wsEvent{Type: "error", Message: "任务进行中,请等待完成或停止"})
return
@@ -287,14 +354,28 @@ func (ac *agentConn) runTask(sshCfg remoteexec.SSHConfig, sess *model.AgentCLISe
coreCfg := core.DefaultConfig()
coreCfg.RoundOffset = roundOffset
coreCfg.Mode = ac.mode
engine, err := agentcli.NewEngine(agentcli.EngineConfig{
engCfg := agentcli.EngineConfig{
Mode: ac.mode,
LLM: buildLLMConfigFromModel(llmCfg),
SSH: sshCfg,
SafetyMode: parseSafetyMode(safetyMode),
MemoryDir: fmt.Sprintf("data/agentcli/user_%d", ac.userID),
Core: coreCfg,
})
}
if ac.mode == agentcli.RunModeKubernetes {
if ac.kubernetesCtx == nil {
ac.write(wsEvent{Type: "error", Message: "Kubernetes 上下文丢失"})
return
}
engCfg.Kubernetes = &agentcli.KubernetesTarget{
Client: ac.kubernetesCtx.client, ClusterName: ac.kubernetesCtx.cluster.Name,
Namespace: ac.kubernetesCtx.namespace, PodName: ac.kubernetesCtx.podName, Container: ac.kubernetesCtx.container,
}
} else {
engCfg.SSH = ac.sshCfg
}
engine, err := agentcli.NewEngine(engCfg)
if err != nil {
ac.write(wsEvent{Type: "error", Message: "引擎初始化失败: " + err.Error()})
ac.finish(sess, "error", "引擎初始化失败: "+err.Error(), ac.lastRound(), 0)
@@ -311,8 +392,7 @@ func (ac *agentConn) runTask(sshCfg remoteexec.SSHConfig, sess *model.AgentCLISe
ac.persistMsg(sess.ID, 0, "user", "user", query, "", "", "", "", false, "", 0)
serverInfo := fmt.Sprintf("主机名: %s | IP: %s:%d | 用户: %s | OS: %s",
ac.server.Hostname, ac.server.IP, ac.server.Port, ac.server.Username, ac.server.OS)
targetInfo := ac.buildTargetInfo()
msg := "任务已开始"
if isContinue {
@@ -326,7 +406,7 @@ func (ac *agentConn) runTask(sshCfg remoteexec.SSHConfig, sess *model.AgentCLISe
engine.Close()
}()
start := time.Now()
result, err := engine.Run(ctx, query, serverInfo, prior, priorTokens)
result, err := engine.Run(ctx, query, targetInfo, prior, priorTokens)
if result != nil && len(result.History) > 0 {
ac.convHistory = result.History
ac.setTotalTokens(result.TotalTokens)
@@ -405,10 +485,22 @@ func (ac *agentConn) onEvent(e core.Event) {
}
}
// buildTargetInfo 构建 Prompt 目标上下文 // Logan.Li
func (ac *agentConn) buildTargetInfo() string {
if ac.mode == agentcli.RunModeKubernetes && ac.kubernetesCtx != nil {
return buildKubernetesTargetInfo(ac.kubernetesCtx)
}
return fmt.Sprintf("主机名: %s | IP: %s:%d | 用户: %s | OS: %s",
ac.server.Hostname, ac.server.IP, ac.server.Port, ac.server.Username, ac.server.OS)
}
// confirmFn 高危命令确认回调:推 confirm 事件并阻塞等前端回传 // Logan.Li
func (ac *agentConn) confirmFn(toolName string, args map[string]interface{}, risk tools.RiskLevel) (bool, error) {
confirmID := fmt.Sprintf("cf-%d", time.Now().UnixNano())
cmd, _ := args["command"].(string)
if cmd == "" && (toolName == "kubernetes_action" || toolName == "k8s_action") {
cmd = fmt.Sprintf("%v %v", args["action"], args["name"])
}
ac.write(wsEvent{Type: "confirm", ConfirmID: confirmID, ToolName: toolName, Command: cmd, Risk: risk.Display(), ToolArgs: args})
ch := make(chan bool, 1)
+16 -1
View File
@@ -10,10 +10,11 @@ import (
"ezops/middleware"
"ezops/model"
"ezops/pkg/agentcli"
)
// ListSessions 会话列表(按当前用户隔离) // Logan.Li
// GET /api/v1/aiops/agent-cli/sessions?server_id=&page=&page_size=
// GET /api/v1/aiops/agent-cli/sessions?mode=&server_id=&cluster_id=&namespace=&page=&page_size=
func (h *AgentCLIHandler) ListSessions(c *gin.Context) {
uid := middleware.GetUserID(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
@@ -26,9 +27,23 @@ func (h *AgentCLIHandler) ListSessions(c *gin.Context) {
}
q := h.db.Model(&model.AgentCLISession{}).Where("user_id = ?", uid)
if mode := c.Query("mode"); mode != "" {
mode = agentcli.NormalizeRunMode(mode)
if mode == agentcli.RunModeKubernetes {
q = q.Where("mode IN ?", []string{agentcli.RunModeKubernetes, "k8s"})
} else {
q = q.Where("mode = ?", mode)
}
}
if sid := c.Query("server_id"); sid != "" {
q = q.Where("server_id = ?", sid)
}
if cid := c.Query("cluster_id"); cid != "" {
q = q.Where("cluster_id = ?", cid)
}
if ns := c.Query("namespace"); ns != "" {
q = q.Where("namespace = ?", ns)
}
var total int64
q.Count(&total)
+111
View File
@@ -0,0 +1,111 @@
// agent_cli_kubernetes.go - Agent CLI Kubernetes 模式 WebSocket 支持 // Logan.Li
package controller
import (
"fmt"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"ezops/model"
k8sclient "ezops/pkg/kubernetesclient"
)
// kubernetesAgentContext Kubernetes 模式运行上下文 // Logan.Li
type kubernetesAgentContext struct {
cluster model.KubernetesCluster
client *k8sclient.Client
namespace string
podName string
container string
}
// parseKubernetesWSParams 从 WS 查询参数解析 Kubernetes 目标 // Logan.Li
func parseKubernetesWSParams(c *gin.Context) (clusterCode, namespace, podName, container string) {
clusterCode = strings.TrimSpace(c.Query("cluster"))
namespace = strings.TrimSpace(c.Query("namespace"))
podName = strings.TrimSpace(c.Query("pod"))
container = strings.TrimSpace(c.Query("container"))
return
}
// loadKubernetesAgentContext 加载集群客户端并预检 // Logan.Li
func loadKubernetesAgentContext(db *gorm.DB, clusterCode, namespace, podName string) (*kubernetesAgentContext, error) {
if clusterCode == "" {
return nil, fmt.Errorf("缺少 cluster 参数")
}
if namespace == "" {
return nil, fmt.Errorf("缺少 namespace 参数")
}
var cluster model.KubernetesCluster
if err := db.Where("code = ?", clusterCode).First(&cluster).Error; err != nil {
return nil, fmt.Errorf("集群不存在: %s", clusterCode)
}
client, err := buildK8sClient(&cluster)
if err != nil {
return nil, fmt.Errorf("集群连接失败: %w", err)
}
if _, err := client.ListNamespaces(); err != nil {
return nil, fmt.Errorf("集群 API 不可达: %w", err)
}
if podName != "" {
pods, err := client.ListPods()
if err != nil {
return nil, fmt.Errorf("获取 Pod 列表失败: %w", err)
}
found := false
for _, p := range pods.Items {
if p.Metadata.Namespace == namespace && p.Metadata.Name == podName {
found = true
break
}
}
if !found {
return nil, fmt.Errorf("Pod %s/%s 不存在", namespace, podName)
}
}
return &kubernetesAgentContext{
cluster: cluster, client: client,
namespace: namespace, podName: podName,
}, nil
}
// buildKubernetesTargetInfo 构建注入 Prompt 的集群上下文摘要 // Logan.Li
func buildKubernetesTargetInfo(ctx *kubernetesAgentContext) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("集群: %s(code=%s)| Namespace: %s", ctx.cluster.Name, ctx.cluster.Code, ctx.namespace))
if ctx.podName != "" {
sb.WriteString(fmt.Sprintf(" | Pod: %s", ctx.podName))
if pods, err := ctx.client.ListPods(); err == nil {
for _, p := range pods.Items {
if p.Metadata.Namespace == ctx.namespace && p.Metadata.Name == ctx.podName {
image := ""
if len(p.Spec.Containers) > 0 {
image = p.Spec.Containers[0].Image
if ctx.container == "" {
ctx.container = p.Spec.Containers[0].Name
}
}
sb.WriteString(fmt.Sprintf(" | 状态: %s | 节点: %s | 镜像: %s", p.Status.Phase, p.Spec.NodeName, image))
break
}
}
}
} else {
sb.WriteString(" | Pod: (未选定,集群/命名空间级任务)")
}
if ctx.container != "" {
sb.WriteString(fmt.Sprintf(" | 容器: %s", ctx.container))
}
return sb.String()
}
// kubernetesEvolveHostKey Evolver 记忆分桶键 // Logan.Li
func kubernetesEvolveHostKey(ctx *kubernetesAgentContext) string {
base := fmt.Sprintf("kubernetes://%s/%s", ctx.cluster.Code, ctx.namespace)
if ctx.podName != "" {
return base + "/" + ctx.podName
}
return base
}
+3
View File
@@ -34,6 +34,9 @@ func (h *BillingHandler) ListBillings(c *gin.Context) {
if accountName := c.Query("account_name"); accountName != "" {
query = query.Where("account_name LIKE ?", "%"+accountName+"%")
}
if accountID := c.Query("cloud_account_id"); accountID != "" {
query = query.Where("cloud_account_id = ?", accountID)
}
if startDate := c.Query("start_date"); startDate != "" {
query = query.Where("billing_date >= ?", startDate)
}
+85
View File
@@ -0,0 +1,85 @@
package controller
import (
"fmt"
"net/http"
"strconv"
"ezops/model"
"ezops/pkg/billingsync"
"github.com/gin-gonic/gin"
)
// syncBillingRequest 同步账单请求(可选指定账号) // Logan.Li
type syncBillingRequest struct {
AccountID *uint `json:"account_id"`
Date string `json:"date"` // YYYY-MM-DD,默认昨日
}
// SyncYesterdayBillings 从云账号 AK 同步指定日期(默认昨日)账单 // Logan.Li
// POST /cloud-billings/sync
func (h *BillingHandler) SyncYesterdayBillings(c *gin.Context) {
var req syncBillingRequest
_ = c.ShouldBindJSON(&req)
billingDate := req.Date
if billingDate == "" {
billingDate = billingsync.Yesterday()
}
if len(billingDate) != 10 {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "date 格式应为 YYYY-MM-DD"})
return
}
result := billingsync.SyncDate(h.db, billingDate, req.AccountID)
if result.Accounts == 0 {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "没有可同步的活跃云账号,请先在云账号管理中添加 AK"})
return
}
msg := fmt.Sprintf("同步完成:新增 %d 条,更新 %d 条", result.Created, result.Updated)
if len(result.Errors) > 0 {
msg += fmt.Sprintf(",%d 个账号失败", len(result.Errors))
}
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: msg,
Data: result,
})
}
// SyncAccountBilling 同步单个云账号账单
// POST /cloud-accounts/:id/sync-billing
func (h *BillingHandler) SyncAccountBilling(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
if id <= 0 {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效账号 ID"})
return
}
var req struct {
Date string `json:"date"`
}
_ = c.ShouldBindJSON(&req)
billingDate := req.Date
if billingDate == "" {
billingDate = billingsync.Yesterday()
}
uid := uint(id)
result := billingsync.SyncDate(h.db, billingDate, &uid)
if result.Accounts == 0 {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "云账号不存在或未激活"})
return
}
msg := fmt.Sprintf("同步完成:新增 %d 条,更新 %d 条", result.Created, result.Updated)
if len(result.Errors) > 0 {
msg = "同步失败: " + result.Errors[0]
}
c.JSON(http.StatusOK, model.Response{Code: 200, Message: msg, Data: result})
}
+53 -11
View File
@@ -7,6 +7,7 @@ import (
"ezops/cloud"
"ezops/model"
"ezops/pkg/crypto"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -14,11 +15,12 @@ import (
// CloudSyncHandler 云平台同步处理器
type CloudSyncHandler struct {
db *gorm.DB
db *gorm.DB
crypt *crypto.SecretBox
}
func NewCloudSyncHandler(db *gorm.DB) *CloudSyncHandler {
return &CloudSyncHandler{db: db}
func NewCloudSyncHandler(db *gorm.DB, masterKey string) *CloudSyncHandler {
return &CloudSyncHandler{db: db, crypt: crypto.NewSecretBox(masterKey)}
}
// SyncRegions 同步云账号的区域列表到本地 regions 表
@@ -167,8 +169,12 @@ func (h *CloudSyncHandler) DiscoverInstances(c *gin.Context) {
}
// importRequest 导入主机请求
type importRequest struct {
Instances []cloud.CloudInstance `json:"instances"`
type importInstancesRequest struct {
Instances []cloud.CloudInstance `json:"instances"`
DefaultPort int `json:"default_port"`
CredentialID *uint `json:"credential_id"` // 兼容旧字段:默认 SSH 凭据 // Logan.Li
SSHCredentialID *uint `json:"ssh_credential_id"` // Linux 默认 SSH 凭据 // Logan.Li
RDPCredentialID *uint `json:"rdp_credential_id"` // Windows 默认 RDP 凭据 // Logan.Li
}
// ImportInstances 将发现的云主机实例导入为本地服务器资产
@@ -182,12 +188,32 @@ func (h *CloudSyncHandler) ImportInstances(c *gin.Context) {
return
}
var req importRequest
var req importInstancesRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误"})
return
}
defaultPort := req.DefaultPort
if defaultPort <= 0 {
defaultPort = 22
}
sshCredentialID := req.SSHCredentialID
if sshCredentialID == nil {
sshCredentialID = req.CredentialID
}
sshCred, sshPlain, hasSSHCred, err := h.loadImportCredential(sshCredentialID, "ssh")
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
rdpCred, rdpPlain, hasRDPCred, err := h.loadImportCredential(req.RDPCredentialID, "rdp")
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
imported := 0
skipped := 0
@@ -216,9 +242,16 @@ func (h *CloudSyncHandler) ImportInstances(c *gin.Context) {
regionID = &region.ID
}
osName := "linux"
if inst.OSType != "" {
osName = inst.OSType
osName := inst.OSType
osType := normalizeImportOSType(inst.OSType, inst.InstanceName)
protocol := "ssh"
username := "root"
if osType == "windows" {
protocol = "rdp"
username = "Administrator"
}
if osName == "" {
osName = osType
}
status := "stopped"
if inst.Status == "Running" {
@@ -245,8 +278,12 @@ func (h *CloudSyncHandler) ImportInstances(c *gin.Context) {
Hostname: hostname,
IP: ipAddr,
PublicIP: inst.PublicIP,
Port: 22,
Username: "root",
Port: defaultPort,
Username: username,
AuthType: "password",
OSType: osType,
Protocol: protocol,
RDPPort: 3389,
OS: osName,
CPU: inst.CPU,
Memory: int64(inst.Memory),
@@ -255,6 +292,11 @@ func (h *CloudSyncHandler) ImportInstances(c *gin.Context) {
Description: "从 " + account.Provider + " 导入 (" + inst.InstanceID + ")",
AgentStatus: "offline",
}
if protocol == "rdp" && hasRDPCred {
applyCredential(rdpCred, rdpPlain, &server)
} else if protocol != "rdp" && hasSSHCred {
applyCredential(sshCred, sshPlain, &server)
}
if err := h.db.Create(&server).Error; err == nil {
imported++
+163 -37
View File
@@ -46,6 +46,25 @@ func buildContent(req model.CreateSecretRequest) (string, string, error) {
}
b, _ := json.Marshal(data)
return string(b), fmt.Sprintf("SSH %s@%d", req.SSHUser, req.SSHPort), nil
case "rdp":
if strings.TrimSpace(req.RDPUsername) == "" {
return "", "", fmt.Errorf("RDP 用户不能为空")
}
if strings.TrimSpace(req.RDPPassword) == "" {
return "", "", fmt.Errorf("RDP 密码不能为空")
}
security := req.RDPSecurity
if security == "" {
security = "nla"
}
data := map[string]string{
"username": strings.TrimSpace(req.RDPUsername),
"password": req.RDPPassword,
"domain": strings.TrimSpace(req.RDPDomain),
"security": security,
}
b, _ := json.Marshal(data)
return string(b), rdpSummary(req.RDPUsername, req.RDPDomain, security), nil
case "docker":
data := map[string]string{
"server": req.DockerServer,
@@ -90,6 +109,20 @@ func buildContent(req model.CreateSecretRequest) (string, string, error) {
return "{}", "", fmt.Errorf("unknown type: %s", req.SecretType)
}
func rdpSummary(username, domain, security string) string {
username = strings.TrimSpace(username)
domain = strings.TrimSpace(domain)
security = strings.TrimSpace(security)
if security == "" || security == "<nil>" {
security = "nla"
}
account := username
if domain != "" && domain != "<nil>" {
account = username + "@" + domain
}
return fmt.Sprintf("Windows RDP %s / %s", account, strings.ToUpper(security))
}
// ────── List ──────
func (h *CredentialHandler) ListCredentials(c *gin.Context) {
@@ -117,9 +150,13 @@ func (h *CredentialHandler) ListCredentials(c *gin.Context) {
db.Count(&total)
page := query.Page
if page < 1 { page = 1 }
if page < 1 {
page = 1
}
pageSize := query.PageSize
if pageSize < 1 || pageSize > 100 { pageSize = 20 }
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
var creds []model.Credential
db.Order("updated_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&creds)
@@ -175,7 +212,9 @@ func (h *CredentialHandler) CreateCredential(c *gin.Context) {
tagsJSON, _ := json.Marshal(req.Tags)
isActive := true
if req.IsActive != nil { isActive = *req.IsActive }
if req.IsActive != nil {
isActive = *req.IsActive
}
cred := model.Credential{
Name: req.Name,
@@ -219,7 +258,9 @@ func (h *CredentialHandler) GetCredential(c *gin.Context) {
json.Unmarshal([]byte(plain), &contentData)
tags := []string{}
if cred.Tags != "" { json.Unmarshal([]byte(cred.Tags), &tags) }
if cred.Tags != "" {
json.Unmarshal([]byte(cred.Tags), &tags)
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{
"id": cred.ID,
@@ -255,11 +296,21 @@ func (h *CredentialHandler) UpdateCredential(c *gin.Context) {
updates := map[string]interface{}{}
if req.Name != nil { updates["name"] = *req.Name }
if req.VariableName != nil { updates["variable_name"] = *req.VariableName }
if req.Description != nil { updates["description"] = *req.Description }
if req.ExpiresAt != nil { updates["expires_at"] = req.ExpiresAt }
if req.IsActive != nil { updates["is_active"] = *req.IsActive }
if req.Name != nil {
updates["name"] = *req.Name
}
if req.VariableName != nil {
updates["variable_name"] = *req.VariableName
}
if req.Description != nil {
updates["description"] = *req.Description
}
if req.ExpiresAt != nil {
updates["expires_at"] = req.ExpiresAt
}
if req.IsActive != nil {
updates["is_active"] = *req.IsActive
}
if len(req.Tags) > 0 {
b, _ := json.Marshal(req.Tags)
@@ -272,7 +323,9 @@ func (h *CredentialHandler) UpdateCredential(c *gin.Context) {
oldPlain, _ := h.crypt.Decrypt(cred.Content)
var old map[string]interface{}
json.Unmarshal([]byte(oldPlain), &old)
if old == nil { old = map[string]interface{}{} }
if old == nil {
old = map[string]interface{}{}
}
mergeUpdate(req, old)
@@ -297,6 +350,7 @@ func (h *CredentialHandler) UpdateCredential(c *gin.Context) {
func hasTypeFieldUpdate(req model.UpdateSecretRequest) bool {
return req.SSHPrivateKey != nil || req.SSHPublicKey != nil || req.SSHUser != nil || req.SSHPort != nil || req.SSHPassword != nil ||
req.RDPUsername != nil || req.RDPPassword != nil || req.RDPDomain != nil || req.RDPSecurity != nil ||
req.DockerServer != nil || req.DockerUsername != nil || req.DockerPassword != nil ||
req.TLSCert != nil || req.TLSKey != nil || req.TLSCA != nil ||
req.AKSKProvider != nil || req.AKSKAccessKey != nil || req.AKSKSecretKey != nil || req.AKSKRegion != nil ||
@@ -306,37 +360,105 @@ func hasTypeFieldUpdate(req model.UpdateSecretRequest) bool {
}
func mergeUpdate(req model.UpdateSecretRequest, old map[string]interface{}) {
if req.SSHPrivateKey != nil { old["private_key"] = *req.SSHPrivateKey }
if req.SSHPublicKey != nil { old["public_key"] = *req.SSHPublicKey }
if req.SSHUser != nil { old["ssh_user"] = *req.SSHUser }
if req.SSHPort != nil { old["ssh_port"] = *req.SSHPort }
if req.SSHPassword != nil { old["password"] = *req.SSHPassword }
if req.DockerServer != nil { old["server"] = *req.DockerServer }
if req.DockerUsername != nil { old["username"] = *req.DockerUsername }
if req.DockerPassword != nil { old["password"] = *req.DockerPassword }
if req.TLSCert != nil { old["cert"] = *req.TLSCert }
if req.TLSKey != nil { old["key"] = *req.TLSKey }
if req.TLSCA != nil { old["ca"] = *req.TLSCA }
if req.AKSKProvider != nil { old["provider"] = *req.AKSKProvider }
if req.AKSKAccessKey != nil { old["access_key"] = *req.AKSKAccessKey }
if req.AKSKSecretKey != nil { old["secret_key"] = *req.AKSKSecretKey }
if req.AKSKRegion != nil { old["region"] = *req.AKSKRegion }
if req.DBType != nil { old["db_type"] = *req.DBType }
if req.DBHost != nil { old["host"] = *req.DBHost }
if req.DBPort != nil { old["port"] = *req.DBPort }
if req.DBName != nil { old["db_name"] = *req.DBName }
if req.DBUsername != nil { old["username"] = *req.DBUsername }
if req.DBPassword != nil { old["password"] = *req.DBPassword }
if req.APIKey != nil { old["api_key"] = *req.APIKey }
if req.APIEndpoint != nil { old["endpoint"] = *req.APIEndpoint }
if req.GenericKey != nil { old["generic_key"] = *req.GenericKey }
if req.GenericValue != nil { old["generic_value"] = *req.GenericValue }
if req.SSHPrivateKey != nil {
old["private_key"] = *req.SSHPrivateKey
}
if req.SSHPublicKey != nil {
old["public_key"] = *req.SSHPublicKey
}
if req.SSHUser != nil {
old["ssh_user"] = *req.SSHUser
}
if req.SSHPort != nil {
old["ssh_port"] = *req.SSHPort
}
if req.SSHPassword != nil {
old["password"] = *req.SSHPassword
}
if req.RDPUsername != nil {
old["username"] = *req.RDPUsername
}
if req.RDPPassword != nil {
old["password"] = *req.RDPPassword
}
if req.RDPDomain != nil {
old["domain"] = *req.RDPDomain
}
if req.RDPSecurity != nil {
security := strings.TrimSpace(*req.RDPSecurity)
if security == "" {
security = "nla"
}
old["security"] = security
}
if req.DockerServer != nil {
old["server"] = *req.DockerServer
}
if req.DockerUsername != nil {
old["username"] = *req.DockerUsername
}
if req.DockerPassword != nil {
old["password"] = *req.DockerPassword
}
if req.TLSCert != nil {
old["cert"] = *req.TLSCert
}
if req.TLSKey != nil {
old["key"] = *req.TLSKey
}
if req.TLSCA != nil {
old["ca"] = *req.TLSCA
}
if req.AKSKProvider != nil {
old["provider"] = *req.AKSKProvider
}
if req.AKSKAccessKey != nil {
old["access_key"] = *req.AKSKAccessKey
}
if req.AKSKSecretKey != nil {
old["secret_key"] = *req.AKSKSecretKey
}
if req.AKSKRegion != nil {
old["region"] = *req.AKSKRegion
}
if req.DBType != nil {
old["db_type"] = *req.DBType
}
if req.DBHost != nil {
old["host"] = *req.DBHost
}
if req.DBPort != nil {
old["port"] = *req.DBPort
}
if req.DBName != nil {
old["db_name"] = *req.DBName
}
if req.DBUsername != nil {
old["username"] = *req.DBUsername
}
if req.DBPassword != nil {
old["password"] = *req.DBPassword
}
if req.APIKey != nil {
old["api_key"] = *req.APIKey
}
if req.APIEndpoint != nil {
old["endpoint"] = *req.APIEndpoint
}
if req.GenericKey != nil {
old["generic_key"] = *req.GenericKey
}
if req.GenericValue != nil {
old["generic_value"] = *req.GenericValue
}
}
func buildSummaryFromRaw(data map[string]interface{}, stype string) string {
switch stype {
case "ssh":
return fmt.Sprintf("SSH %v@%v", data["ssh_user"], data["ssh_port"])
case "rdp":
return rdpSummary(fmt.Sprint(data["username"]), fmt.Sprint(data["domain"]), fmt.Sprint(data["security"]))
case "docker":
return fmt.Sprintf("%v", data["server"])
case "aksk":
@@ -391,10 +513,14 @@ func (h *CredentialHandler) GetTags(c *gin.Context) {
for _, r := range rows {
var tags []string
if json.Unmarshal([]byte(r), &tags) == nil {
for _, t := range tags { tagSet[t] = true }
for _, t := range tags {
tagSet[t] = true
}
}
}
tags := make([]string, 0, len(tagSet))
for t := range tagSet { tags = append(tags, t) }
for t := range tagSet {
tags = append(tags, t)
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: tags})
}
+17
View File
@@ -74,6 +74,23 @@ func applyCredential(cred model.Credential, plain string, server *model.Server)
server.SSHKey = privateKey
server.AuthType = "key"
server.Password = ""
server.Protocol = "ssh"
if server.OSType == "" {
server.OSType = "linux"
}
} else if cred.SecretType == "rdp" {
var rdpc model.RDPSecretContent
if json.Unmarshal([]byte(plain), &rdpc) == nil && rdpc.Username != "" {
server.Username = rdpc.Username
}
server.Password = plain
server.AuthType = "password"
server.SSHKey = ""
server.Protocol = "rdp"
server.OSType = "windows"
if server.RDPPort == 0 {
server.RDPPort = 3389
}
} else {
server.Password = plain
server.AuthType = "password"
+488
View File
@@ -0,0 +1,488 @@
package controller
import (
"bytes"
"context"
"encoding/base64"
"encoding/binary"
"errors"
"image"
"image/png"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
"ezops/middleware"
"ezops/model"
"ezops/pkg/crypto"
rdpengine "ezops/pkg/rdp"
"ezops/pkg/wshub"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"gorm.io/gorm"
)
// RDPHandler 提供后端渲染型 RDP 会话,浏览器只负责 Canvas 与输入事件 // Logan.Li
type RDPHandler struct {
db *gorm.DB
crypt *crypto.SecretBox
wsMgr *wshub.WSManager
engine rdpengine.Engine
clients sync.Map // sessionID -> context.CancelFunc // Logan.Li
live sync.Map // sessionID -> *rdpLiveBroadcaster // Logan.Li
}
const rdpAuditMaxText = 500
const rdpMaxWidth = 1024
const rdpMaxHeight = 800
func NewRDPHandler(db *gorm.DB, masterKey string, wsMgr *wshub.WSManager) *RDPHandler {
return &RDPHandler{
db: db,
crypt: crypto.NewSecretBox(masterKey),
wsMgr: wsMgr,
engine: rdpengine.NewEngine(),
}
}
func (h *RDPHandler) KillSession(sessionID uint) bool {
if val, ok := h.clients.Load(sessionID); ok {
cancel := val.(context.CancelFunc)
cancel()
h.clients.Delete(sessionID)
return true
}
return false
}
func (h *RDPHandler) ServeLive(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("sessionId"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的 RDP 会话ID"})
return
}
var session model.RDPSession
if err := h.db.First(&session, id).Error; err != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "RDP 会话不存在"})
return
}
if session.Status != "active" {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "RDP 会话已结束,请使用历史回放"})
return
}
val, ok := h.live.Load(uint(id))
if !ok {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "RDP 实时流不存在"})
return
}
conn, info, err := h.wsMgr.Upgrade(c, wshub.TypeAudit, middleware.GetUserID(c), middleware.GetUsername(c))
if err != nil {
return
}
defer h.wsMgr.Unregister(info)
hub := val.(*rdpLiveBroadcaster)
ch := hub.subscribe()
defer hub.unsubscribe(ch)
_ = writeRDPFrame(h.wsMgr, conn, rdpengine.ServerFrame{
Type: "session",
Message: strconv.FormatUint(uint64(session.ID), 10),
Width: session.Width,
Height: session.Height,
})
for {
select {
case frame, ok := <-ch:
if !ok {
return
}
if err := writeRDPFrame(h.wsMgr, conn, frame); err != nil {
return
}
case <-c.Request.Context().Done():
return
}
}
}
func (h *RDPHandler) Serve(c *gin.Context) {
serverID := c.Query("id")
if serverID == "" {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "缺少服务器ID"})
return
}
width := clampPositiveInt(c.Query("width"), 1280, rdpMaxWidth)
height := clampPositiveInt(c.Query("height"), 800, rdpMaxHeight)
var server model.Server
if err := h.db.First(&server, serverID).Error; err != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "服务器不存在"})
return
}
normalizeServerRemoteAccess(&server)
if !isRDPServer(server) {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "该资产未配置为 RDP/Windows"})
return
}
rdpCred, err := h.loadRDPCredential(server)
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
userID := middleware.GetUserID(c)
username := middleware.GetUsername(c)
conn, info, err := h.wsMgr.Upgrade(c, "rdp", userID, username)
if err != nil {
return
}
log.Printf("[RDP] ws connected session pending server=%s ip=%s user=%s size=%dx%d", server.Hostname, server.IP, username, width, height)
defer h.wsMgr.Unregister(info)
var userIDPtr *uint
if userID > 0 {
userIDPtr = &userID
}
port := server.RDPPort
if port == 0 {
port = 3389
}
session := model.RDPSession{
UserID: userIDPtr,
Username: username,
ServerID: server.ID,
ServerName: server.Hostname,
ServerIP: server.IP,
ServerPort: port,
ClientIP: c.ClientIP(),
Width: width,
Height: height,
Status: "active",
StartTime: time.Now(),
}
if err := h.db.Create(&session).Error; err != nil {
_ = conn.WriteJSON(rdpengine.ServerFrame{Type: "error", Message: "创建 RDP 会话失败"})
return
}
ctx, cancel := context.WithCancel(c.Request.Context())
h.clients.Store(session.ID, cancel)
liveHub := newRDPLiveBroadcaster()
h.live.Store(session.ID, liveHub)
defer func() {
cancel()
h.clients.Delete(session.ID)
h.live.Delete(session.ID)
liveHub.close()
}()
events := make(chan rdpengine.ClientEvent, 128)
sink := &wsFrameSink{conn: conn, mgr: h.wsMgr, recorder: rdpengine.NewMemoryRecorder(500), live: liveHub, sessionID: session.ID}
_ = sink.SendFrame(rdpengine.ServerFrame{Type: "session", Message: strconv.Itoa(int(session.ID)), Width: width, Height: height})
engineDone := make(chan error, 1)
go func() {
log.Printf("[RDP] engine start session=%d server=%s:%d size=%dx%d", session.ID, server.IP, port, width, height)
engineDone <- h.engine.Run(ctx, rdpengine.Config{
Host: server.IP,
Port: port,
Username: rdpCred.Username,
Password: rdpCred.Password,
Domain: rdpCred.Domain,
Security: rdpCred.Security,
Width: width,
Height: height,
}, sink, events)
}()
status := "closed"
errMsg := ""
defer func() {
cancel()
end := time.Now()
duration := int(end.Sub(session.StartTime).Seconds())
recordData := sink.recorder.JSON()
updates := map[string]interface{}{
"status": status,
"duration": duration,
"end_time": end,
"record_data": recordData,
"record_size": len(recordData),
}
if errMsg != "" {
updates["error"] = errMsg
}
_ = h.db.Model(&model.RDPSession{}).Where("id = ?", session.ID).Updates(updates).Error
}()
audit := newRDPOperationRecorder(h.db, session, userIDPtr, username)
defer audit.Flush()
readDone := make(chan struct{})
go func() {
defer close(readDone)
for {
var event rdpengine.ClientEvent
if err := conn.ReadJSON(&event); err != nil {
return
}
audit.Record(event)
enqueueRDPEvent(events, event)
}
}()
select {
case err := <-engineDone:
if err != nil && !errors.Is(err, context.Canceled) {
status = "failed"
errMsg = err.Error()
log.Printf("[RDP] engine failed session=%d err=%v", session.ID, err)
} else {
log.Printf("[RDP] engine closed session=%d err=%v", session.ID, err)
}
case <-readDone:
log.Printf("[RDP] ws read closed session=%d", session.ID)
}
}
func enqueueRDPEvent(events chan rdpengine.ClientEvent, event rdpengine.ClientEvent) {
select {
case events <- event:
return
default:
}
if event.Type == "mousemove" || event.Type == "mouse_move" {
// 队列已满时丢弃当前移动事件,避免旧坐标堆积拖慢点击/拖拽响应。 // Logan.Li
return
}
// 关键输入不能被旧 mousemove 堵住,优先清理一批移动事件后再投递。 // Logan.Li
for i := 0; i < cap(events)/2; i++ {
select {
case queued := <-events:
if queued.Type != "mousemove" && queued.Type != "mouse_move" {
select {
case events <- queued:
default:
}
}
default:
i = cap(events)
}
select {
case events <- event:
return
default:
}
}
select {
case events <- event:
case <-time.After(200 * time.Millisecond):
log.Printf("[RDP] input queue congested, drop critical event type=%s", event.Type)
}
}
type rdpOperationRecorder struct {
db *gorm.DB
session model.RDPSession
userID *uint
username string
inputBuffer string
lastInput time.Time
}
func newRDPOperationRecorder(db *gorm.DB, session model.RDPSession, userID *uint, username string) *rdpOperationRecorder {
return &rdpOperationRecorder{db: db, session: session, userID: userID, username: username}
}
func (r *rdpOperationRecorder) Record(event rdpengine.ClientEvent) {
now := time.Now()
if r.inputBuffer != "" && now.Sub(r.lastInput) > 2*time.Second {
r.Flush()
}
switch event.Type {
case "key":
r.recordKey(event, now)
case "mousemove", "mouse_move", "mousedown", "mouse_down", "mouseup", "mouse_up", "wheel":
return // 鼠标轨迹只进图形录屏,不写命令审计,避免 RDP 命令表噪声。 // Logan.Li
case "clipboard":
r.Flush()
r.create("[RDP粘贴] " + truncateAuditText(event.Text, rdpAuditMaxText))
case "file_upload":
r.Flush()
r.create("[RDP上传] " + truncateAuditText(event.Text, rdpAuditMaxText))
case "file_download":
r.Flush()
r.create("[RDP下载] " + truncateAuditText(event.Text, rdpAuditMaxText))
}
}
func (r *rdpOperationRecorder) recordKey(event rdpengine.ClientEvent, now time.Time) {
if !event.Down {
return
}
if event.Key == "Control" || event.Key == "Shift" || event.Key == "Alt" || event.Key == "Meta" {
return
}
if event.Code == "Enter" || event.Code == "NumpadEnter" {
if strings.TrimSpace(r.inputBuffer) != "" {
r.Flush()
return
}
r.create("[RDP按键] Enter")
return
}
if event.Code == "Backspace" {
if len(r.inputBuffer) > 0 {
r.inputBuffer = r.inputBuffer[:len(r.inputBuffer)-1]
r.lastInput = now
}
return
}
if len(event.Key) == 1 {
r.inputBuffer += event.Key
r.lastInput = now
if len(r.inputBuffer) >= rdpAuditMaxText {
r.Flush()
}
return
}
if isAuditableRDPKey(event.Key) {
r.Flush()
r.create("[RDP按键] " + event.Key)
}
}
// isAuditableRDPKey 只保留对 Windows 操作有审计意义的非文本按键。 // Logan.Li
func isAuditableRDPKey(key string) bool {
switch key {
case "Tab", "Escape", "Delete", "Home", "End", "PageUp", "PageDown":
return true
}
if strings.HasPrefix(key, "F") {
n, err := strconv.Atoi(strings.TrimPrefix(key, "F"))
return err == nil && n >= 1 && n <= 24
}
return false
}
func (r *rdpOperationRecorder) Flush() {
if strings.TrimSpace(r.inputBuffer) == "" {
r.inputBuffer = ""
return
}
r.create("[RDP输入] " + truncateAuditText(r.inputBuffer, rdpAuditMaxText))
r.inputBuffer = ""
}
func (r *rdpOperationRecorder) create(command string) {
cmd := model.SessionCommand{
SessionID: r.session.ID,
SessionType: "rdp",
UserID: r.userID,
Username: r.username,
ServerID: r.session.ServerID,
ServerName: r.session.ServerName,
Command: command,
Timestamp: time.Now(),
}
_ = r.db.Create(&cmd).Error
}
func (h *RDPHandler) loadRDPCredential(server model.Server) (model.RDPSecretContent, error) {
return loadRDPCredential(h.crypt, server)
}
func parsePositiveInt(raw string, fallback int) int {
v, err := strconv.Atoi(raw)
if err != nil || v <= 0 {
return fallback
}
return v
}
func clampPositiveInt(raw string, fallback int, max int) int {
v := parsePositiveInt(raw, fallback)
if max > 0 && v > max {
return max
}
return v
}
type wsFrameSink struct {
conn *websocket.Conn
mgr *wshub.WSManager
mu sync.Mutex
recorder *rdpengine.MemoryRecorder
live *rdpLiveBroadcaster
sessionID uint
bitmaps int
lastLog time.Time
lastRecord time.Time
}
func (s *wsFrameSink) SendFrame(frame rdpengine.ServerFrame) error {
s.recorder.Append(frame)
if s.live != nil {
s.live.publish(frame)
}
if snap, ok := s.snapshotFrame(frame); ok {
s.recorder.AppendSnapshot(snap)
}
if frame.Type == "bitmap" {
s.bitmaps++
if s.bitmaps <= 3 || time.Since(s.lastLog) > 5*time.Second {
log.Printf("[RDP] bitmap session=%d count=%d rect=%dx%d@%d,%d image=%d rgba=%d", s.sessionID, s.bitmaps, frame.Width, frame.Height, frame.X, frame.Y, len(frame.Image), len(frame.RGBA))
s.lastLog = time.Now()
}
} else if frame.Type == "status" || frame.Type == "error" || frame.Type == "session" {
log.Printf("[RDP] frame session=%d type=%s msg=%q size=%dx%d", s.sessionID, frame.Type, frame.Message, frame.Width, frame.Height)
}
s.mu.Lock()
defer s.mu.Unlock()
return writeRDPFrame(s.mgr, s.conn, frame)
}
func (s *wsFrameSink) snapshotFrame(frame rdpengine.ServerFrame) (rdpengine.ServerFrame, bool) {
if frame.Type != "bitmap" || len(frame.RGBA) == 0 || frame.Width <= 0 || frame.Height <= 0 {
return rdpengine.ServerFrame{}, false
}
now := time.Now()
if !s.lastRecord.IsZero() && now.Sub(s.lastRecord) < 500*time.Millisecond {
return rdpengine.ServerFrame{}, false
}
s.lastRecord = now
img := image.NewRGBA(image.Rect(0, 0, frame.Width, frame.Height))
copy(img.Pix, frame.RGBA)
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return rdpengine.ServerFrame{}, false
}
frame.RGBA = nil
frame.Image = "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
return frame, true
}
func writeRDPFrame(mgr *wshub.WSManager, conn *websocket.Conn, frame rdpengine.ServerFrame) error {
mgr.SetWriteDeadline(conn)
if frame.Type == "bitmap" && len(frame.RGBA) > 0 {
return conn.WriteMessage(websocket.BinaryMessage, encodeRDPBitmapBinary(frame))
}
return conn.WriteJSON(frame)
}
func encodeRDPBitmapBinary(frame rdpengine.ServerFrame) []byte {
headerLen := 20
out := make([]byte, headerLen+len(frame.RGBA))
copy(out[0:4], []byte{'R', 'D', 'P', 'R'})
binary.LittleEndian.PutUint32(out[4:8], uint32(frame.X))
binary.LittleEndian.PutUint32(out[8:12], uint32(frame.Y))
binary.LittleEndian.PutUint32(out[12:16], uint32(frame.Width))
binary.LittleEndian.PutUint32(out[16:20], uint32(frame.Height))
copy(out[headerLen:], frame.RGBA)
return out
}
+35
View File
@@ -0,0 +1,35 @@
package controller
import (
"encoding/json"
"errors"
"ezops/model"
"ezops/pkg/crypto"
)
// loadRDPCredential 解密并解析 Windows 服务器 RDP 凭据 // Logan.Li
func loadRDPCredential(crypt *crypto.SecretBox, server model.Server) (model.RDPSecretContent, error) {
var cred model.RDPSecretContent
if server.Password == "" {
return cred, errors.New("该 Windows 资产未关联 RDP 凭据")
}
plain := server.Password
if dec, err := crypt.Decrypt(server.Password); err == nil {
plain = dec
}
if err := json.Unmarshal([]byte(plain), &cred); err != nil {
cred.Username = server.Username
cred.Password = plain
}
if cred.Username == "" {
cred.Username = server.Username
}
if cred.Security == "" {
cred.Security = "nla"
}
if cred.Username == "" || cred.Password == "" {
return cred, errors.New("RDP 凭据缺少用户名或密码")
}
return cred, nil
}
+242
View File
@@ -0,0 +1,242 @@
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
}
+74
View File
@@ -0,0 +1,74 @@
package controller
import (
"sync"
rdpengine "ezops/pkg/rdp"
)
// rdpLiveBroadcaster 将 RDP 主会话帧广播给审计实时查看端,慢消费者会丢旧帧保最新。 // Logan.Li
type rdpLiveBroadcaster struct {
mu sync.Mutex
closed bool
clients map[chan rdpengine.ServerFrame]struct{}
}
func newRDPLiveBroadcaster() *rdpLiveBroadcaster {
return &rdpLiveBroadcaster{clients: make(map[chan rdpengine.ServerFrame]struct{})}
}
func (b *rdpLiveBroadcaster) subscribe() chan rdpengine.ServerFrame {
ch := make(chan rdpengine.ServerFrame, 8)
b.mu.Lock()
defer b.mu.Unlock()
if b.closed {
close(ch)
return ch
}
b.clients[ch] = struct{}{}
return ch
}
func (b *rdpLiveBroadcaster) unsubscribe(ch chan rdpengine.ServerFrame) {
b.mu.Lock()
defer b.mu.Unlock()
if _, ok := b.clients[ch]; ok {
delete(b.clients, ch)
close(ch)
}
}
func (b *rdpLiveBroadcaster) publish(frame rdpengine.ServerFrame) {
b.mu.Lock()
defer b.mu.Unlock()
if b.closed {
return
}
for ch := range b.clients {
select {
case ch <- frame:
default:
select {
case <-ch:
default:
}
select {
case ch <- frame:
default:
}
}
}
}
func (b *rdpLiveBroadcaster) close() {
b.mu.Lock()
defer b.mu.Unlock()
if b.closed {
return
}
b.closed = true
for ch := range b.clients {
delete(b.clients, ch)
close(ch)
}
}
+177
View File
@@ -0,0 +1,177 @@
package controller
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"ezops/middleware"
"ezops/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// RDPSessionHandler 管理 Windows RDP 图形会话审计记录 // Logan.Li
type RDPSessionHandler struct {
db *gorm.DB
killFunc func(sessionID uint) bool
}
func NewRDPSessionHandler(db *gorm.DB, killFunc func(sessionID uint) bool) *RDPSessionHandler {
return &RDPSessionHandler{db: db, killFunc: killFunc}
}
func (h *RDPSessionHandler) ListRDPSessions(c *gin.Context) {
var req model.RDPSessionQuery
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
query := h.db.Model(&model.RDPSession{})
if req.ServerID > 0 {
query = query.Where("server_id = ?", req.ServerID)
}
if req.Username != "" {
query = query.Where("username LIKE ?", "%"+req.Username+"%")
}
if req.Status != "" {
query = query.Where("status = ?", req.Status)
}
if kw := strings.TrimSpace(req.Keyword); kw != "" {
like := "%" + kw + "%"
query = query.Where("username LIKE ? OR server_name LIKE ? OR server_ip LIKE ? OR client_ip LIKE ?", like, like, like, like)
}
if req.StartDate != "" {
if t, err := time.ParseInLocation("2006-01-02", req.StartDate, time.Local); err == nil {
query = query.Where("start_time >= ?", t)
}
}
if req.EndDate != "" {
if t, err := time.ParseInLocation("2006-01-02", req.EndDate, time.Local); err == nil {
query = query.Where("start_time < ?", t.Add(24*time.Hour))
}
}
if req.HasRecord == "1" || strings.EqualFold(req.HasRecord, "true") {
query = query.Where("record_size > 0")
}
var total int64
if err := query.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "查询失败"})
return
}
var sessions []model.RDPSession
if err := query.Order("id DESC").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&sessions).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "查询失败"})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: model.ListResponse{List: sessions, Total: total}})
}
func (h *RDPSessionHandler) GetRDPSession(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的会话ID"})
return
}
var session model.RDPSession
if err := h.db.First(&session, id).Error; err != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "会话不存在"})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: session})
}
func (h *RDPSessionHandler) GetRDPRecord(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的会话ID"})
return
}
var session model.RDPSession
if err := h.db.First(&session, id).Error; err != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "会话不存在"})
return
}
if session.RecordData == "" {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "无录制数据"})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: session.RecordData})
}
func (h *RDPSessionHandler) KillRDPSession(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的会话ID"})
return
}
if h.killFunc != nil && h.killFunc(uint(id)) {
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已终止"})
return
}
now := time.Now()
var session model.RDPSession
if err := h.db.First(&session, id).Error; err != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "会话不存在"})
return
}
duration := int(now.Sub(session.StartTime).Seconds())
_ = h.db.Model(&session).Updates(map[string]interface{}{"status": "closed", "end_time": now, "duration": duration}).Error
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已终止"})
}
func (h *RDPSessionHandler) DownloadRDPRecord(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的会话ID"})
return
}
var session model.RDPSession
if err := h.db.First(&session, id).Error; err != nil || session.RecordData == "" {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "无录制数据"})
return
}
c.Header("Content-Type", "application/json; charset=utf-8")
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="rdp-session-%d.json"`, session.ID))
c.Header("Content-Length", fmt.Sprintf("%d", len(session.RecordData)))
c.String(http.StatusOK, session.RecordData)
}
func (h *RDPSessionHandler) LogRDPReplayView(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的会话ID"})
return
}
var session model.RDPSession
if err := h.db.Select("id", "username", "server_name", "server_ip").First(&session, id).Error; err != nil {
c.JSON(http.StatusOK, model.Response{Code: 404, Message: "会话不存在"})
return
}
uid := middleware.GetUserID(c)
log := model.AuditLog{
UserID: &uid,
Username: middleware.GetUsername(c),
IP: c.ClientIP(),
UserAgent: c.GetHeader("User-Agent"),
Module: "rdp_audit",
Action: "replay_view",
Resource: "rdp_session",
ResourceID: strconv.FormatUint(id, 10),
Request: fmt.Sprintf("回放 Windows RDP 会话 #%d(%s → %s/%s)", id, session.Username, session.ServerName, session.ServerIP),
Status: 200,
}
_ = h.db.Create(&log).Error
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "ok"})
}
+4
View File
@@ -119,6 +119,7 @@ func (h *ServerHandler) ListServers(c *gin.Context) {
// 填充 has_auth 字段(密码和SSH密钥在JSON中隐藏,但前端需知道是否有凭据) // Logan.Li
for i := range servers {
normalizeServerRemoteAccess(&servers[i])
servers[i].HasAuth = servers[i].Password != "" || servers[i].SSHKey != ""
}
@@ -162,6 +163,9 @@ func (h *ServerHandler) GetServer(c *gin.Context) {
return
}
normalizeServerRemoteAccess(&server)
server.HasAuth = server.Password != "" || server.SSHKey != ""
c.JSON(http.StatusOK, model.Response{
Code: 200,
Message: "success",
+39 -23
View File
@@ -13,8 +13,7 @@ import (
// 批量操作域:批量导入 + 批量删除 // Logan.Li
// BatchCreateServers 批量导入服务器 // Logan.Li
// 接收 {servers:[...], region_id, auth_type, credential_id},逐条校验并创建,跳过重复(主机名或IP已存在)
// BatchCreateServers 批量导入服务器,Linux 默认 SSH 凭据,Windows 默认 RDP 凭据 // Logan.Li
func (h *ServerHandler) BatchCreateServers(c *gin.Context) {
var req model.BatchCreateServerRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -30,24 +29,19 @@ func (h *ServerHandler) BatchCreateServers(c *gin.Context) {
authType = "password"
}
// 如果指定了凭据,提前解密(plain 在循环内由 applyCredential 解析填充) // Logan.Li
var (
hasCred bool
cred model.Credential
credPlain string
)
if req.CredentialID != nil && *req.CredentialID > 0 {
if err := h.db.First(&cred, *req.CredentialID).Error; err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "凭据不存在"})
return
}
p, err := h.crypt.Decrypt(cred.Content)
if err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "凭据解密失败"})
return
}
credPlain = p
hasCred = true
sshCredentialID := req.SSHCredentialID
if sshCredentialID == nil {
sshCredentialID = req.CredentialID
}
sshCred, sshPlain, hasSSHCred, err := h.loadImportCredential(sshCredentialID, "ssh")
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
rdpCred, rdpPlain, hasRDPCred, err := h.loadImportCredential(req.RDPCredentialID, "rdp")
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
return
}
result := model.BatchCreateResult{Errors: []string{}}
@@ -68,13 +62,30 @@ func (h *ServerHandler) BatchCreateServers(c *gin.Context) {
continue
}
osType := normalizeImportOSType(item.OSType, item.OS)
protocol := strings.TrimSpace(strings.ToLower(item.Protocol))
if protocol == "" {
if osType == "windows" {
protocol = "rdp"
} else {
protocol = "ssh"
}
}
port := item.Port
if port == 0 {
port = 22
}
rdpPort := item.RDPPort
if rdpPort == 0 {
rdpPort = 3389
}
username := strings.TrimSpace(item.Username)
if username == "" {
username = "root"
if protocol == "rdp" {
username = "Administrator"
} else {
username = "root"
}
}
server := model.Server{
@@ -83,6 +94,9 @@ func (h *ServerHandler) BatchCreateServers(c *gin.Context) {
Port: port,
Username: username,
AuthType: authType, // 无凭证时用请求 authType(默认 password)
OSType: osType,
Protocol: protocol,
RDPPort: rdpPort,
OS: item.OS,
CPU: item.CPU,
Memory: item.Memory * 1024, // GB → MB
@@ -91,8 +105,10 @@ func (h *ServerHandler) BatchCreateServers(c *gin.Context) {
RegionID: req.RegionID,
Status: "stopped",
}
if hasCred {
applyCredential(cred, credPlain, &server) // 有凭证则覆盖认证字段(ssh→私钥/ssh_user/ssh_port) // Logan.Li
if protocol == "rdp" && hasRDPCred {
applyCredential(rdpCred, rdpPlain, &server)
} else if protocol != "rdp" && hasSSHCred {
applyCredential(sshCred, sshPlain, &server)
}
if err := h.db.Create(&server).Error; err != nil {
result.Failed++
@@ -0,0 +1,85 @@
package controller
import (
"fmt"
"strings"
"ezops/model"
"ezops/pkg/crypto"
"gorm.io/gorm"
)
func (h *ServerHandler) loadImportCredential(id *uint, wantType string) (model.Credential, string, bool, error) {
return loadImportCredentialByDB(h.db, h.crypt, id, wantType)
}
func (h *CloudSyncHandler) loadImportCredential(id *uint, wantType string) (model.Credential, string, bool, error) {
return loadImportCredentialByDB(h.db, h.crypt, id, wantType)
}
func loadImportCredentialByDB(db *gorm.DB, crypt *crypto.SecretBox, id *uint, wantType string) (model.Credential, string, bool, error) {
var cred model.Credential
if id == nil || *id == 0 {
return cred, "", false, nil
}
if err := db.First(&cred, *id).Error; err != nil {
return cred, "", false, fmt.Errorf("凭据不存在")
}
if cred.SecretType != wantType {
return cred, "", false, fmt.Errorf("请选择 %s 类型凭据", strings.ToUpper(wantType))
}
plain, err := crypt.Decrypt(cred.Content)
if err != nil {
return cred, "", false, fmt.Errorf("凭据解密失败")
}
return cred, plain, true, nil
}
func normalizeImportOSType(osType, osName string) string {
raw := strings.ToLower(strings.TrimSpace(osType + " " + osName))
if strings.Contains(raw, "windows") || strings.Contains(raw, "win") {
return "windows"
}
return "linux"
}
// normalizeServerRemoteAccess 补齐历史导入数据中缺失的 os_type/protocol 字段 // Logan.Li
func normalizeServerRemoteAccess(server *model.Server) {
if server == nil {
return
}
if server.OSType != "" && server.Protocol != "" {
if server.OSType == "windows" && server.Protocol != "rdp" {
server.Protocol = "rdp"
}
if server.Protocol == "rdp" && server.OSType == "" {
server.OSType = "windows"
}
if server.RDPPort == 0 {
server.RDPPort = 3389
}
return
}
osType := normalizeImportOSType(server.OSType, server.OS)
if osType == "windows" {
server.OSType = "windows"
server.Protocol = "rdp"
if server.RDPPort == 0 {
server.RDPPort = 3389
}
return
}
if server.OSType == "" {
server.OSType = "linux"
}
if server.Protocol == "" {
server.Protocol = "ssh"
}
}
// isRDPServer 判断资产是否应走 RDP 图形会话 // Logan.Li
func isRDPServer(server model.Server) bool {
normalizeServerRemoteAccess(&server)
return server.Protocol == "rdp" || server.OSType == "windows"
}
+3 -2
View File
@@ -68,8 +68,9 @@ func (h *SessionCommandHandler) ListSessionCommands(c *gin.Context) {
// 联表带出会话录制大小,前端可判断能否回放 // Logan.Li
var items []model.SessionCommandItem
listQuery := h.db.Table("sys_session_commands AS sc").
Select("sc.*, COALESCE(ts.record_size, 0) AS session_record_size").
Joins("LEFT JOIN sys_terminal_sessions AS ts ON ts.id = sc.session_id")
Select("sc.*, COALESCE(ts.record_size, rs.record_size, 0) AS session_record_size").
Joins("LEFT JOIN sys_terminal_sessions AS ts ON ts.id = sc.session_id AND COALESCE(sc.session_type, 'terminal') = 'terminal'").
Joins("LEFT JOIN sys_rdp_sessions AS rs ON rs.id = sc.session_id AND sc.session_type = 'rdp'")
if req.SessionID > 0 {
listQuery = listQuery.Where("sc.session_id = ?", req.SessionID)
}
+8 -10
View File
@@ -64,6 +64,7 @@ func (r *asciicastRecorder) writeHeader() {
"width": 80,
"height": 24,
"timestamp": r.startTime.Unix(),
"aiops_input_recorded": false,
"env": map[string]string{
"SHELL": "/bin/bash",
"TERM": "xterm-256color",
@@ -396,10 +397,6 @@ func (h *WebShellHandler) Terminal(c *gin.Context) {
case 0:
if msg.Data != "" {
stdinPipe.Write([]byte(msg.Data))
// 录入用户输入,回放可见键入内容 // Logan.Li
if recorder.Len() < maxRecordSize {
recorder.WriteRow([]byte(msg.Data))
}
// Command extraction from stdin
cmdBuf.WriteString(msg.Data)
@@ -469,12 +466,13 @@ func extractCommand(input string) string {
func recordCommand(db *gorm.DB, sessionID, serverID uint, serverName, username, command string) uint {
now := time.Now()
cmd := model.SessionCommand{
SessionID: sessionID,
ServerID: serverID,
ServerName: serverName,
Username: username,
Command: command,
Timestamp: now,
SessionID: sessionID,
SessionType: "terminal",
ServerID: serverID,
ServerName: serverName,
Username: username,
Command: command,
Timestamp: now,
}
if err := db.Create(&cmd).Error; err != nil {
log.Printf("[WebShell] Command record failed: %v", err)