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:
1 parent
777d9e4e71
commit
76ec9387f0
202 files changed
+16555
-2543
No files matched your search
+128
-36
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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 = ®ion.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++
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user