&namespace=&pod=<可选>&container=<可选>&token=
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)
diff --git a/backend/controller/agent_cli_api.go b/backend/controller/agent_cli_api.go
index e05f655..ee578fb 100644
--- a/backend/controller/agent_cli_api.go
+++ b/backend/controller/agent_cli_api.go
@@ -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)
diff --git a/backend/controller/agent_cli_kubernetes.go b/backend/controller/agent_cli_kubernetes.go
new file mode 100644
index 0000000..d14b275
--- /dev/null
+++ b/backend/controller/agent_cli_kubernetes.go
@@ -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
+}
\ No newline at end of file
diff --git a/backend/controller/billing.go b/backend/controller/billing.go
index d3b8073..d1d994e 100644
--- a/backend/controller/billing.go
+++ b/backend/controller/billing.go
@@ -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)
}
diff --git a/backend/controller/billing_sync.go b/backend/controller/billing_sync.go
new file mode 100644
index 0000000..2a8ad74
--- /dev/null
+++ b/backend/controller/billing_sync.go
@@ -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})
+}
\ No newline at end of file
diff --git a/backend/controller/cloud_sync.go b/backend/controller/cloud_sync.go
index 823cfcc..1eb8bdb 100644
--- a/backend/controller/cloud_sync.go
+++ b/backend/controller/cloud_sync.go
@@ -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++
diff --git a/backend/controller/credential.go b/backend/controller/credential.go
index 21a28d3..a6d6b6c 100644
--- a/backend/controller/credential.go
+++ b/backend/controller/credential.go
@@ -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 == "" {
+ security = "nla"
+ }
+ account := username
+ if domain != "" && domain != "" {
+ 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})
}
diff --git a/backend/controller/credential_assign.go b/backend/controller/credential_assign.go
index 8fed486..0f2977e 100644
--- a/backend/controller/credential_assign.go
+++ b/backend/controller/credential_assign.go
@@ -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"
diff --git a/backend/controller/rdp.go b/backend/controller/rdp.go
new file mode 100644
index 0000000..51f7f7b
--- /dev/null
+++ b/backend/controller/rdp.go
@@ -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
+}
diff --git a/backend/controller/rdp_cred.go b/backend/controller/rdp_cred.go
new file mode 100644
index 0000000..f7c8a73
--- /dev/null
+++ b/backend/controller/rdp_cred.go
@@ -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
+}
\ No newline at end of file
diff --git a/backend/controller/rdp_file.go b/backend/controller/rdp_file.go
new file mode 100644
index 0000000..a6478d6
--- /dev/null
+++ b/backend/controller/rdp_file.go
@@ -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
+}
\ No newline at end of file
diff --git a/backend/controller/rdp_live.go b/backend/controller/rdp_live.go
new file mode 100644
index 0000000..d39d41b
--- /dev/null
+++ b/backend/controller/rdp_live.go
@@ -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)
+ }
+}
diff --git a/backend/controller/rdp_session.go b/backend/controller/rdp_session.go
new file mode 100644
index 0000000..dac402e
--- /dev/null
+++ b/backend/controller/rdp_session.go
@@ -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"})
+}
diff --git a/backend/controller/server.go b/backend/controller/server.go
index 47a0756..a3167e0 100644
--- a/backend/controller/server.go
+++ b/backend/controller/server.go
@@ -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",
diff --git a/backend/controller/server_batch.go b/backend/controller/server_batch.go
index c06da7f..f71237d 100644
--- a/backend/controller/server_batch.go
+++ b/backend/controller/server_batch.go
@@ -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++
diff --git a/backend/controller/server_import_helpers.go b/backend/controller/server_import_helpers.go
new file mode 100644
index 0000000..aaa1e12
--- /dev/null
+++ b/backend/controller/server_import_helpers.go
@@ -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"
+}
diff --git a/backend/controller/session_command.go b/backend/controller/session_command.go
index 131d339..382e43b 100644
--- a/backend/controller/session_command.go
+++ b/backend/controller/session_command.go
@@ -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)
}
diff --git a/backend/controller/webshell.go b/backend/controller/webshell.go
index 4aacdcd..dff6ca4 100644
--- a/backend/controller/webshell.go
+++ b/backend/controller/webshell.go
@@ -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)
diff --git a/backend/dto/dto.go b/backend/dto/dto.go
index b531a0a..6de22f2 100644
--- a/backend/dto/dto.go
+++ b/backend/dto/dto.go
@@ -8,50 +8,58 @@ import "time"
// CreateServerRequest 创建服务器
type CreateServerRequest struct {
- Hostname string `json:"hostname" binding:"required"`
- IP string `json:"ip" binding:"required"`
- PublicIP string `json:"public_ip"`
- Port int `json:"port"`
- Username string `json:"username"`
- Password string `json:"password"`
- SSHKey string `json:"ssh_key"`
- AuthType string `json:"auth_type"`
- OS string `json:"os"`
- OSVersion string `json:"os_version"`
- Arch string `json:"arch"`
- CPU int `json:"cpu"`
- Memory int64 `json:"memory"`
- Disk int64 `json:"disk"`
- RegionID *uint `json:"region_id"`
- CloudID *uint `json:"cloud_id"`
- Tags string `json:"tags"`
- Description string `json:"description"`
+ Hostname string `json:"hostname" binding:"required"`
+ IP string `json:"ip" binding:"required"`
+ PublicIP string `json:"public_ip"`
+ Port int `json:"port"`
+ Username string `json:"username"`
+ Password string `json:"password"`
+ SSHKey string `json:"ssh_key"`
+ AuthType string `json:"auth_type"`
+ CredentialID *uint `json:"credential_id"`
+ OSType string `json:"os_type"`
+ Protocol string `json:"protocol"`
+ RDPPort int `json:"rdp_port"`
+ OS string `json:"os"`
+ OSVersion string `json:"os_version"`
+ Arch string `json:"arch"`
+ CPU int `json:"cpu"`
+ Memory int64 `json:"memory"`
+ Disk int64 `json:"disk"`
+ RegionID *uint `json:"region_id"`
+ CloudID *uint `json:"cloud_id"`
+ Tags string `json:"tags"`
+ Description string `json:"description"`
}
// UpdateServerRequest 更新服务器 — 部分更新 (使用 map)
type UpdateServerRequest struct {
- Hostname *string `json:"hostname"`
- IP *string `json:"ip"`
- PublicIP *string `json:"public_ip"`
- Port *int `json:"port"`
- Username *string `json:"username"`
- Password *string `json:"password"`
- SSHKey *string `json:"ssh_key"`
- AuthType *string `json:"auth_type"`
- OS *string `json:"os"`
- OSVersion *string `json:"os_version"`
- Arch *string `json:"arch"`
- CPU *int `json:"cpu"`
- Memory *int64 `json:"memory"`
- Disk *int64 `json:"disk"`
- Status *string `json:"status"`
- CPUUsage *float64 `json:"cpu_usage"`
- MemoryUsage *float64 `json:"memory_usage"`
- DiskUsage *float64 `json:"disk_usage"`
- RegionID *uint `json:"region_id"`
- CloudID *uint `json:"cloud_id"`
- Tags *string `json:"tags"`
- Description *string `json:"description"`
+ Hostname *string `json:"hostname"`
+ IP *string `json:"ip"`
+ PublicIP *string `json:"public_ip"`
+ Port *int `json:"port"`
+ Username *string `json:"username"`
+ Password *string `json:"password"`
+ SSHKey *string `json:"ssh_key"`
+ AuthType *string `json:"auth_type"`
+ CredentialID *uint `json:"credential_id"`
+ OSType *string `json:"os_type"`
+ Protocol *string `json:"protocol"`
+ RDPPort *int `json:"rdp_port"`
+ OS *string `json:"os"`
+ OSVersion *string `json:"os_version"`
+ Arch *string `json:"arch"`
+ CPU *int `json:"cpu"`
+ Memory *int64 `json:"memory"`
+ Disk *int64 `json:"disk"`
+ Status *string `json:"status"`
+ CPUUsage *float64 `json:"cpu_usage"`
+ MemoryUsage *float64 `json:"memory_usage"`
+ DiskUsage *float64 `json:"disk_usage"`
+ RegionID *uint `json:"region_id"`
+ CloudID *uint `json:"cloud_id"`
+ Tags *string `json:"tags"`
+ Description *string `json:"description"`
}
// ServerListItem 服务器列表项 (精简)
@@ -85,6 +93,12 @@ type CreateCredentialRequest struct {
SSHPort int `json:"ssh_port"`
SSHPassword string `json:"ssh_password"`
+ // RDP
+ RDPUsername string `json:"rdp_username"`
+ RDPPassword string `json:"rdp_password"`
+ RDPDomain string `json:"rdp_domain"`
+ RDPSecurity string `json:"rdp_security"`
+
// Docker
DockerServer string `json:"docker_server"`
DockerUsername string `json:"docker_username"`
@@ -113,6 +127,11 @@ type UpdateCredentialRequest struct {
SSHPort *int `json:"ssh_port"`
SSHPassword *string `json:"ssh_password"`
+ RDPUsername *string `json:"rdp_username"`
+ RDPPassword *string `json:"rdp_password"`
+ RDPDomain *string `json:"rdp_domain"`
+ RDPSecurity *string `json:"rdp_security"`
+
DockerServer *string `json:"docker_server"`
DockerUsername *string `json:"docker_username"`
DockerPassword *string `json:"docker_password"`
@@ -161,14 +180,14 @@ type UpdateAlertContactGroupRequest struct {
// CreateAlertRuleRequest 创建告警规则
type CreateAlertRuleRequest struct {
- Name string `json:"name" binding:"required"`
- Metric string `json:"metric" binding:"required"`
- Condition string `json:"condition" binding:"required"` // >, <, >=, <=, ==
+ Name string `json:"name" binding:"required"`
+ Metric string `json:"metric" binding:"required"`
+ Condition string `json:"condition" binding:"required"` // >, <, >=, <=, ==
Threshold float64 `json:"threshold" binding:"required"`
- Duration string `json:"duration"` // 30s, 1m, 5m
- ContactIDs []uint `json:"contact_ids"`
- Enabled bool `json:"enabled"`
- Description string `json:"description"`
+ Duration string `json:"duration"` // 30s, 1m, 5m
+ ContactIDs []uint `json:"contact_ids"`
+ Enabled bool `json:"enabled"`
+ Description string `json:"description"`
}
// UpdateAlertRuleRequest 更新告警规则
diff --git a/backend/go.mod b/backend/go.mod
index 5d289e0..de42129 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -1,19 +1,23 @@
module ezops
-go 1.24.0
+go 1.25.0
require (
github.com/creack/pty v1.1.24
github.com/gin-gonic/gin v1.9.1
github.com/glebarez/sqlite v1.11.0
github.com/go-ldap/ldap/v3 v3.4.10
+ github.com/go-sql-driver/mysql v1.7.0
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/gosnmp/gosnmp v1.35.0
+ github.com/hirochachacha/go-smb2 v1.1.0
+ github.com/mattn/go-sqlite3 v1.14.47
github.com/nalgeon/redka v0.2.0
github.com/pkg/sftp v1.13.10
github.com/redis/go-redis/v9 v9.7.0
+ github.com/robfig/cron/v3 v3.0.1
github.com/spf13/viper v1.18.2
go.uber.org/zap v1.26.0
golang.org/x/crypto v0.46.0
@@ -32,13 +36,13 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
+ github.com/geoffgarside/ber v1.1.0 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.16.0 // indirect
- github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
@@ -52,13 +56,11 @@ require (
github.com/leodido/go-urn v1.2.4 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
- github.com/mattn/go-sqlite3 v1.14.47 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
- github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
diff --git a/backend/go.sum b/backend/go.sum
index 6fea326..7f8333f 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -30,6 +30,8 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
+github.com/geoffgarside/ber v1.1.0 h1:qTmFG4jJbwiSzSXoNJeHcOprVzZ8Ulde2Rrrifu5U9w=
+github.com/geoffgarside/ber v1.1.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
@@ -76,6 +78,8 @@ github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/C
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
+github.com/hirochachacha/go-smb2 v1.1.0 h1:b6hs9qKIql9eVXAiN0M2wSFY5xnhbHAQoCwRKbaRTZI=
+github.com/hirochachacha/go-smb2 v1.1.0/go.mod h1:8F1A4d5EZzrGu5R7PU163UcMRDJQl4FtcxjBfsY8TZE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
@@ -115,8 +119,6 @@ github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0V
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
-github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
@@ -188,6 +190,7 @@ golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUu
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
@@ -203,6 +206,7 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -224,6 +228,7 @@ golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/backend/main.go b/backend/main.go
index dd20d7b..1ed8774 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -19,6 +19,7 @@ import (
"ezops/seed"
"ezops/pkg/appdir"
"ezops/pkg/auditcleaner"
+ "ezops/pkg/billingsync"
"ezops/pkg/database"
snmppkg "ezops/pkg/snmp"
"ezops/pkg/cronscheduler"
@@ -304,6 +305,42 @@ func main() {
}
}()
+ // 每日 10:30 同步云账号昨日账单(阿里云 T+1 出账) // Logan.Li
+ go func() {
+ runBillingSync := func() {
+ start := time.Now()
+ result := billingsync.SyncDate(db, billingsync.Yesterday(), nil)
+ msg := billingsync.SummaryMessage(result)
+ status := "success"
+ if result.Accounts == 0 {
+ status = "failed"
+ msg = "没有可同步的活跃云账号"
+ } else if result.Created == 0 && result.Updated == 0 && len(result.Errors) > 0 {
+ status = "failed"
+ msg = result.Errors[0]
+ } else if len(result.Errors) > 0 {
+ msg += ";" + result.Errors[0]
+ }
+ systask.Record("cloud_billing_sync", status, msg, time.Since(start))
+ if status == "success" && (result.Created > 0 || result.Updated > 0) {
+ log.Info("云账单定时同步完成",
+ zap.String("date", result.BillingDate),
+ zap.Int("created", result.Created),
+ zap.Int("updated", result.Updated),
+ )
+ }
+ }
+ for {
+ now := time.Now()
+ next := time.Date(now.Year(), now.Month(), now.Day(), 10, 30, 0, 0, now.Location())
+ if !now.Before(next) {
+ next = next.Add(24 * time.Hour)
+ }
+ time.Sleep(time.Until(next))
+ runBillingSync()
+ }
+ }()
+
// 前端静态文件服务(go:embed 模式,二进制部署用) // Logan.Li
setupFrontend(r)
diff --git a/backend/model/agent_cli.go b/backend/model/agent_cli.go
index f7ca633..2593a08 100644
--- a/backend/model/agent_cli.go
+++ b/backend/model/agent_cli.go
@@ -9,9 +9,15 @@ import "time"
type AgentCLISession struct {
ID uint `gorm:"primarykey" json:"id"`
UserID uint `gorm:"index" json:"user_id"` // 发起用户
- ServerID uint `gorm:"index" json:"server_id"` // 目标主机
+ Mode string `gorm:"size:16;default:host;index" json:"mode"` // host | kubernetes
+ ServerID uint `gorm:"index" json:"server_id"` // 主机模式目标主机
ServerName string `gorm:"size:128" json:"server_name"` // 主机名快照
ServerHost string `gorm:"size:64" json:"server_host"` // 主机 IP 快照
+ ClusterID uint `gorm:"index" json:"cluster_id"` // K8s 模式集群 ID
+ ClusterName string `gorm:"size:128" json:"cluster_name"` // 集群名快照
+ Namespace string `gorm:"size:128" json:"namespace"` // K8s 命名空间
+ PodName string `gorm:"size:128" json:"pod_name"` // 可选目标 Pod
+ Container string `gorm:"size:128" json:"container"` // 可选目标容器
Title string `gorm:"size:255" json:"title"` // 任务标题(query 截断)
Status string `gorm:"size:32;default:running" json:"status"` // running/done/aborted/error
Provider string `gorm:"size:50" json:"provider"` // LLM 提供商
diff --git a/backend/model/credential.go b/backend/model/credential.go
index a4f7c3a..06d7c93 100644
--- a/backend/model/credential.go
+++ b/backend/model/credential.go
@@ -9,27 +9,29 @@ import (
// 核心加密字段 content 存储类型特有数据(JSON),API 不返回
//
// 类型与内容 JSON 映射:
-// ssh → {"private_key":"...","public_key":"...","ssh_user":"...","ssh_port":22}
-// docker → {"server":"...","username":"...","password":"..."}
-// tls → {"cert":"...","key":"...","ca":"..."}
-// aksk → {"provider":"...","access_key":"...","secret_key":"...","region":"..."}
-// database → {"db_type":"...","host":"...","port":3306,"db_name":"...","username":"...","password":"..."}
-// api → {"key":"...","endpoint":"..."}
-// generic → {"key":"...","value":"..."}
+//
+// ssh → {"private_key":"...","public_key":"...","ssh_user":"...","ssh_port":22}
+// rdp → {"username":"...","password":"...","domain":"...","security":"nla"}
+// docker → {"server":"...","username":"...","password":"..."}
+// tls → {"cert":"...","key":"...","ca":"..."}
+// aksk → {"provider":"...","access_key":"...","secret_key":"...","region":"..."}
+// database → {"db_type":"...","host":"...","port":3306,"db_name":"...","username":"...","password":"..."}
+// api → {"key":"...","endpoint":"..."}
+// generic → {"key":"...","value":"..."}
type Credential struct {
- ID uint `gorm:"primarykey" json:"id"`
- Name string `gorm:"size:128;not null" json:"name"` // 凭据名称
- VariableName string `gorm:"size:64;index" json:"variable_name,omitempty"` // 环境变量别名 e.g. MYSQL_PROD
- SecretType string `gorm:"size:32;not null;index" json:"secret_type"` // ssh/docker/tls/aksk/database/api/generic
- Summary string `gorm:"size:255" json:"summary,omitempty"` // 关键信息摘要(脱敏展示)
- Content string `gorm:"type:text;not null" json:"-"` // AES 加密后的 JSON,API 不返回
- Tags string `gorm:"type:text" json:"tags,omitempty"` // JSON 数组: ["生产环境","阿里云"]
- Description string `gorm:"size:500" json:"description,omitempty"`
- ExpiresAt *time.Time `json:"expires_at,omitempty"` // 过期时间
- IsActive bool `gorm:"default:true" json:"is_active"` // 启用/禁用
- EnvPrefix string `gorm:"size:32" json:"env_prefix,omitempty"` // 引用 ID(可复制)
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID uint `gorm:"primarykey" json:"id"`
+ Name string `gorm:"size:128;not null" json:"name"` // 凭据名称
+ VariableName string `gorm:"size:64;index" json:"variable_name,omitempty"` // 环境变量别名 e.g. MYSQL_PROD
+ SecretType string `gorm:"size:32;not null;index" json:"secret_type"` // ssh/rdp/docker/tls/aksk/database/api/generic
+ Summary string `gorm:"size:255" json:"summary,omitempty"` // 关键信息摘要(脱敏展示)
+ Content string `gorm:"type:text;not null" json:"-"` // AES 加密后的 JSON,API 不返回
+ Tags string `gorm:"type:text" json:"tags,omitempty"` // JSON 数组: ["生产环境","阿里云"]
+ Description string `gorm:"size:500" json:"description,omitempty"`
+ ExpiresAt *time.Time `json:"expires_at,omitempty"` // 过期时间
+ IsActive bool `gorm:"default:true" json:"is_active"` // 启用/禁用
+ EnvPrefix string `gorm:"size:32" json:"env_prefix,omitempty"` // 引用 ID(可复制)
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
}
func (Credential) TableName() string {
@@ -47,6 +49,14 @@ type SSHSecretContent struct {
Password string `json:"password,omitempty"`
}
+// RDPSecretContent RDP 凭据 content,后端渲染模式下仅服务端解密使用 // Logan.Li
+type RDPSecretContent struct {
+ Username string `json:"username"`
+ Password string `json:"password"`
+ Domain string `json:"domain,omitempty"`
+ Security string `json:"security,omitempty"`
+}
+
// DockerSecretContent Docker 凭据 content
type DockerSecretContent struct {
Server string `json:"server"`
@@ -95,13 +105,13 @@ type GenericSecretContent struct {
// CreateSecretRequest 创建凭据请求
type CreateSecretRequest struct {
- Name string `json:"name" binding:"required"`
- VariableName string `json:"variable_name"`
- SecretType string `json:"secret_type" binding:"required,oneof=ssh docker tls aksk database api generic"`
- Tags []string `json:"tags"`
- Description string `json:"description"`
+ Name string `json:"name" binding:"required"`
+ VariableName string `json:"variable_name"`
+ SecretType string `json:"secret_type" binding:"required,oneof=ssh rdp docker tls aksk database api generic"`
+ Tags []string `json:"tags"`
+ Description string `json:"description"`
ExpiresAt *time.Time `json:"expires_at"`
- IsActive *bool `json:"is_active"`
+ IsActive *bool `json:"is_active"`
// 类型特有字段(明文,后端组装成 JSON 加密存储)
// SSH
@@ -110,6 +120,11 @@ type CreateSecretRequest struct {
SSHUser string `json:"ssh_user,omitempty"`
SSHPort int `json:"ssh_port,omitempty"`
SSHPassword string `json:"ssh_password,omitempty"`
+ // RDP
+ RDPUsername string `json:"rdp_username,omitempty"`
+ RDPPassword string `json:"rdp_password,omitempty"`
+ RDPDomain string `json:"rdp_domain,omitempty"`
+ RDPSecurity string `json:"rdp_security,omitempty"`
// Docker
DockerServer string `json:"docker_server,omitempty"`
DockerUsername string `json:"docker_username,omitempty"`
@@ -140,39 +155,43 @@ type CreateSecretRequest struct {
// UpdateSecretRequest 更新凭据请求
type UpdateSecretRequest struct {
- Name *string `json:"name"`
- VariableName *string `json:"variable_name"`
- Tags []string `json:"tags"`
- Description *string `json:"description"`
+ Name *string `json:"name"`
+ VariableName *string `json:"variable_name"`
+ Tags []string `json:"tags"`
+ Description *string `json:"description"`
ExpiresAt *time.Time `json:"expires_at"`
- IsActive *bool `json:"is_active"`
+ IsActive *bool `json:"is_active"`
// 类型特有字段 — 传了才更新
- SSHPrivateKey *string `json:"ssh_private_key,omitempty"`
- SSHPublicKey *string `json:"ssh_public_key,omitempty"`
- SSHUser *string `json:"ssh_user,omitempty"`
- SSHPort *int `json:"ssh_port,omitempty"`
- SSHPassword *string `json:"ssh_password,omitempty"`
+ SSHPrivateKey *string `json:"ssh_private_key,omitempty"`
+ SSHPublicKey *string `json:"ssh_public_key,omitempty"`
+ SSHUser *string `json:"ssh_user,omitempty"`
+ SSHPort *int `json:"ssh_port,omitempty"`
+ SSHPassword *string `json:"ssh_password,omitempty"`
+ RDPUsername *string `json:"rdp_username,omitempty"`
+ RDPPassword *string `json:"rdp_password,omitempty"`
+ RDPDomain *string `json:"rdp_domain,omitempty"`
+ RDPSecurity *string `json:"rdp_security,omitempty"`
DockerServer *string `json:"docker_server,omitempty"`
DockerUsername *string `json:"docker_username,omitempty"`
DockerPassword *string `json:"docker_password,omitempty"`
- TLSCert *string `json:"tls_cert,omitempty"`
- TLSKey *string `json:"tls_key,omitempty"`
- TLSCA *string `json:"tls_ca,omitempty"`
- AKSKProvider *string `json:"aksk_provider,omitempty"`
- AKSKAccessKey *string `json:"aksk_access_key,omitempty"`
- AKSKSecretKey *string `json:"aksk_secret_key,omitempty"`
- AKSKRegion *string `json:"aksk_region,omitempty"`
- DBType *string `json:"db_type,omitempty"`
- DBHost *string `json:"db_host,omitempty"`
- DBPort *int `json:"db_port,omitempty"`
- DBName *string `json:"db_name,omitempty"`
- DBUsername *string `json:"db_username,omitempty"`
- DBPassword *string `json:"db_password,omitempty"`
- APIKey *string `json:"api_key,omitempty"`
- APIEndpoint *string `json:"api_endpoint,omitempty"`
- GenericKey *string `json:"generic_key,omitempty"`
- GenericValue *string `json:"generic_value,omitempty"`
+ TLSCert *string `json:"tls_cert,omitempty"`
+ TLSKey *string `json:"tls_key,omitempty"`
+ TLSCA *string `json:"tls_ca,omitempty"`
+ AKSKProvider *string `json:"aksk_provider,omitempty"`
+ AKSKAccessKey *string `json:"aksk_access_key,omitempty"`
+ AKSKSecretKey *string `json:"aksk_secret_key,omitempty"`
+ AKSKRegion *string `json:"aksk_region,omitempty"`
+ DBType *string `json:"db_type,omitempty"`
+ DBHost *string `json:"db_host,omitempty"`
+ DBPort *int `json:"db_port,omitempty"`
+ DBName *string `json:"db_name,omitempty"`
+ DBUsername *string `json:"db_username,omitempty"`
+ DBPassword *string `json:"db_password,omitempty"`
+ APIKey *string `json:"api_key,omitempty"`
+ APIEndpoint *string `json:"api_endpoint,omitempty"`
+ GenericKey *string `json:"generic_key,omitempty"`
+ GenericValue *string `json:"generic_value,omitempty"`
}
// SecretListResponse 列表返回(内容脱敏)
diff --git a/backend/model/rdp_session.go b/backend/model/rdp_session.go
new file mode 100644
index 0000000..79c931f
--- /dev/null
+++ b/backend/model/rdp_session.go
@@ -0,0 +1,42 @@
+package model
+
+import "time"
+
+// RDPSession RDP 图形会话记录,独立于 SSH 终端审计表便于后续录屏扩展 // Logan.Li
+type RDPSession struct {
+ ID uint `gorm:"primarykey" json:"id"`
+ UserID *uint `json:"user_id"`
+ Username string `gorm:"size:64" json:"username"`
+ ServerID uint `json:"server_id"`
+ ServerName string `gorm:"size:128" json:"server_name"`
+ ServerIP string `gorm:"size:64" json:"server_ip"`
+ ServerPort int `json:"server_port"`
+ ClientIP string `gorm:"size:64" json:"client_ip"`
+ Width int `gorm:"default:1280" json:"width"`
+ Height int `gorm:"default:800" json:"height"`
+ Status string `gorm:"size:32" json:"status"` // active, closed, failed // Logan.Li
+ Duration int `json:"duration"`
+ Error string `gorm:"size:500" json:"error,omitempty"`
+ RecordData string `gorm:"type:mediumtext" json:"-"` // 图形帧录制 JSON,回放接口单独返回 // Logan.Li
+ RecordSize int `json:"record_size"` // 录制字节数 // Logan.Li
+ StartTime time.Time `json:"start_time"`
+ EndTime *time.Time `json:"end_time"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+func (RDPSession) TableName() string {
+ return "sys_rdp_sessions"
+}
+
+// RDPSessionQuery Windows RDP 图形会话查询 // Logan.Li
+type RDPSessionQuery struct {
+ ServerID uint `form:"server_id"`
+ Username string `form:"username"`
+ Status string `form:"status"`
+ Keyword string `form:"keyword"`
+ StartDate string `form:"start_date"`
+ EndDate string `form:"end_date"`
+ HasRecord string `form:"has_record"`
+ Page int `form:"page,default=1"`
+ PageSize int `form:"page_size,default=20"`
+}
diff --git a/backend/model/server.go b/backend/model/server.go
index b5efa07..8feeab3 100644
--- a/backend/model/server.go
+++ b/backend/model/server.go
@@ -2,42 +2,44 @@ package model
import (
"time"
-
)
// Server 服务器模型
type Server struct {
- ID uint `gorm:"primarykey" json:"id"`
- Hostname string `gorm:"size:128;not null" json:"hostname"`
- IP string `gorm:"size:64;not null" json:"ip"`
- PublicIP string `gorm:"size:64" json:"public_ip"`
- Port int `gorm:"default:22" json:"port"`
- Username string `gorm:"size:64;default:root" json:"username"`
- Password string `gorm:"size:255" json:"-"`
- SSHKey string `gorm:"type:text" json:"-"`
- AuthType string `gorm:"size:32;default:password" json:"auth_type"` // password, key
- OS string `gorm:"size:64" json:"os"`
- Kernel string `gorm:"size:64" json:"kernel"`
- OSVersion string `gorm:"size:64" json:"os_version"`
- Arch string `gorm:"size:32" json:"arch"`
- CPU int `json:"cpu"`
- Memory int64 `json:"memory"` // MB
- Disk int64 `json:"disk"` // GB
- Status string `gorm:"size:32;default:stopped" json:"status"` // running, stopped, maintenance, error
- CPUUsage float64 `json:"cpu_usage"`
- MemoryUsage float64 `json:"memory_usage"`
- DiskUsage float64 `json:"disk_usage"`
- Uptime int64 `json:"uptime"` // seconds
- AgentVersion string `gorm:"size:32" json:"agent_version"`
- AgentStatus string `gorm:"size:32;default:offline" json:"agent_status"` // online, offline
- HasAuth bool `gorm:"-" json:"has_auth"` // 是否有认证凭据(密码或SSH密钥) — 不存DB,handler 计算后返回 // Logan.Li
- RegionID *uint `json:"region_id"`
- CloudID *uint `json:"cloud_id"`
- Tags string `gorm:"type:text" json:"tags"` // JSON
- Description string `gorm:"size:500" json:"description"`
- LastCheckAt *time.Time `json:"last_check_at"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID uint `gorm:"primarykey" json:"id"`
+ Hostname string `gorm:"size:128;not null" json:"hostname"`
+ IP string `gorm:"size:64;not null" json:"ip"`
+ PublicIP string `gorm:"size:64" json:"public_ip"`
+ Port int `gorm:"default:22" json:"port"`
+ Username string `gorm:"size:64;default:root" json:"username"`
+ Password string `gorm:"size:255" json:"-"`
+ SSHKey string `gorm:"type:text" json:"-"`
+ AuthType string `gorm:"size:32;default:password" json:"auth_type"` // password, key
+ OSType string `gorm:"size:32;default:linux" json:"os_type"` // 操作系统类型:linux/windows // Logan.Li
+ Protocol string `gorm:"size:32;default:ssh" json:"protocol"` // 远程协议:ssh/rdp // Logan.Li
+ RDPPort int `gorm:"default:3389" json:"rdp_port"` // Windows RDP 端口 // Logan.Li
+ OS string `gorm:"size:64" json:"os"`
+ Kernel string `gorm:"size:64" json:"kernel"`
+ OSVersion string `gorm:"size:64" json:"os_version"`
+ Arch string `gorm:"size:32" json:"arch"`
+ CPU int `json:"cpu"`
+ Memory int64 `json:"memory"` // MB
+ Disk int64 `json:"disk"` // GB
+ Status string `gorm:"size:32;default:stopped" json:"status"` // running, stopped, maintenance, error
+ CPUUsage float64 `json:"cpu_usage"`
+ MemoryUsage float64 `json:"memory_usage"`
+ DiskUsage float64 `json:"disk_usage"`
+ Uptime int64 `json:"uptime"` // seconds
+ AgentVersion string `gorm:"size:32" json:"agent_version"`
+ AgentStatus string `gorm:"size:32;default:offline" json:"agent_status"` // online, offline
+ HasAuth bool `gorm:"-" json:"has_auth"` // 是否有认证凭据(密码或SSH密钥) — 不存DB,handler 计算后返回 // Logan.Li
+ RegionID *uint `json:"region_id"`
+ CloudID *uint `json:"cloud_id"`
+ Tags string `gorm:"type:text" json:"tags"` // JSON
+ Description string `gorm:"size:500" json:"description"`
+ LastCheckAt *time.Time `json:"last_check_at"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
// 关联
Region *Region `json:"region,omitempty"`
@@ -51,15 +53,15 @@ func (Server) TableName() string {
// Cloud 云平台模型
type Cloud struct {
- ID uint `gorm:"primarykey" json:"id"`
- Name string `gorm:"size:64;not null" json:"name"`
- Code string `gorm:"size:64;uniqueIndex;not null" json:"code"`
- Type string `gorm:"size:32;default:public" json:"type"` // public, private, hybrid
- Status string `gorm:"size:32;default:active" json:"status"`
- Website string `gorm:"size:255" json:"website"`
- Description string `gorm:"size:500" json:"description"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID uint `gorm:"primarykey" json:"id"`
+ Name string `gorm:"size:64;not null" json:"name"`
+ Code string `gorm:"size:64;uniqueIndex;not null" json:"code"`
+ Type string `gorm:"size:32;default:public" json:"type"` // public, private, hybrid
+ Status string `gorm:"size:32;default:active" json:"status"`
+ Website string `gorm:"size:255" json:"website"`
+ Description string `gorm:"size:500" json:"description"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
}
// TableName 表名
@@ -69,18 +71,18 @@ func (Cloud) TableName() string {
// CloudAccount 云账号模型
type CloudAccount struct {
- ID uint `gorm:"primarykey" json:"id"`
- Name string `gorm:"size:128;not null" json:"name"`
- Provider string `gorm:"size:64;not null" json:"provider"` // aws, aliyun, tencent, huawei, azure
- AccessKey string `gorm:"size:255;not null" json:"-"`
- SecretKey string `gorm:"size:255;not null" json:"-"`
- Status string `gorm:"size:32;default:active" json:"status"` // active, inactive, error
- RegionCount int `gorm:"default:0" json:"region_count"`
- ServerCount int `gorm:"default:0" json:"server_count"`
- MonthlyCost float64 `gorm:"default:0" json:"monthly_cost"` // 月费用(元),驱动仪表盘成本统计
- LastSyncAt *time.Time `json:"last_sync_at"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID uint `gorm:"primarykey" json:"id"`
+ Name string `gorm:"size:128;not null" json:"name"`
+ Provider string `gorm:"size:64;not null" json:"provider"` // aws, aliyun, tencent, huawei, azure
+ AccessKey string `gorm:"size:255;not null" json:"-"`
+ SecretKey string `gorm:"size:255;not null" json:"-"`
+ Status string `gorm:"size:32;default:active" json:"status"` // active, inactive, error
+ RegionCount int `gorm:"default:0" json:"region_count"`
+ ServerCount int `gorm:"default:0" json:"server_count"`
+ MonthlyCost float64 `gorm:"default:0" json:"monthly_cost"` // 月费用(元),驱动仪表盘成本统计
+ LastSyncAt *time.Time `json:"last_sync_at"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
}
// TableName 表名
@@ -90,16 +92,16 @@ func (CloudAccount) TableName() string {
// Region 区域模型
type Region struct {
- ID uint `gorm:"primarykey" json:"id"`
- Name string `gorm:"size:64;not null" json:"name"`
- Code string `gorm:"size:64;uniqueIndex;not null" json:"code"`
- Provider string `gorm:"size:64;not null" json:"provider"`
- Location string `gorm:"size:128" json:"location"`
- Status string `gorm:"size:32;default:active" json:"status"`
- Endpoint string `gorm:"size:255" json:"endpoint"`
- VPCCount int `gorm:"default:0" json:"vpc_count"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID uint `gorm:"primarykey" json:"id"`
+ Name string `gorm:"size:64;not null" json:"name"`
+ Code string `gorm:"size:64;uniqueIndex;not null" json:"code"`
+ Provider string `gorm:"size:64;not null" json:"provider"`
+ Location string `gorm:"size:128" json:"location"`
+ Status string `gorm:"size:32;default:active" json:"status"`
+ Endpoint string `gorm:"size:255" json:"endpoint"`
+ VPCCount int `gorm:"default:0" json:"vpc_count"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
}
// TableName 表名
@@ -109,17 +111,17 @@ func (Region) TableName() string {
// Domain 域名模型
type Domain struct {
- ID uint `gorm:"primarykey" json:"id"`
- Name string `gorm:"size:255;uniqueIndex;not null" json:"name"`
- Registrar string `gorm:"size:128" json:"registrar"`
- Status string `gorm:"size:32;default:active" json:"status"` // active, expired, pending
- ExpireDate *time.Time `json:"expire_date"`
- DNSProvider string `gorm:"size:128" json:"dns_provider"`
- RecordCount int `gorm:"default:0" json:"record_count"`
- SSLEnabled bool `gorm:"default:false" json:"ssl_enabled"`
- SSLExpireDate *time.Time `json:"ssl_expire_date"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID uint `gorm:"primarykey" json:"id"`
+ Name string `gorm:"size:255;uniqueIndex;not null" json:"name"`
+ Registrar string `gorm:"size:128" json:"registrar"`
+ Status string `gorm:"size:32;default:active" json:"status"` // active, expired, pending
+ ExpireDate *time.Time `json:"expire_date"`
+ DNSProvider string `gorm:"size:128" json:"dns_provider"`
+ RecordCount int `gorm:"default:0" json:"record_count"`
+ SSLEnabled bool `gorm:"default:false" json:"ssl_enabled"`
+ SSLExpireDate *time.Time `json:"ssl_expire_date"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
}
// TableName 表名
@@ -129,46 +131,52 @@ func (Domain) TableName() string {
// CreateServerRequest 创建服务器请求
type CreateServerRequest struct {
- Hostname string `json:"hostname" binding:"required"`
- IP string `json:"ip" binding:"required,ip"`
- PublicIP string `json:"public_ip" binding:"omitempty,ip"`
- Port int `json:"port" binding:"omitempty,min=1,max=65535"`
- Username string `json:"username"`
- Password string `json:"password"`
- SSHKey string `json:"ssh_key"`
- AuthType string `json:"auth_type" binding:"omitempty,oneof=password key"`
- CredentialID *uint `json:"credential_id,omitempty"` // 关联 SSH 凭证,非空则后端解密覆盖认证字段
- OS string `json:"os"`
- CPU int `json:"cpu"`
- Memory int64 `json:"memory"`
- Disk int64 `json:"disk"`
- Status string `json:"status" binding:"omitempty,oneof=running stopped maintenance error"`
- RegionID *uint `json:"region_id"`
- CloudID *uint `json:"cloud_id"`
- Tags string `json:"tags"`
- Description string `json:"description"`
+ Hostname string `json:"hostname" binding:"required"`
+ IP string `json:"ip" binding:"required,ip"`
+ PublicIP string `json:"public_ip" binding:"omitempty,ip"`
+ Port int `json:"port" binding:"omitempty,min=1,max=65535"`
+ Username string `json:"username"`
+ Password string `json:"password"`
+ SSHKey string `json:"ssh_key"`
+ AuthType string `json:"auth_type" binding:"omitempty,oneof=password key"`
+ CredentialID *uint `json:"credential_id,omitempty"` // 关联 SSH 凭证,非空则后端解密覆盖认证字段
+ OSType string `json:"os_type" binding:"omitempty,oneof=linux windows"` // 操作系统类型 // Logan.Li
+ Protocol string `json:"protocol" binding:"omitempty,oneof=ssh rdp"` // 远程协议 // Logan.Li
+ RDPPort int `json:"rdp_port" binding:"omitempty,min=1,max=65535"` // RDP 端口 // Logan.Li
+ OS string `json:"os"`
+ CPU int `json:"cpu"`
+ Memory int64 `json:"memory"`
+ Disk int64 `json:"disk"`
+ Status string `json:"status" binding:"omitempty,oneof=running stopped maintenance error"`
+ RegionID *uint `json:"region_id"`
+ CloudID *uint `json:"cloud_id"`
+ Tags string `json:"tags"`
+ Description string `json:"description"`
}
// UpdateServerRequest 更新服务器请求
type UpdateServerRequest struct {
- Hostname string `json:"hostname"`
- IP string `json:"ip" binding:"omitempty,ip"`
- PublicIP string `json:"public_ip" binding:"omitempty,ip"`
- Port int `json:"port" binding:"omitempty,min=1,max=65535"`
- Username string `json:"username"`
- Password string `json:"password"`
- SSHKey string `json:"ssh_key"`
- AuthType string `json:"auth_type" binding:"omitempty,oneof=password key"`
- CredentialID *uint `json:"credential_id,omitempty"` // 关联 SSH 凭证,非空则后端解密覆盖认证字段
- OS string `json:"os"`
- CPU int `json:"cpu"`
- Memory int64 `json:"memory"`
- Disk int64 `json:"disk"`
- Status string `json:"status" binding:"omitempty,oneof=running stopped maintenance error"`
- RegionID *uint `json:"region_id"`
- CloudID *uint `json:"cloud_id"`
- Tags string `json:"tags"`
- Description string `json:"description"`
+ Hostname string `json:"hostname"`
+ IP string `json:"ip" binding:"omitempty,ip"`
+ PublicIP string `json:"public_ip" binding:"omitempty,ip"`
+ Port int `json:"port" binding:"omitempty,min=1,max=65535"`
+ Username string `json:"username"`
+ Password string `json:"password"`
+ SSHKey string `json:"ssh_key"`
+ AuthType string `json:"auth_type" binding:"omitempty,oneof=password key"`
+ CredentialID *uint `json:"credential_id,omitempty"` // 关联 SSH 凭证,非空则后端解密覆盖认证字段
+ OSType string `json:"os_type" binding:"omitempty,oneof=linux windows"` // 操作系统类型 // Logan.Li
+ Protocol string `json:"protocol" binding:"omitempty,oneof=ssh rdp"` // 远程协议 // Logan.Li
+ RDPPort int `json:"rdp_port" binding:"omitempty,min=1,max=65535"` // RDP 端口 // Logan.Li
+ OS string `json:"os"`
+ CPU int `json:"cpu"`
+ Memory int64 `json:"memory"`
+ Disk int64 `json:"disk"`
+ Status string `json:"status" binding:"omitempty,oneof=running stopped maintenance error"`
+ RegionID *uint `json:"region_id"`
+ CloudID *uint `json:"cloud_id"`
+ Tags string `json:"tags"`
+ Description string `json:"description"`
}
// BatchServerItem 批量导入单条服务器 // Logan.Li
@@ -177,6 +185,9 @@ type BatchServerItem struct {
IP string `json:"ip"`
Port int `json:"port"`
Username string `json:"username"`
+ OSType string `json:"os_type"` // linux/windows // Logan.Li
+ Protocol string `json:"protocol"` // ssh/rdp // Logan.Li
+ RDPPort int `json:"rdp_port"` // Windows RDP 端口 // Logan.Li
OS string `json:"os"`
CPU int `json:"cpu"`
Memory int64 `json:"memory"` // GB(后端转 MB 存储)
@@ -186,10 +197,12 @@ type BatchServerItem struct {
// BatchCreateServerRequest 批量导入服务器请求 // Logan.Li
type BatchCreateServerRequest struct {
- Servers []BatchServerItem `json:"servers"`
- RegionID *uint `json:"region_id"`
- AuthType string `json:"auth_type"`
- CredentialID *uint `json:"credential_id"` // 凭据ID,非空则解密后写入每台服务器
+ Servers []BatchServerItem `json:"servers"`
+ RegionID *uint `json:"region_id"`
+ AuthType string `json:"auth_type"`
+ 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
}
// BatchCreateResult 批量导入结果 // Logan.Li
@@ -217,9 +230,9 @@ type ServerStats struct {
AvgMemory float64 `json:"avg_memory"`
AvgDisk float64 `json:"avg_disk"`
// 总资源概览(GB/核),用于仪表盘资源使用率卡片展示具体容量 // Logan.Li
- TotalCPU int64 `json:"total_cpu"` // CPU 核心数合计
- TotalMemory int64 `json:"total_memory"` // 内存总量 GB
- UsedMemory int64 `json:"used_memory"` // 内存已用 GB
- TotalDisk int64 `json:"total_disk"` // 磁盘总量 GB
- UsedDisk int64 `json:"used_disk"` // 磁盘已用 GB
+ TotalCPU int64 `json:"total_cpu"` // CPU 核心数合计
+ TotalMemory int64 `json:"total_memory"` // 内存总量 GB
+ UsedMemory int64 `json:"used_memory"` // 内存已用 GB
+ TotalDisk int64 `json:"total_disk"` // 磁盘总量 GB
+ UsedDisk int64 `json:"used_disk"` // 磁盘已用 GB
}
diff --git a/backend/model/session_command.go b/backend/model/session_command.go
index 2fe36b4..79f3363 100644
--- a/backend/model/session_command.go
+++ b/backend/model/session_command.go
@@ -4,16 +4,17 @@ import "time"
// SessionCommand 会话命令记录(JumpServer 式命令审计) // Logan.Li
type SessionCommand struct {
- ID uint `gorm:"primarykey" json:"id"`
- SessionID uint `gorm:"index" json:"session_id"` // 关联终端会话
- UserID *uint `json:"user_id"`
- Username string `gorm:"size:64" json:"username"`
- ServerID uint `json:"server_id"`
- ServerName string `gorm:"size:128" json:"server_name"`
- Command string `gorm:"type:text" json:"command"` // 执行的原始命令
- OutputSize *int `json:"output_size"` // 下一条命令前累计的终端输出字节数,nil=历史未统计 // Logan.Li
- Timestamp time.Time `json:"timestamp"` // 命令执行时间
- CreatedAt time.Time `json:"created_at"`
+ ID uint `gorm:"primarykey" json:"id"`
+ SessionID uint `gorm:"index" json:"session_id"` // 关联终端会话
+ SessionType string `gorm:"size:32;default:terminal" json:"session_type"` // terminal/rdp,用于区分回放入口 // Logan.Li
+ UserID *uint `json:"user_id"`
+ Username string `gorm:"size:64" json:"username"`
+ ServerID uint `json:"server_id"`
+ ServerName string `gorm:"size:128" json:"server_name"`
+ Command string `gorm:"type:text" json:"command"` // 执行的原始命令
+ OutputSize *int `json:"output_size"` // 下一条命令前累计的终端输出字节数,nil=历史未统计 // Logan.Li
+ Timestamp time.Time `json:"timestamp"` // 命令执行时间
+ CreatedAt time.Time `json:"created_at"`
}
func (SessionCommand) TableName() string {
diff --git a/backend/pkg/agentcli/agentcli.go b/backend/pkg/agentcli/agentcli.go
index 4195baa..c3c8511 100644
--- a/backend/pkg/agentcli/agentcli.go
+++ b/backend/pkg/agentcli/agentcli.go
@@ -18,13 +18,42 @@ import (
"ezops/pkg/agentcli/llm"
"ezops/pkg/agentcli/safety"
"ezops/pkg/agentcli/tools"
+ k8sclient "ezops/pkg/kubernetesclient"
"ezops/pkg/remoteexec"
)
-// EngineConfig 引擎配置(由 controller 从 model.LLMConfig + model.Server 构建后注入) // Logan.Li
+// RunMode Agent 运行模式 // Logan.Li
+const (
+ RunModeHost = "host"
+ RunModeKubernetes = "kubernetes"
+)
+
+// NormalizeRunMode 规范化运行模式(兼容旧 k8s 参数) // Logan.Li
+func NormalizeRunMode(mode string) string {
+ if mode == "k8s" || mode == RunModeKubernetes {
+ return RunModeKubernetes
+ }
+ if mode == "" {
+ return RunModeHost
+ }
+ return mode
+}
+
+// KubernetesTarget Kubernetes 模式目标上下文(由 controller 注入) // Logan.Li
+type KubernetesTarget struct {
+ Client *k8sclient.Client
+ ClusterName string
+ Namespace string
+ PodName string
+ Container string
+}
+
+// EngineConfig 引擎配置(由 controller 从 model.LLMConfig + 目标上下文构建后注入) // Logan.Li
type EngineConfig struct {
+ Mode string // host | kubernetes
LLM llm.Config // LLM 客户端配置(endpoint 已解析)
- SSH remoteexec.SSHConfig // 目标主机 SSH 配置(含凭据)
+ SSH remoteexec.SSHConfig // 主机模式 SSH 配置
+ Kubernetes *KubernetesTarget // Kubernetes 模式目标
SafetyMode safety.Mode // 安全模式(默认 balanced)
Core *core.Config // ReAct 配置(nil 用默认)
MemoryDir string // Evolver 记忆目录(空则 data/agentcli)
@@ -47,25 +76,40 @@ func NewEngine(cfg EngineConfig) (*Engine, error) {
if cfg.MemoryDir != "" {
coreCfg.MemoryDir = cfg.MemoryDir
}
- mode := cfg.SafetyMode
- if mode == "" {
- mode = safety.ModeBalanced
+ safetyMode := cfg.SafetyMode
+ if safetyMode == "" {
+ safetyMode = safety.ModeBalanced
}
// LLM 客户端
client := llm.NewClient(cfg.LLM)
- // 工具注册中心:ssh_execute(SSH 长连接)+ analyze_output
+ // 工具注册中心:按模式注册互斥工具集 // Logan.Li
registry := tools.NewRegistry()
- sshTool, err := tools.NewSSHExecuteTool(cfg.SSH, coreCfg.ToolTimeout)
- if err != nil {
- return nil, fmt.Errorf("ssh_execute 初始化失败: %w", err)
+ runMode := NormalizeRunMode(cfg.Mode)
+ coreCfg.Mode = runMode
+
+ switch runMode {
+ case RunModeKubernetes:
+ if cfg.Kubernetes == nil || cfg.Kubernetes.Client == nil {
+ return nil, fmt.Errorf("Kubernetes 模式缺少集群客户端")
+ }
+ kt := cfg.Kubernetes
+ registry.Register(tools.NewKubernetesGetTool(kt.Client, kt.Namespace))
+ registry.Register(tools.NewPodLogsTool(kt.Client, kt.Namespace, kt.PodName, kt.Container))
+ registry.Register(tools.NewPodExecuteTool(kt.Client, kt.Namespace, kt.PodName, kt.Container, coreCfg.ToolTimeout))
+ registry.Register(tools.NewKubernetesActionTool(kt.Client, kt.Namespace))
+ default:
+ sshTool, err := tools.NewSSHExecuteTool(cfg.SSH, coreCfg.ToolTimeout)
+ if err != nil {
+ return nil, fmt.Errorf("ssh_execute 初始化失败: %w", err)
+ }
+ registry.Register(sshTool)
}
- registry.Register(sshTool)
registry.Register(tools.NewAnalyzeOutputTool())
// 安全控制器
- sc := safety.NewController(mode)
+ sc := safety.NewController(safetyMode)
// Evolver(LLM 可用时启用学习;失败降级)
ev, err := evolver.NewEvolverEngine(coreCfg.MemoryDir, client)
@@ -86,9 +130,9 @@ func (e *Engine) SetConfirmFn(fn safety.ConfirmFn) { e.safety.SetConfirmFn(fn) }
// SetAutoApprove 测试用:自动批准(生产禁用) // Logan.Li
func (e *Engine) SetAutoApprove(b bool) { e.safety.SetAutoApprove(b) }
-// Run 执行任务(ReAct 循环);prior 为上一轮对话历史(不含 system),priorTokens 为累计 token // Logan.Li
-func (e *Engine) Run(ctx context.Context, query, serverInfo string, prior []llm.Message, priorTokens int) (*core.RunResult, error) {
- return e.agent.Run(ctx, query, serverInfo, prior, priorTokens)
+// Run 执行任务(ReAct 循环);targetInfo 为目标上下文摘要;prior 为上一轮对话历史(不含 system) // Logan.Li
+func (e *Engine) Run(ctx context.Context, query, targetInfo string, prior []llm.Message, priorTokens int) (*core.RunResult, error) {
+ return e.agent.Run(ctx, query, targetInfo, prior, priorTokens)
}
// EvolveStats 进化统计(供审计展示) // Logan.Li
diff --git a/backend/pkg/agentcli/core/agent.go b/backend/pkg/agentcli/core/agent.go
index b9e6238..b7ca228 100644
--- a/backend/pkg/agentcli/core/agent.go
+++ b/backend/pkg/agentcli/core/agent.go
@@ -61,6 +61,7 @@ type RunResult struct {
// Config Agent 运行配置 // Logan.Li
type Config struct {
+ Mode string // host | kubernetes
MaxIterations int
Temperature float64
ToolTimeout time.Duration
@@ -135,7 +136,7 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string, prior []llm.M
// 工具描述
toolDescs := a.buildToolDescriptions()
- systemPrompt := prompt.BuildSystemPrompt(serverInfo, toolDescs, evolveCtx)
+ systemPrompt := prompt.BuildSystemPrompt(a.config.Mode, serverInfo, toolDescs, evolveCtx)
messages := []llm.Message{{Role: "system", Content: systemPrompt}}
if len(prior) > 0 {
@@ -191,7 +192,7 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string, prior []llm.M
}
messages = append(messages, llm.Message{
Role: "user",
- Content: "【系统提示】请立即调用 ssh_execute 工具在目标主机执行命令获取实际结果,不要仅用文字描述计划。",
+ Content: fmt.Sprintf("【系统提示】请立即调用 %s 工具获取实际结果,不要仅用文字描述计划。", a.primaryExecTool()),
})
continue
}
@@ -303,8 +304,8 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist
success = false
}
- // 超长 ssh 输出自动分析,避免 token 爆炸 // Logan.Li
- if tc.Function.Name == "ssh_execute" && success && len(output) > a.autoAnalyzeThreshold() {
+ // 超长命令输出自动分析,避免 token 爆炸 // Logan.Li
+ if (tc.Function.Name == "ssh_execute" || tc.Function.Name == "pod_execute") && success && len(output) > a.autoAnalyzeThreshold() {
if analyzed := a.autoAnalyzeOutput(ctx, output); analyzed != "" {
output = analyzed
}
@@ -364,6 +365,14 @@ func (a *Agent) roundNum(iter int) int {
return a.config.RoundOffset + iter + 1
}
+// primaryExecTool 返回当前模式下的主执行工具名 // Logan.Li
+func (a *Agent) primaryExecTool() string {
+ if a.config.Mode == "kubernetes" || a.config.Mode == "k8s" {
+ return "kubernetes_get"
+ }
+ return "ssh_execute"
+}
+
// lastToolOutput 取最近一条工具 observation,供最终答案为空时回退展示 // Logan.Li
func lastToolOutput(messages []llm.Message) string {
for i := len(messages) - 1; i >= 0; i-- {
diff --git a/backend/pkg/agentcli/prompt/prompt.go b/backend/pkg/agentcli/prompt/prompt.go
index c8c99f8..5752e40 100644
--- a/backend/pkg/agentcli/prompt/prompt.go
+++ b/backend/pkg/agentcli/prompt/prompt.go
@@ -1,5 +1,4 @@
// Package prompt 构建 Agent CLI 的 System Prompt(五层架构 + 动态记忆注入)。 // Logan.Li
-// 设计参考 opsxcli internal/agent/prompt,角色改写为 aiops Web 运维 Agent 语境。
package prompt
import (
@@ -7,34 +6,59 @@ import (
"strings"
)
-// BuildSystemPrompt 构建五层 System Prompt。 // Logan.Li
-// - Layer 1: 角色定位(aiops 智能运维 Agent)
-// - Layer 2: 安全规则(黑名单/高危确认/优先只读)
-// - Layer 3: 工具说明(由 registry 动态生成)
-// - Layer 4: 工作方式(ReAct 循环:思考→执行→观察→失败则换法重试→直到解决)
-// - Layer 5: 记忆注入(Evolver 提供的历史经验)
-//
-// serverInfo:目标主机摘要;toolDescs:工具描述文本;evolveContext:历史经验。
-func BuildSystemPrompt(serverInfo, toolDescs, evolveContext string) string {
+const runModeKubernetes = "kubernetes"
+
+// normalizeMode 规范化运行模式(兼容旧 k8s,避免 import 循环) // Logan.Li
+func normalizeMode(mode string) string {
+ if mode == "k8s" || mode == runModeKubernetes {
+ return runModeKubernetes
+ }
+ if mode == "" {
+ return "host"
+ }
+ return mode
+}
+
+// BuildSystemPrompt 构建五层 System Prompt。mode: host|kubernetes // Logan.Li
+func BuildSystemPrompt(mode, targetInfo, toolDescs, evolveContext string) string {
+ mode = normalizeMode(mode)
var sb strings.Builder
// Layer 1: 角色
- sb.WriteString("你是 aiops 平台的 Agent CLI —— 一个企业级智能运维 Agent," +
- "类似 Claude Code / Codex,但运行在 Web 页面上,通过 SSH 工具远程操作真实服务器。\n")
- sb.WriteString("你的职责是:根据用户的自然语言任务描述,自主在目标服务器上排查故障、安装与配置软件、修复问题," +
- "并通过 WebSocket 把每一步思考、执行的命令和输出实时反馈给用户。\n\n")
+ if mode == runModeKubernetes {
+ sb.WriteString("你是 aiops 平台的 Agent CLI —— 一个企业级 Kubernetes 智能运维 Agent," +
+ "运行在 Web 页面上,通过 kubernetes_get / pod_logs / pod_execute / kubernetes_action 操作真实 Kubernetes 集群。\n")
+ sb.WriteString("你的职责是:根据用户自然语言任务,自主查询集群/命名空间资源状态、分析事件与日志、" +
+ "必要时进入 Pod 容器排障,并通过 WebSocket 实时反馈每一步思考与结果。\n\n")
+ } else {
+ sb.WriteString("你是 aiops 平台的 Agent CLI —— 一个企业级智能运维 Agent," +
+ "类似 Claude Code / Codex,但运行在 Web 页面上,通过 SSH 工具远程操作真实服务器。\n")
+ sb.WriteString("你的职责是:根据用户的自然语言任务描述,自主在目标服务器上排查故障、安装与配置软件、修复问题," +
+ "并通过 WebSocket 把每一步思考、执行的命令和输出实时反馈给用户。\n\n")
+ }
- // 目标主机
- if strings.TrimSpace(serverInfo) != "" {
- sb.WriteString("【目标主机】\n" + serverInfo + "\n\n")
+ // 目标上下文
+ if strings.TrimSpace(targetInfo) != "" {
+ label := "目标主机"
+ if mode == runModeKubernetes {
+ label = "目标集群上下文"
+ }
+ sb.WriteString(fmt.Sprintf("【%s】\n%s\n\n", label, targetInfo))
}
// Layer 2: 安全规则
sb.WriteString("【安全规则】\n")
sb.WriteString("1. 破坏性命令(rm -rf /、mkfs、dd 覆写磁盘等)被严格禁止,绝不执行。\n")
- sb.WriteString("2. 高危操作(reboot、systemctl restart、kill、卸载软件等)需要用户确认后才能执行。\n")
- sb.WriteString("3. 排障时优先使用只读命令(df/ps/cat/grep/tail/systemctl status/journalctl)收集信息,确认根因后再动手修复。\n")
- sb.WriteString("4. 不要执行任何你不确定后果的命令;宁可多问用户,也不盲目操作。\n\n")
+ sb.WriteString("2. 高危操作(删除 Pod、重启 Deployment、扩缩容、换镜像等)需要用户确认后才能执行。\n")
+ if mode == runModeKubernetes {
+ sb.WriteString("3. 排障时优先使用只读工具:kubernetes_get 查资源状态、pod_logs 看日志;确认根因后再 pod_execute 或 kubernetes_action。\n")
+ sb.WriteString("4. 容器内可能没有 systemctl、宿主机路径;不要假设节点级命令可用。\n")
+ sb.WriteString("5. 未选定 Pod 时,用 kubernetes_get 和 pod_logs(带 pod 参数)排障;选定 Pod 后可直接 pod_execute。\n")
+ } else {
+ sb.WriteString("3. 排障时优先使用只读命令(df/ps/cat/grep/tail/systemctl status/journalctl)收集信息,确认根因后再动手修复。\n")
+ sb.WriteString("4. 不要执行任何你不确定后果的命令;宁可多问用户,也不盲目操作。\n")
+ }
+ sb.WriteString("\n")
// Layer 3: 工具
if strings.TrimSpace(toolDescs) != "" {
@@ -45,20 +69,24 @@ func BuildSystemPrompt(serverInfo, toolDescs, evolveContext string) string {
sb.WriteString("【工作方式(ReAct 循环)】\n")
sb.WriteString("你采用「思考-行动-观察」循环解决任务:\n")
sb.WriteString("1. 思考:分析当前状态,决定下一步。在回复中先用简短文字说明你的判断和计划。\n")
- sb.WriteString("2. 行动:调用 ssh_execute 执行命令(一次只做关键的、可验证的一步)。\n")
- sb.WriteString("3. 观察:仔细阅读命令输出(含 exit_code,0 表示成功)。\n")
- sb.WriteString("4. 循环:如果命令失败或问题未解决,分析错误原因,换一种方法重试,直到真正解决。不要因一次失败就放弃。\n")
+ if mode == runModeKubernetes {
+ sb.WriteString("2. 行动:优先 kubernetes_get / pod_logs;需要容器内信息时用 pod_execute;变更用 kubernetes_action(一次一步)。\n")
+ sb.WriteString("3. 观察:仔细阅读工具输出;pod_execute 输出含 [exit_code=0] 表示成功。\n")
+ } else {
+ sb.WriteString("2. 行动:调用 ssh_execute 执行命令(一次只做关键的、可验证的一步)。\n")
+ sb.WriteString("3. 观察:仔细阅读命令输出(含 exit_code,0 表示成功)。\n")
+ }
+ sb.WriteString("4. 循环:如果失败或问题未解决,分析原因,换一种方法重试,直到真正解决。不要因一次失败就放弃。\n")
sb.WriteString("5. 收尾:问题解决后,用清晰的中文总结:问题根因、你做了什么、最终结果、后续建议。然后停止调用工具。\n\n")
- sb.WriteString("【重要】务必真正解决问题,而不是假装解决。每一步都要基于实际命令输出判断," +
- "禁止编造不存在的输出。命令超时或连接失败时,向用户说明并建议排查方向。\n\n")
+ sb.WriteString("【重要】务必真正解决问题,而不是假装解决。每一步都要基于实际工具输出判断," +
+ "禁止编造不存在的输出。超时或 API 失败时,向用户说明并建议排查方向。\n\n")
// Layer 5: 记忆注入
if strings.TrimSpace(evolveContext) != "" {
sb.WriteString("【历史经验(来自自进化引擎)】\n" + evolveContext + "\n\n")
}
- // 输出要求
sb.WriteString("【输出要求】使用简体中文,简洁专业。思考与命令穿插呈现,避免冗长空话。")
return sb.String()
}
@@ -70,4 +98,4 @@ func FormatToolDescriptions(items []struct{ Name, Desc string }) string {
sb.WriteString(fmt.Sprintf("- %s: %s\n", it.Name, it.Desc))
}
return sb.String()
-}
+}
\ No newline at end of file
diff --git a/backend/pkg/agentcli/tools/builtin.go b/backend/pkg/agentcli/tools/builtin.go
index a9f35f8..eb95c60 100644
--- a/backend/pkg/agentcli/tools/builtin.go
+++ b/backend/pkg/agentcli/tools/builtin.go
@@ -20,7 +20,7 @@ func (t *AnalyzeOutputTool) Name() string { return "analyze_output" }
// Description 给 LLM 的说明 // Logan.Li
func (t *AnalyzeOutputTool) Description() string {
return "分析一段(可能很长的)命令输出文本:统计行数、提取疑似错误/告警行、必要时截断保留首尾。" +
- "当 ssh_execute 返回内容过长时调用此工具提炼关键信息再继续推理。"
+ "当 ssh_execute 或 pod_execute 返回内容过长时调用此工具提炼关键信息再继续推理。"
}
// Parameters 参数定义 // Logan.Li
diff --git a/backend/pkg/agentcli/tools/kubernetes_action.go b/backend/pkg/agentcli/tools/kubernetes_action.go
new file mode 100644
index 0000000..d57a158
--- /dev/null
+++ b/backend/pkg/agentcli/tools/kubernetes_action.go
@@ -0,0 +1,131 @@
+// kubernetes_action.go - Kubernetes 集群变更操作(高风险,需用户确认) // Logan.Li
+package tools
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ k8sclient "ezops/pkg/kubernetesclient"
+)
+
+// KubernetesActionTool 执行 Kubernetes 变更动作 // Logan.Li
+type KubernetesActionTool struct {
+ client *k8sclient.Client
+ namespace string
+}
+
+// NewKubernetesActionTool 创建变更工具 // Logan.Li
+func NewKubernetesActionTool(client *k8sclient.Client, namespace string) *KubernetesActionTool {
+ return &KubernetesActionTool{client: client, namespace: namespace}
+}
+
+// Name 工具名 // Logan.Li
+func (t *KubernetesActionTool) Name() string { return "kubernetes_action" }
+
+// Description 给 LLM 的说明 // Logan.Li
+func (t *KubernetesActionTool) Description() string {
+ return "对 Kubernetes 资源执行变更操作(高风险,需用户确认)。" +
+ "action 支持: restart_deployment, scale_deployment, update_image, restart_statefulset, delete_pod, suspend_cronjob。" +
+ "变更前请先用 kubernetes_get 确认当前状态。"
+}
+
+// Parameters JSON Schema // Logan.Li
+func (t *KubernetesActionTool) Parameters() map[string]interface{} {
+ return map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "action": map[string]interface{}{
+ "type": "string",
+ "description": "操作类型: restart_deployment|scale_deployment|update_image|restart_statefulset|delete_pod|suspend_cronjob",
+ },
+ "namespace": map[string]interface{}{
+ "type": "string",
+ "description": "命名空间,默认会话绑定命名空间",
+ },
+ "name": map[string]interface{}{
+ "type": "string",
+ "description": "资源名称(Deployment/StatefulSet/CronJob/Pod)",
+ },
+ "replicas": map[string]interface{}{
+ "type": "integer",
+ "description": "scale_deployment 目标副本数",
+ },
+ "image": map[string]interface{}{
+ "type": "string",
+ "description": "update_image 新镜像地址",
+ },
+ "container": map[string]interface{}{
+ "type": "string",
+ "description": "update_image 目标容器名,可省略(默认第一个)",
+ },
+ "suspend": map[string]interface{}{
+ "type": "boolean",
+ "description": "suspend_cronjob: true=暂停, false=恢复",
+ },
+ },
+ "required": []string{"action", "name"},
+ }
+}
+
+// RiskLevel 静态高风险 // Logan.Li
+func (t *KubernetesActionTool) RiskLevel() RiskLevel { return RiskHigh }
+
+// Execute 执行变更 // Logan.Li
+func (t *KubernetesActionTool) Execute(ctx context.Context, args map[string]interface{}) (*Result, error) {
+ action := strings.ToLower(strings.TrimSpace(StringParam(args, "action")))
+ name := strings.TrimSpace(StringParam(args, "name"))
+ ns := strings.TrimSpace(StringParam(args, "namespace"))
+ if ns == "" {
+ ns = t.namespace
+ }
+ if action == "" || name == "" {
+ return &Result{Success: false, Error: "action 和 name 不能为空"}, nil
+ }
+
+ var err error
+ var msg string
+ switch action {
+ case "restart_deployment":
+ err = t.client.RestartDeployment(ns, name)
+ msg = fmt.Sprintf("已触发 Deployment %s/%s 滚动重启", ns, name)
+ case "scale_deployment":
+ replicas := IntParam(args, "replicas")
+ if replicas < 0 {
+ return &Result{Success: false, Error: "replicas 不能为负数"}, nil
+ }
+ err = t.client.ScaleDeployment(ns, name, replicas)
+ msg = fmt.Sprintf("已将 Deployment %s/%s 扩缩容至 %d 副本", ns, name, replicas)
+ case "update_image":
+ image := strings.TrimSpace(StringParam(args, "image"))
+ if image == "" {
+ return &Result{Success: false, Error: "update_image 需要 image 参数"}, nil
+ }
+ container := strings.TrimSpace(StringParam(args, "container"))
+ err = t.client.UpdateDeploymentImage(ns, name, container, image)
+ msg = fmt.Sprintf("已更新 Deployment %s/%s 镜像为 %s", ns, name, image)
+ case "restart_statefulset":
+ err = t.client.RestartStatefulSet(ns, name)
+ msg = fmt.Sprintf("已触发 StatefulSet %s/%s 滚动重启", ns, name)
+ case "delete_pod":
+ err = t.client.DeletePod(ns, name)
+ msg = fmt.Sprintf("已删除 Pod %s/%s(控制器将视情况重建)", ns, name)
+ case "suspend_cronjob":
+ suspend := true
+ if v, ok := args["suspend"].(bool); ok {
+ suspend = v
+ }
+ err = t.client.SuspendCronJob(ns, name, suspend)
+ if suspend {
+ msg = fmt.Sprintf("已暂停 CronJob %s/%s", ns, name)
+ } else {
+ msg = fmt.Sprintf("已恢复 CronJob %s/%s", ns, name)
+ }
+ default:
+ return &Result{Success: false, Error: fmt.Sprintf("不支持的 action: %s", action)}, nil
+ }
+ if err != nil {
+ return &Result{Success: false, Error: fmt.Sprintf("操作失败: %v", err)}, nil
+ }
+ return &Result{Success: true, Output: msg}, nil
+}
\ No newline at end of file
diff --git a/backend/pkg/agentcli/tools/kubernetes_get.go b/backend/pkg/agentcli/tools/kubernetes_get.go
new file mode 100644
index 0000000..af28f24
--- /dev/null
+++ b/backend/pkg/agentcli/tools/kubernetes_get.go
@@ -0,0 +1,526 @@
+// kubernetes_get.go - Kubernetes 集群/命名空间资源统一只读查询 // Logan.Li
+package tools
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+
+ k8sclient "ezops/pkg/kubernetesclient"
+)
+
+// KubernetesGetTool 统一只读查询工具 // Logan.Li
+type KubernetesGetTool struct {
+ client *k8sclient.Client
+ namespace string // 会话默认命名空间
+}
+
+// NewKubernetesGetTool 创建查询工具 // Logan.Li
+func NewKubernetesGetTool(client *k8sclient.Client, namespace string) *KubernetesGetTool {
+ return &KubernetesGetTool{client: client, namespace: namespace}
+}
+
+// Name 工具名 // Logan.Li
+func (t *KubernetesGetTool) Name() string { return "kubernetes_get" }
+
+// Description 给 LLM 的说明 // Logan.Li
+func (t *KubernetesGetTool) Description() string {
+ return "查询 Kubernetes 集群或命名空间资源状态(只读)。" +
+ "resource 支持: nodes, pods, deployments, statefulsets, daemonsets, services, ingresses, events, namespaces, hpa, cronjobs。" +
+ "用于盘点节点/Pod 健康、副本状态、事件告警、服务路由等。返回结构化摘要,非原始 JSON。"
+}
+
+// Parameters JSON Schema // Logan.Li
+func (t *KubernetesGetTool) Parameters() map[string]interface{} {
+ return map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "resource": map[string]interface{}{
+ "type": "string",
+ "description": "资源类型: nodes|pods|deployments|statefulsets|daemonsets|services|ingresses|events|namespaces|hpa|cronjobs",
+ },
+ "namespace": map[string]interface{}{
+ "type": "string",
+ "description": "命名空间;pods/deployments 等命名空间级资源需要;空则用会话绑定命名空间",
+ },
+ "name": map[string]interface{}{
+ "type": "string",
+ "description": "可选,查询单个资源名",
+ },
+ "label_selector": map[string]interface{}{
+ "type": "string",
+ "description": "可选,标签筛选,格式 app=nginx 或 app=nginx,env=prod",
+ },
+ "status_filter": map[string]interface{}{
+ "type": "string",
+ "description": "可选,Pod 状态筛选,如 Running、Pending、CrashLoopBackOff、NotRunning",
+ },
+ },
+ "required": []string{"resource"},
+ }
+}
+
+// RiskLevel 只读 // Logan.Li
+func (t *KubernetesGetTool) RiskLevel() RiskLevel { return RiskSafe }
+
+// Execute 执行查询 // Logan.Li
+func (t *KubernetesGetTool) Execute(ctx context.Context, args map[string]interface{}) (*Result, error) {
+ resource := strings.ToLower(strings.TrimSpace(StringParam(args, "resource")))
+ if resource == "" {
+ return &Result{Success: false, Error: "resource 不能为空"}, nil
+ }
+ ns := strings.TrimSpace(StringParam(args, "namespace"))
+ if ns == "" {
+ ns = t.namespace
+ }
+ name := strings.TrimSpace(StringParam(args, "name"))
+ labelSel := parseLabelSelector(StringParam(args, "label_selector"))
+ statusFilter := strings.TrimSpace(StringParam(args, "status_filter"))
+
+ var output string
+ var err error
+ switch resource {
+ case "nodes", "node":
+ output, err = t.formatNodes(name)
+ case "pods", "pod":
+ output, err = t.formatPods(ns, name, labelSel, statusFilter)
+ case "deployments", "deployment", "deploy":
+ output, err = t.formatDeployments(ns, name)
+ case "statefulsets", "statefulset", "sts":
+ output, err = t.formatStatefulSets(ns, name)
+ case "daemonsets", "daemonset", "ds":
+ output, err = t.formatDaemonSets(ns, name)
+ case "services", "service", "svc":
+ output, err = t.formatServices(ns, name)
+ case "ingresses", "ingress", "ing":
+ output, err = t.formatIngresses(ns, name)
+ case "events", "event":
+ output, err = t.formatEvents(ns, name)
+ case "namespaces", "namespace", "ns":
+ output, err = t.formatNamespaces(name)
+ case "hpa", "hpas":
+ output, err = t.formatHPAs(ns, name)
+ case "cronjobs", "cronjob":
+ output, err = t.formatCronJobs(ns, name)
+ default:
+ return &Result{Success: false, Error: fmt.Sprintf("不支持的 resource: %s", resource)}, nil
+ }
+ if err != nil {
+ return &Result{Success: false, Error: err.Error()}, nil
+ }
+ return &Result{Success: true, Output: output}, nil
+}
+
+func parseLabelSelector(raw string) map[string]string {
+ out := make(map[string]string)
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return out
+ }
+ for _, part := range strings.Split(raw, ",") {
+ part = strings.TrimSpace(part)
+ if kv := strings.SplitN(part, "=", 2); len(kv) == 2 {
+ out[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
+ }
+ }
+ return out
+}
+
+func labelsMatch(labels, selector map[string]string) bool {
+ if len(selector) == 0 {
+ return true
+ }
+ for k, v := range selector {
+ if labels[k] != v {
+ return false
+ }
+ }
+ return true
+}
+
+func podDisplayStatus(p k8sclient.PodItem) string {
+ phase := p.Status.Phase
+ for _, cs := range p.Status.ContainerStatuses {
+ if !cs.Ready && cs.RestartCount > 0 {
+ return "CrashLoopBackOff"
+ }
+ }
+ for _, c := range p.Status.Conditions {
+ if c.Type == "Ready" && c.Status != "True" && c.Reason != "" {
+ return c.Reason
+ }
+ }
+ return phase
+}
+
+func matchPodStatus(status, filter string) bool {
+ if filter == "" {
+ return true
+ }
+ f := strings.ToLower(filter)
+ s := strings.ToLower(status)
+ if f == "notrunning" {
+ return s != "running" && s != "succeeded" && s != "completed"
+ }
+ return strings.Contains(s, f)
+}
+
+func (t *KubernetesGetTool) formatNodes(name string) (string, error) {
+ list, err := t.client.ListNodes()
+ if err != nil {
+ return "", err
+ }
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("Nodes (%d):\n", len(list.Items)))
+ for _, n := range list.Items {
+ if name != "" && n.Metadata.Name != name {
+ continue
+ }
+ ready := "Unknown"
+ for _, c := range n.Status.Conditions {
+ if c.Type == "Ready" {
+ ready = c.Status
+ break
+ }
+ }
+ ip := ""
+ for _, a := range n.Status.Addresses {
+ if a.Type == "InternalIP" {
+ ip = a.Address
+ break
+ }
+ }
+ sb.WriteString(fmt.Sprintf("- %s Ready=%s IP=%s OS=%s kubelet=%s\n",
+ n.Metadata.Name, ready, ip, n.Status.NodeInfo.OSImage, n.Status.NodeInfo.KubeletVersion))
+ }
+ return sb.String(), nil
+}
+
+func (t *KubernetesGetTool) formatPods(ns, name string, labelSel map[string]string, statusFilter string) (string, error) {
+ list, err := t.client.ListPods()
+ if err != nil {
+ return "", err
+ }
+ type row struct{ line string }
+ var rows []row
+ for _, p := range list.Items {
+ if ns != "" && p.Metadata.Namespace != ns {
+ continue
+ }
+ if name != "" && p.Metadata.Name != name {
+ continue
+ }
+ if !labelsMatch(p.Metadata.Labels, labelSel) {
+ continue
+ }
+ st := podDisplayStatus(p)
+ if !matchPodStatus(st, statusFilter) {
+ continue
+ }
+ image := ""
+ if len(p.Spec.Containers) > 0 {
+ image = p.Spec.Containers[0].Image
+ }
+ restarts := 0
+ if len(p.Status.ContainerStatuses) > 0 {
+ restarts = p.Status.ContainerStatuses[0].RestartCount
+ }
+ rows = append(rows, row{line: fmt.Sprintf("- %s/%s %s restarts=%d node=%s ip=%s image=%s\n",
+ p.Metadata.Namespace, p.Metadata.Name, st, restarts, p.Spec.NodeName, p.Status.PodIP, image)})
+ }
+ sort.Slice(rows, func(i, j int) bool { return rows[i].line < rows[j].line })
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("Pods (%d):\n", len(rows)))
+ for _, r := range rows {
+ sb.WriteString(r.line)
+ }
+ if len(rows) == 0 {
+ sb.WriteString("(无匹配 Pod)\n")
+ }
+ return sb.String(), nil
+}
+
+func (t *KubernetesGetTool) formatDeployments(ns, name string) (string, error) {
+ list, err := t.client.ListDeployments()
+ if err != nil {
+ return "", err
+ }
+ var sb strings.Builder
+ count := 0
+ sb.WriteString("Deployments:\n")
+ for _, d := range list.Items {
+ if ns != "" && d.Metadata.Namespace != ns {
+ continue
+ }
+ if name != "" && d.Metadata.Name != name {
+ continue
+ }
+ count++
+ sb.WriteString(fmt.Sprintf("- %s/%s replicas=%d/%d ready=%d available=%d paused=%v\n",
+ d.Metadata.Namespace, d.Metadata.Name,
+ d.Status.ReadyReplicas, d.Spec.Replicas,
+ d.Status.ReadyReplicas, d.Status.AvailableReplicas, d.Spec.Paused))
+ }
+ if count == 0 {
+ sb.WriteString("(无匹配 Deployment)\n")
+ } else {
+ sb.Reset()
+ sb.WriteString(fmt.Sprintf("Deployments (%d):\n", count))
+ for _, d := range list.Items {
+ if ns != "" && d.Metadata.Namespace != ns {
+ continue
+ }
+ if name != "" && d.Metadata.Name != name {
+ continue
+ }
+ sb.WriteString(fmt.Sprintf("- %s/%s replicas=%d/%d ready=%d available=%d paused=%v\n",
+ d.Metadata.Namespace, d.Metadata.Name,
+ d.Status.ReadyReplicas, d.Spec.Replicas,
+ d.Status.ReadyReplicas, d.Status.AvailableReplicas, d.Spec.Paused))
+ }
+ }
+ return sb.String(), nil
+}
+
+func (t *KubernetesGetTool) formatStatefulSets(ns, name string) (string, error) {
+ list, err := t.client.ListStatefulSets()
+ if err != nil {
+ return "", err
+ }
+ var lines []string
+ for _, s := range list.Items {
+ if ns != "" && s.Metadata.Namespace != ns {
+ continue
+ }
+ if name != "" && s.Metadata.Name != name {
+ continue
+ }
+ lines = append(lines, fmt.Sprintf("- %s/%s replicas=%d/%d ready=%d\n",
+ s.Metadata.Namespace, s.Metadata.Name, s.Status.ReadyReplicas, s.Spec.Replicas, s.Status.ReadyReplicas))
+ }
+ sort.Strings(lines)
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("StatefulSets (%d):\n", len(lines)))
+ if len(lines) == 0 {
+ sb.WriteString("(无匹配 StatefulSet)\n")
+ } else {
+ for _, l := range lines {
+ sb.WriteString(l + "\n")
+ }
+ }
+ return sb.String(), nil
+}
+
+func (t *KubernetesGetTool) formatDaemonSets(ns, name string) (string, error) {
+ list, err := t.client.ListDaemonSets()
+ if err != nil {
+ return "", err
+ }
+ var lines []string
+ for _, d := range list.Items {
+ if ns != "" && d.Metadata.Namespace != ns {
+ continue
+ }
+ if name != "" && d.Metadata.Name != name {
+ continue
+ }
+ lines = append(lines, fmt.Sprintf("- %s/%s desired=%d ready=%d\n",
+ d.Metadata.Namespace, d.Metadata.Name, d.Status.DesiredNumberScheduled,
+ d.Status.NumberReady))
+ }
+ sort.Strings(lines)
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("DaemonSets (%d):\n", len(lines)))
+ for _, l := range lines {
+ sb.WriteString(l + "\n")
+ }
+ return sb.String(), nil
+}
+
+func (t *KubernetesGetTool) formatServices(ns, name string) (string, error) {
+ list, err := t.client.ListServices()
+ if err != nil {
+ return "", err
+ }
+ var lines []string
+ for _, s := range list.Items {
+ if ns != "" && s.Metadata.Namespace != ns {
+ continue
+ }
+ if name != "" && s.Metadata.Name != name {
+ continue
+ }
+ ports := ""
+ for i, p := range s.Spec.Ports {
+ if i > 0 {
+ ports += ","
+ }
+ ports += fmt.Sprintf("%d", p.Port)
+ }
+ lines = append(lines, fmt.Sprintf("- %s/%s type=%s clusterIP=%s ports=[%s] selector=%v\n",
+ s.Metadata.Namespace, s.Metadata.Name, s.Spec.Type, s.Spec.ClusterIP, ports, s.Spec.Selector))
+ }
+ sort.Strings(lines)
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("Services (%d):\n", len(lines)))
+ for _, l := range lines {
+ sb.WriteString(l)
+ }
+ return sb.String(), nil
+}
+
+func (t *KubernetesGetTool) formatIngresses(ns, name string) (string, error) {
+ list, err := t.client.ListIngresses()
+ if err != nil {
+ return "", err
+ }
+ var lines []string
+ for _, ing := range list.Items {
+ if ns != "" && ing.Metadata.Namespace != ns {
+ continue
+ }
+ if name != "" && ing.Metadata.Name != name {
+ continue
+ }
+ hosts := []string{}
+ for _, r := range ing.Spec.Rules {
+ if r.Host != "" {
+ hosts = append(hosts, r.Host)
+ }
+ }
+ lines = append(lines, fmt.Sprintf("- %s/%s hosts=%v\n", ing.Metadata.Namespace, ing.Metadata.Name, hosts))
+ }
+ sort.Strings(lines)
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("Ingresses (%d):\n", len(lines)))
+ for _, l := range lines {
+ sb.WriteString(l + "\n")
+ }
+ return sb.String(), nil
+}
+
+func (t *KubernetesGetTool) formatEvents(ns, involvedName string) (string, error) {
+ list, err := t.client.ListEvents(ns)
+ if err != nil {
+ return "", err
+ }
+ type evRow struct {
+ ts time.Time
+ line string
+ }
+ var rows []evRow
+ for _, e := range list.Items {
+ if involvedName != "" && e.InvolvedObject.Name != involvedName {
+ continue
+ }
+ ts := e.LastTimestamp
+ if ts.IsZero() {
+ ts = e.EventTime
+ }
+ rows = append(rows, evRow{
+ ts: ts,
+ line: fmt.Sprintf("[%s] %s %s/%s: %s — %s (x%d)\n",
+ e.Type, e.Reason, e.InvolvedObject.Kind, e.InvolvedObject.Name, e.Message,
+ e.InvolvedObject.Namespace, e.Count),
+ })
+ }
+ sort.Slice(rows, func(i, j int) bool { return rows[i].ts.After(rows[j].ts) })
+ if len(rows) > 30 {
+ rows = rows[:30]
+ }
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("Events (recent %d):\n", len(rows)))
+ for _, r := range rows {
+ sb.WriteString(r.line)
+ }
+ return sb.String(), nil
+}
+
+func (t *KubernetesGetTool) formatNamespaces(name string) (string, error) {
+ names, err := t.client.ListNamespaces()
+ if err != nil {
+ return "", err
+ }
+ sort.Strings(names)
+ var sb strings.Builder
+ count := 0
+ sb.WriteString("Namespaces:\n")
+ for _, n := range names {
+ if name != "" && n != name {
+ continue
+ }
+ count++
+ sb.WriteString(fmt.Sprintf("- %s\n", n))
+ }
+ if count == 0 {
+ sb.WriteString("(无匹配命名空间)\n")
+ } else {
+ sb.Reset()
+ sb.WriteString(fmt.Sprintf("Namespaces (%d):\n", count))
+ for _, n := range names {
+ if name != "" && n != name {
+ continue
+ }
+ sb.WriteString(fmt.Sprintf("- %s\n", n))
+ }
+ }
+ return sb.String(), nil
+}
+
+func (t *KubernetesGetTool) formatHPAs(ns, name string) (string, error) {
+ list, err := t.client.ListHPAs()
+ if err != nil {
+ return "", err
+ }
+ var lines []string
+ for _, h := range list.Items {
+ if ns != "" && h.Metadata.Namespace != ns {
+ continue
+ }
+ if name != "" && h.Metadata.Name != name {
+ continue
+ }
+ lines = append(lines, fmt.Sprintf("- %s/%s target=%s/%s min=%d max=%d current=%d\n",
+ h.Metadata.Namespace, h.Metadata.Name,
+ h.Spec.ScaleTargetRef.Kind, h.Spec.ScaleTargetRef.Name,
+ h.Spec.MinReplicas, h.Spec.MaxReplicas, h.Status.CurrentReplicas))
+ }
+ sort.Strings(lines)
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("HPAs (%d):\n", len(lines)))
+ for _, l := range lines {
+ sb.WriteString(l)
+ }
+ return sb.String(), nil
+}
+
+func (t *KubernetesGetTool) formatCronJobs(ns, name string) (string, error) {
+ list, err := t.client.ListCronJobs()
+ if err != nil {
+ return "", err
+ }
+ var lines []string
+ for _, cj := range list.Items {
+ if ns != "" && cj.Metadata.Namespace != ns {
+ continue
+ }
+ if name != "" && cj.Metadata.Name != name {
+ continue
+ }
+ suspended := false
+ if cj.Spec.Suspend != nil {
+ suspended = *cj.Spec.Suspend
+ }
+ lines = append(lines, fmt.Sprintf("- %s/%s schedule=%s suspend=%v active=%d\n",
+ cj.Metadata.Namespace, cj.Metadata.Name, cj.Spec.Schedule, suspended, len(cj.Status.Active)))
+ }
+ sort.Strings(lines)
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("CronJobs (%d):\n", len(lines)))
+ for _, l := range lines {
+ sb.WriteString(l)
+ }
+ return sb.String(), nil
+}
\ No newline at end of file
diff --git a/backend/pkg/agentcli/tools/pod_execute.go b/backend/pkg/agentcli/tools/pod_execute.go
new file mode 100644
index 0000000..5977d7d
--- /dev/null
+++ b/backend/pkg/agentcli/tools/pod_execute.go
@@ -0,0 +1,137 @@
+// pod_execute.go - 在目标 Pod 容器内执行 shell 命令 // Logan.Li
+package tools
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ k8sclient "ezops/pkg/kubernetesclient"
+)
+
+// PodExecuteTool 在 Pod 容器内执行命令(K8s exec WebSocket 协议) // Logan.Li
+type PodExecuteTool struct {
+ client *k8sclient.Client
+ namespace string
+ podName string
+ container string
+ timeout time.Duration
+}
+
+// NewPodExecuteTool 创建 Pod 执行工具 // Logan.Li
+func NewPodExecuteTool(client *k8sclient.Client, namespace, podName, container string, timeout time.Duration) *PodExecuteTool {
+ if timeout <= 0 {
+ timeout = 60 * time.Second
+ }
+ return &PodExecuteTool{
+ client: client, namespace: namespace, podName: podName,
+ container: container, timeout: timeout,
+ }
+}
+
+// Name 工具名 // Logan.Li
+func (t *PodExecuteTool) Name() string { return "pod_execute" }
+
+// Description 给 LLM 的工具说明 // Logan.Li
+func (t *PodExecuteTool) Description() string {
+ return "在已选定的 Kubernetes Pod 容器内执行 shell 命令并返回 stdout+stderr。" +
+ "用于容器内排障(查看进程/配置/连通性)、应用调试。" +
+ "注意:容器内可能没有 systemctl 或宿主机路径;多容器 Pod 可通过 container 参数指定。" +
+ "高危命令需用户确认。"
+}
+
+// Parameters JSON Schema // Logan.Li
+func (t *PodExecuteTool) Parameters() map[string]interface{} {
+ return map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "command": map[string]interface{}{
+ "type": "string",
+ "description": "要执行的 shell 命令,例如 ps aux、cat /etc/nginx/nginx.conf、curl -s localhost:8080/health",
+ },
+ "container": map[string]interface{}{
+ "type": "string",
+ "description": "可选,多容器 Pod 时指定容器名;默认使用会话绑定的容器",
+ },
+ "timeout": map[string]interface{}{
+ "type": "integer",
+ "description": "命令超时秒数(默认 60)",
+ },
+ },
+ "required": []string{"command"},
+ }
+}
+
+// RiskLevel 静态风险 // Logan.Li
+func (t *PodExecuteTool) RiskLevel() RiskLevel { return RiskHigh }
+
+// RiskLevelForArgs 依据命令动态评估风险 // Logan.Li
+func (t *PodExecuteTool) RiskLevelForArgs(args map[string]interface{}) RiskLevel {
+ return AssessBashRisk(StringParam(args, "command"))
+}
+
+// Execute 在 Pod 内执行命令 // Logan.Li
+func (t *PodExecuteTool) Execute(ctx context.Context, args map[string]interface{}) (*Result, error) {
+ command := strings.TrimSpace(StringParam(args, "command"))
+ if command == "" {
+ return &Result{Success: false, Error: "参数 command 不能为空"}, nil
+ }
+ if t.podName == "" {
+ return &Result{Success: false, Error: "未绑定目标 Pod,请在前端选择 Pod 或在命令前先 kubernetes_get 确认"}, nil
+ }
+
+ container := strings.TrimSpace(StringParam(args, "container"))
+ if container == "" {
+ container = t.container
+ }
+
+ timeout := t.timeout
+ if sec := IntParam(args, "timeout"); sec > 0 {
+ timeout = time.Duration(sec) * time.Second
+ }
+
+ execCtx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+
+ // 用 sh -c 保持 shell 语义,并嵌入 exit_code 与 ssh_execute 对齐 // Logan.Li
+ wrapped := command + "; echo [exit_code=$?]"
+ done := make(chan struct {
+ res *k8sclient.ExecResult
+ err error
+ }, 1)
+ go func() {
+ r, err := t.client.ExecInPod(t.namespace, t.podName, container, []string{"sh", "-c", wrapped})
+ done <- struct {
+ res *k8sclient.ExecResult
+ err error
+ }{r, err}
+ }()
+
+ var result *k8sclient.ExecResult
+ var err error
+ select {
+ case <-execCtx.Done():
+ return &Result{Success: false, Error: "命令执行超时"}, nil
+ case out := <-done:
+ result, err = out.res, out.err
+ }
+ if err != nil {
+ return &Result{Success: false, Error: fmt.Sprintf("Pod exec 失败: %v", err)}, nil
+ }
+
+ output := strings.TrimSpace(result.Stdout)
+ if result.Stderr != "" {
+ if output != "" {
+ output += "\n"
+ }
+ output += strings.TrimSpace(result.Stderr)
+ }
+ success := strings.Contains(output, "[exit_code=0]")
+ if !success && !strings.Contains(output, "[exit_code=") {
+ // 无 exit_code 标记时,无 stderr 视为成功 // Logan.Li
+ success = strings.TrimSpace(result.Stderr) == ""
+ }
+
+ return &Result{Success: success, Output: output}, nil
+}
\ No newline at end of file
diff --git a/backend/pkg/agentcli/tools/pod_logs.go b/backend/pkg/agentcli/tools/pod_logs.go
new file mode 100644
index 0000000..4128011
--- /dev/null
+++ b/backend/pkg/agentcli/tools/pod_logs.go
@@ -0,0 +1,89 @@
+// pod_logs.go - 获取 Pod 容器日志(只读) // Logan.Li
+package tools
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ k8sclient "ezops/pkg/kubernetesclient"
+)
+
+// PodLogsTool 拉取 Pod 日志 // Logan.Li
+type PodLogsTool struct {
+ client *k8sclient.Client
+ namespace string
+ podName string
+ container string
+}
+
+// NewPodLogsTool 创建日志工具 // Logan.Li
+func NewPodLogsTool(client *k8sclient.Client, namespace, podName, container string) *PodLogsTool {
+ return &PodLogsTool{client: client, namespace: namespace, podName: podName, container: container}
+}
+
+// Name 工具名 // Logan.Li
+func (t *PodLogsTool) Name() string { return "pod_logs" }
+
+// Description 给 LLM 的说明 // Logan.Li
+func (t *PodLogsTool) Description() string {
+ return "获取目标 Pod 容器的最近日志(只读)。排障时优先使用此工具,比 pod_execute tail 更高效。" +
+ "未选 Pod 时需通过 name 参数指定 Pod 名。"
+}
+
+// Parameters JSON Schema // Logan.Li
+func (t *PodLogsTool) Parameters() map[string]interface{} {
+ return map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "pod": map[string]interface{}{
+ "type": "string",
+ "description": "Pod 名称;会话已绑定 Pod 时可省略",
+ },
+ "container": map[string]interface{}{
+ "type": "string",
+ "description": "容器名;多容器时指定,默认会话绑定容器",
+ },
+ "tail_lines": map[string]interface{}{
+ "type": "integer",
+ "description": "返回最近行数,默认 200,最大 2000",
+ },
+ },
+ }
+}
+
+// RiskLevel 只读安全 // Logan.Li
+func (t *PodLogsTool) RiskLevel() RiskLevel { return RiskSafe }
+
+// Execute 拉取日志 // Logan.Li
+func (t *PodLogsTool) Execute(ctx context.Context, args map[string]interface{}) (*Result, error) {
+ pod := strings.TrimSpace(StringParam(args, "pod"))
+ if pod == "" {
+ pod = t.podName
+ }
+ if pod == "" {
+ return &Result{Success: false, Error: "请指定 pod 参数或在前端选择目标 Pod"}, nil
+ }
+
+ container := strings.TrimSpace(StringParam(args, "container"))
+ if container == "" {
+ container = t.container
+ }
+
+ tail := IntParam(args, "tail_lines")
+ if tail <= 0 {
+ tail = 200
+ }
+ if tail > 2000 {
+ tail = 2000
+ }
+
+ logs, err := t.client.GetPodLogs(t.namespace, pod, container, tail)
+ if err != nil {
+ return &Result{Success: false, Error: fmt.Sprintf("获取日志失败: %v", err)}, nil
+ }
+ if strings.TrimSpace(logs) == "" {
+ return &Result{Success: true, Output: "(日志为空)"}, nil
+ }
+ return &Result{Success: true, Output: logs}, nil
+}
\ No newline at end of file
diff --git a/backend/pkg/billingsync/sync.go b/backend/pkg/billingsync/sync.go
new file mode 100644
index 0000000..3c40cb2
--- /dev/null
+++ b/backend/pkg/billingsync/sync.go
@@ -0,0 +1,141 @@
+// 云账单同步引擎 — 从云账号 AK 拉取日账单写入本地 // Logan.Li
+package billingsync
+
+import (
+ "fmt"
+ "time"
+
+ "ezops/cloud"
+ "ezops/model"
+
+ "gorm.io/gorm"
+)
+
+// Result 同步结果
+type Result struct {
+ BillingDate string `json:"billing_date"`
+ Accounts int `json:"accounts"`
+ Created int `json:"created"`
+ Updated int `json:"updated"`
+ Skipped int `json:"skipped"`
+ Errors []string `json:"errors"`
+}
+
+// Yesterday 昨日日期 YYYY-MM-DD
+func Yesterday() string {
+ return time.Now().AddDate(0, 0, -1).Format("2006-01-02")
+}
+
+// SyncDate 同步指定日期账单(全部或单个活跃云账号)
+func SyncDate(db *gorm.DB, billingDate string, accountID *uint) Result {
+ result := Result{
+ BillingDate: billingDate,
+ Errors: []string{},
+ }
+
+ var accounts []model.CloudAccount
+ q := db.Where("status = ?", "active")
+ if accountID != nil {
+ q = q.Where("id = ?", *accountID)
+ }
+ q.Find(&accounts)
+ result.Accounts = len(accounts)
+
+ if len(accounts) == 0 {
+ result.Errors = append(result.Errors, "没有可同步的活跃云账号")
+ return result
+ }
+
+ for _, account := range accounts {
+ created, updated, skipped, err := syncAccount(db, account, billingDate)
+ result.Created += created
+ result.Updated += updated
+ result.Skipped += skipped
+ if err != nil {
+ result.Errors = append(result.Errors, fmt.Sprintf("%s(%s): %s", account.Name, account.Provider, err.Error()))
+ }
+ }
+ return result
+}
+
+// SummaryMessage 生成执行摘要文案
+func SummaryMessage(r Result) string {
+ msg := fmt.Sprintf("%s 新增 %d 条,更新 %d 条", r.BillingDate, r.Created, r.Updated)
+ if len(r.Errors) > 0 {
+ msg += fmt.Sprintf(";%d 个账号异常", len(r.Errors))
+ }
+ return msg
+}
+
+func syncAccount(db *gorm.DB, account model.CloudAccount, billingDate string) (created, updated, skipped int, err error) {
+ fetcher := cloud.NewBillingFetcher(account.Provider, account.AccessKey, account.SecretKey)
+ if fetcher == nil {
+ return 0, 0, 0, fmt.Errorf("暂不支持该云厂商账单同步")
+ }
+
+ records, err := fetcher.FetchDailyBill(billingDate)
+ if err != nil {
+ return 0, 0, 0, err
+ }
+ if len(records) == 0 {
+ return 0, 0, 1, fmt.Errorf("该日期暂无账单数据(可能尚未出账)")
+ }
+
+ accountID := account.ID
+ accountName := account.Name
+
+ for _, rec := range records {
+ var existing model.CloudBilling
+ findErr := db.Where(
+ "cloud_account_id = ? AND billing_date = ? AND provider = ? AND service = ? AND region = ?",
+ accountID, billingDate, rec.Provider, rec.Service, rec.Region,
+ ).Take(&existing).Error
+
+ if findErr == gorm.ErrRecordNotFound {
+ row := model.CloudBilling{
+ CloudAccountID: &accountID,
+ AccountName: accountName,
+ Provider: rec.Provider,
+ BillingDate: billingDate,
+ Cost: rec.Cost,
+ Currency: rec.Currency,
+ Service: rec.Service,
+ Region: rec.Region,
+ Description: rec.Description,
+ }
+ if row.Currency == "" {
+ row.Currency = "CNY"
+ }
+ if db.Create(&row).Error == nil {
+ created++
+ } else {
+ skipped++
+ }
+ continue
+ }
+ if findErr != nil {
+ skipped++
+ continue
+ }
+
+ if db.Model(&existing).Updates(map[string]interface{}{
+ "cost": rec.Cost,
+ "currency": rec.Currency,
+ "description": rec.Description,
+ "account_name": accountName,
+ }).Error == nil {
+ updated++
+ } else {
+ skipped++
+ }
+ }
+
+ monthStart := billingDate[:7] + "-01"
+ var monthCost float64
+ db.Model(&model.CloudBilling{}).
+ Where("cloud_account_id = ? AND billing_date >= ?", accountID, monthStart).
+ Select("COALESCE(SUM(cost), 0)").Scan(&monthCost)
+ db.Model(&account).Update("monthly_cost", monthCost)
+
+ return created, updated, skipped, nil
+}
\ No newline at end of file
diff --git a/backend/pkg/database/database.go b/backend/pkg/database/database.go
index 22421c7..8aed328 100644
--- a/backend/pkg/database/database.go
+++ b/backend/pkg/database/database.go
@@ -10,9 +10,9 @@ import (
"ezops/model"
"ezops/pkg/appdir"
+ "github.com/glebarez/sqlite"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
- "github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
@@ -91,8 +91,8 @@ func AutoMigrate(db *gorm.DB) error {
&model.KubernetesIngress{},
&model.AlertRule{},
&model.AlertEvent{},
- &model.AlertContact{}, // 报警联系人 // Logan.Li
- &model.AlertContactGroup{}, // 报警联系组 // Logan.Li
+ &model.AlertContact{}, // 报警联系人 // Logan.Li
+ &model.AlertContactGroup{}, // 报警联系组 // Logan.Li
&model.AlertNotifyPolicy{}, // 告警集合通知策略 // Logan.Li
&model.AlertNotifySettings{}, // 告警消息模版 // Logan.Li
&model.NotifyChannel{},
@@ -110,9 +110,9 @@ func AutoMigrate(db *gorm.DB) error {
&model.NavigationGroup{},
&model.NavigationLink{},
&model.AuditLog{},
- &model.AuditSettings{}, // 审计保留策略 // Logan.Li
- &model.LoginLog{}, // 登录日志 // Logan.Li
- &model.SystemTaskStatus{}, // 内置系统任务状态 // Logan.Li
+ &model.AuditSettings{}, // 审计保留策略 // Logan.Li
+ &model.LoginLog{}, // 登录日志 // Logan.Li
+ &model.SystemTaskStatus{}, // 内置系统任务状态 // Logan.Li
&model.LDAPConfig{},
&model.ResourceModel{},
&model.CloudBilling{},
@@ -126,54 +126,55 @@ func AutoMigrate(db *gorm.DB) error {
&model.AnsibleInventory{},
&model.LogSource{},
&model.LogEntry{},
- &model.TopologyLayout{}, // 拓扑布局保存 // Logan.Li
- &model.TerminalSession{}, // 终端会话录制(终端审计) // Logan.Li
- &model.SessionCommand{}, // 终端会话命令审计 // Logan.Li
- &model.LLMConfig{}, // 大语言模型配置(AI 智能助手) // Logan.Li
- &model.ChatHistory{}, // 对话历史记录(按用户隔离) // Logan.Li
- &model.MetricsAggregate{}, // 指标时序聚合(Redis → MySQL)// Logan.Li
- &model.MonitorTarget{}, // 监控目标(主机/中间件/设备/站点) // Logan.Li
- &model.MonitorMetric{}, // 监控指标定义 // Logan.Li
- &model.MetricValue{}, // 指标当前值 // Logan.Li
- &model.MetricHistory{}, // 指标历史时序 // Logan.Li
- &model.HostGroup{}, // 主机组 // Logan.Li
- &model.SNMPDevice{}, // SNMP设备 // Logan.Li
- &model.SNMPTemplate{}, // SNMP模板 // Logan.Li
- &model.SNMPTemplateOID{}, // SNMP模板OID // Logan.Li
- &model.SNMPMetric{}, // SNMP采集指标 // Logan.Li
- &model.SNMPInterface{}, // SNMP接口信息 // Logan.Li
- &model.SiteTask{}, // 站点拨测任务 // Logan.Li
- &model.SiteResult{}, // 拨测结果 // Logan.Li
- &model.SiteAlert{}, // 拨测告警 // Logan.Li
- &model.MonitorAlertRule{}, // 中间件/站点拨测告警规则 // Logan.Li
+ &model.TopologyLayout{}, // 拓扑布局保存 // Logan.Li
+ &model.TerminalSession{}, // 终端会话录制(终端审计) // Logan.Li
+ &model.RDPSession{}, // RDP 图形会话审计 // Logan.Li
+ &model.SessionCommand{}, // 终端会话命令审计 // Logan.Li
+ &model.LLMConfig{}, // 大语言模型配置(AI 智能助手) // Logan.Li
+ &model.ChatHistory{}, // 对话历史记录(按用户隔离) // Logan.Li
+ &model.MetricsAggregate{}, // 指标时序聚合(Redis → MySQL)// Logan.Li
+ &model.MonitorTarget{}, // 监控目标(主机/中间件/设备/站点) // Logan.Li
+ &model.MonitorMetric{}, // 监控指标定义 // Logan.Li
+ &model.MetricValue{}, // 指标当前值 // Logan.Li
+ &model.MetricHistory{}, // 指标历史时序 // Logan.Li
+ &model.HostGroup{}, // 主机组 // Logan.Li
+ &model.SNMPDevice{}, // SNMP设备 // Logan.Li
+ &model.SNMPTemplate{}, // SNMP模板 // Logan.Li
+ &model.SNMPTemplateOID{}, // SNMP模板OID // Logan.Li
+ &model.SNMPMetric{}, // SNMP采集指标 // Logan.Li
+ &model.SNMPInterface{}, // SNMP接口信息 // Logan.Li
+ &model.SiteTask{}, // 站点拨测任务 // Logan.Li
+ &model.SiteResult{}, // 拨测结果 // Logan.Li
+ &model.SiteAlert{}, // 拨测告警 // Logan.Li
+ &model.MonitorAlertRule{}, // 中间件/站点拨测告警规则 // Logan.Li
&model.PrometheusDataSource{}, // Prometheus 数据源配置 // Logan.Li
&model.ZabbixDataSource{}, // Zabbix 数据源配置 // Logan.Li
&model.LogDataSource{}, // Loki / ES 日志数据源 // Logan.Li
- &model.Credential{}, // 凭据管理(SSH密钥/API Token) // Logan.Li
- &model.PipelineRepo{}, // Git 仓库认证 // Logan.Li
- &model.PipelineRegistry{}, // 镜像仓库认证 // Logan.Li
- &model.DeployEnvironment{}, // 发布环境分组 // Logan.Li
- &model.DeployPipelineMember{}, // 流水线成员 // Logan.Li
- &model.DeployUserGroup{}, // 用户组 // Logan.Li
+ &model.Credential{}, // 凭据管理(SSH密钥/API Token) // Logan.Li
+ &model.PipelineRepo{}, // Git 仓库认证 // Logan.Li
+ &model.PipelineRegistry{}, // 镜像仓库认证 // Logan.Li
+ &model.DeployEnvironment{}, // 发布环境分组 // Logan.Li
+ &model.DeployPipelineMember{}, // 流水线成员 // Logan.Li
+ &model.DeployUserGroup{}, // 用户组 // Logan.Li
&model.DeployUserGroupMember{}, // 用户组成员 // Logan.Li
- &model.ItemRole{}, // Jenkins-style Item Role (regex pipeline matching) // Logan.Li
- &model.UserItemRole{}, // User ↔ ItemRole association // Logan.Li
- &model.LogScopeRole{}, // 日志查询范围权限(正则匹配索引/Label)// Logan.Li
- &model.UserLogScopeRole{}, // 用户 ↔ 日志范围权限 // Logan.Li
- &model.BuildMasterConfig{}, // Master 构建节点并发配置 // Logan.Li
- &model.DeployTool{}, // 工具配置(懒加载/预装/禁用) // Logan.Li
+ &model.ItemRole{}, // Jenkins-style Item Role (regex pipeline matching) // Logan.Li
+ &model.UserItemRole{}, // User ↔ ItemRole association // Logan.Li
+ &model.LogScopeRole{}, // 日志查询范围权限(正则匹配索引/Label)// Logan.Li
+ &model.UserLogScopeRole{}, // 用户 ↔ 日志范围权限 // Logan.Li
+ &model.BuildMasterConfig{}, // Master 构建节点并发配置 // Logan.Li
+ &model.DeployTool{}, // 工具配置(懒加载/预装/禁用) // Logan.Li
&model.AlertCondition{}, // 告警条件 // Logan.Li
&model.AlertNotifyTemplate{}, // 告警通知模板 // Logan.Li
&model.AlertUpgrade{}, // 告警升级策略 // Logan.Li
&model.AlertUpgradeRecord{}, // 告警升级记录 // Logan.Li
&model.AlertSilence{}, // 告警静默 // Logan.Li
- &model.HealthProbe{}, // 健康探针配置 // Logan.Li
+ &model.HealthProbe{}, // 健康探针配置 // Logan.Li
&model.ApprovalWorkflow{}, // 审批流程定义 // Logan.Li
- &model.ApprovalWorkflowStep{}, // 审批流程步骤 // Logan.Li
- &model.AgentCLISession{}, // Agent CLI 任务会话 // Logan.Li
- &model.AgentCLIMessage{}, // Agent CLI 推理消息 // Logan.Li
- &model.CustomDashboard{}, // 自定义监控仪表盘 // Logan.Li
- &model.TraceDataSource{}, // 链路追踪数据源 Jaeger/Tempo // Logan.Li
+ &model.ApprovalWorkflowStep{}, // 审批流程步骤 // Logan.Li
+ &model.AgentCLISession{}, // Agent CLI 任务会话 // Logan.Li
+ &model.AgentCLIMessage{}, // Agent CLI 推理消息 // Logan.Li
+ &model.CustomDashboard{}, // 自定义监控仪表盘 // Logan.Li
+ &model.TraceDataSource{}, // 链路追踪数据源 Jaeger/Tempo // Logan.Li
}
var lastErr error
@@ -238,4 +239,26 @@ func applySchemaPatches(db *gorm.DB) {
log.Printf("[DB] 补列 iam_ldap_configs.server_type 失败: %v", err)
}
}
+
+ // 服务器 RDP/协议字段(旧库 AutoMigrate 可能未加列)// Logan.Li
+ patchServerColumn := func(field string, col string) {
+ if !m.HasColumn(&model.Server{}, col) {
+ if err := m.AddColumn(&model.Server{}, field); err != nil {
+ log.Printf("[DB] 补列 asset_servers.%s 失败: %v", col, err)
+ }
+ }
+ }
+ patchServerColumn("OSType", "os_type")
+ patchServerColumn("Protocol", "protocol")
+ patchServerColumn("RDPPort", "rdp_port")
+
+ // 将历史仅填 os=windows 的记录补齐协议字段 // Logan.Li
+ if m.HasColumn(&model.Server{}, "os_type") && m.HasColumn(&model.Server{}, "protocol") {
+ _ = db.Exec(`
+ UPDATE asset_servers
+ SET os_type = 'windows', protocol = 'rdp', rdp_port = IF(rdp_port IS NULL OR rdp_port = 0, 3389, rdp_port)
+ WHERE (os_type IS NULL OR os_type = '' OR protocol IS NULL OR protocol = '')
+ AND LOWER(os) LIKE '%windows%'
+ `).Error
+ }
}
diff --git a/backend/pkg/rdp/README.md b/backend/pkg/rdp/README.md
new file mode 100644
index 0000000..fe515d4
--- /dev/null
+++ b/backend/pkg/rdp/README.md
@@ -0,0 +1,37 @@
+# RDP 协议栈分层
+
+`backend/pkg/rdp` 是 AIOps 资产远程桌面的协议层。当前固定使用自研 RDP 引擎,第三方 `grdp` 兜底和 `backend/third_party` 已移除。根包只保留兼容入口、引擎创建与临时会话编排,协议细节逐步迁入域目录。// Logan.Li
+
+## 引擎选择
+
+| 环境变量 | 行为 |
+|----------|------|
+| `RDP_ENGINE` | 保留兼容,不再参与引擎选择 |
+| `RDP_DEBUG=1` | 打开协议级 trace 日志,统一输出 `[RDP:TRACE]` |
+
+## 目录职责
+
+| 目录 | 职责 |
+|------|------|
+| `domain/` | 对外契约:连接配置、浏览器输入事件、服务端帧、引擎接口、诊断结果、录屏摘要 |
+| `transport/` | TCP 上的 TPKT、X.224、TLS 升级与安全协议协商 |
+| `credssp/` | NLA/CredSSP/NTLMv2 认证,自研 ASN.1 与 NTLM 消息处理 |
+| `rle/` | RDP 位图 RLE 解压算法 |
+| `trace/` | 协议级调试日志开关,避免高频 bitmap 默认刷屏 |
+| 根包 | 引擎选择、兼容入口、临时会话编排、MCS/GCC、Fast-Path 收发、键鼠输入、位图解码、探针入口 |
+
+## 依赖方向
+
+控制器只依赖根包 `ezops/pkg/rdp` 的 `Engine`、`Config`、`ClientEvent`、`ServerFrame`。根包向内组合 `domain`、`transport`、`credssp`、`rle`。底层域目录不能反向依赖控制器、数据库、WebSocket 或审计模型。
+
+## 后续拆分目标
+
+1. 协议基础帧、TLS、X.224 只放 `transport/`。
+2. 登录认证只放 `credssp/`,不得调用 Gin/GORM。
+3. 浏览器与控制器共享的 DTO 先放 `domain/`,根包用类型别名兼容旧调用。
+4. `pdu/` 承接 DemandActive、ConfirmActive、Synchronize、Control、FontList、BitmapUpdate 等状态机。
+5. `graphics/` 承接 bitmap/RLE/PNG 或后续 raw frame 编码,输入只消费标准化矩形。
+6. `input/` 承接鼠标、键盘、Unicode、组合键与自动执行。
+7. `channels/` 承接 cliprdr、drdynvc、rdpgfx,避免虚拟通道逻辑污染会话主流程。
+8. `recorder/` 承接 RDP 事件录制、回放和截图探针。
+9. 新增测试优先覆盖底层域目录,再用根包探针做资产联调。
diff --git a/backend/pkg/rdp/bitmap_decode.go b/backend/pkg/rdp/bitmap_decode.go
new file mode 100644
index 0000000..4f99ebf
--- /dev/null
+++ b/backend/pkg/rdp/bitmap_decode.go
@@ -0,0 +1,297 @@
+package rdp
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/binary"
+ "image"
+ "image/png"
+ "io"
+
+ "ezops/pkg/rdp/rle"
+ "ezops/pkg/rdp/trace"
+)
+
+const (
+ bitmapCompression = 0x0001
+ noBitmapCompressionHdr = 0x0400
+ bitmapNoProcessing = 0x8000
+ fastPathUpdateBitmap = 0x1
+ fastPathUpdateSurfaceCmds = 0x4
+ pduType2Update = 0x02
+)
+
+// BitmapRect 表示一块待绘制到 Canvas 的位图区域 // Logan.Li
+type BitmapRect struct {
+ DestLeft int
+ DestTop int
+ Width int
+ Height int
+ BitsPerPixel int
+ Data []byte
+}
+
+func bppFromWire(v uint16) int {
+ switch v {
+ case 8:
+ return 1
+ case 15, 16:
+ return 2
+ case 24:
+ return 3
+ case 32:
+ return 4
+ default:
+ if v <= 4 {
+ return int(v)
+ }
+ return 4
+ }
+}
+
+type wireBitmap struct {
+ DestLeft uint16
+ DestTop uint16
+ DestRight uint16
+ DestBottom uint16
+ Width uint16
+ Height uint16
+ BitsPerPixel uint16
+ Flags uint16
+ BitmapLength uint16
+ BitmapDataStream []byte
+}
+
+func readWireBitmap(r *bytes.Reader) (wireBitmap, error) {
+ var rect wireBitmap
+ var err error
+ rect.DestLeft, err = readLE16(r)
+ if err != nil {
+ return rect, err
+ }
+ rect.DestTop, err = readLE16(r)
+ if err != nil {
+ return rect, err
+ }
+ rect.DestRight, err = readLE16(r)
+ if err != nil {
+ return rect, err
+ }
+ rect.DestBottom, err = readLE16(r)
+ if err != nil {
+ return rect, err
+ }
+ rect.Width, err = readLE16(r)
+ if err != nil {
+ return rect, err
+ }
+ rect.Height, err = readLE16(r)
+ if err != nil {
+ return rect, err
+ }
+ rect.BitsPerPixel, err = readLE16(r)
+ if err != nil {
+ return rect, err
+ }
+ rect.Flags, err = readLE16(r)
+ if err != nil {
+ return rect, err
+ }
+ rect.BitmapLength, err = readLE16(r)
+ if err != nil {
+ return rect, err
+ }
+ ln := int(rect.BitmapLength)
+ if rect.Flags&bitmapCompression != 0 && rect.Flags&noBitmapCompressionHdr == 0 {
+ if _, err = readLE16(r); err != nil {
+ return rect, err
+ }
+ mainBody, err := readLE16(r)
+ if err != nil {
+ return rect, err
+ }
+ if _, err = readLE16(r); err != nil {
+ return rect, err
+ }
+ if _, err = readLE16(r); err != nil {
+ return rect, err
+ }
+ ln = int(mainBody)
+ }
+ rect.BitmapDataStream = make([]byte, ln)
+ _, err = io.ReadFull(r, rect.BitmapDataStream)
+ return rect, err
+}
+
+func parseFastPathBitmap(payload []byte) ([]BitmapRect, error) {
+ r := bytes.NewReader(payload)
+ if _, err := readLE16(r); err != nil {
+ return nil, err
+ }
+ count, err := readLE16(r)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]BitmapRect, 0, count)
+ for i := 0; i < int(count); i++ {
+ wb, err := readWireBitmap(r)
+ if err != nil {
+ return out, err
+ }
+ if rect, ok := decodeWireBitmap(wb); ok {
+ out = append(out, rect)
+ }
+ }
+ return out, nil
+}
+
+func parseSlowPathBitmap(payload []byte) ([]BitmapRect, error) {
+ if len(payload) < 15 {
+ return nil, nil
+ }
+ pduType := binaryLittleUint16(payload[2:4])
+ if pduType != pduTypeData {
+ return nil, nil
+ }
+ type2 := payload[14]
+ if type2 != pduType2Update {
+ return nil, nil
+ }
+ body := payload[18:]
+ if len(body) < 2 {
+ return nil, nil
+ }
+ updateType := binaryLittleUint16(body[0:2])
+ if updateType != fastPathUpdateBitmap {
+ return nil, nil
+ }
+ return parseFastPathBitmap(body[2:])
+}
+
+func decodeWireBitmap(wb wireBitmap) (rect BitmapRect, ok bool) {
+ defer func() {
+ if recover() != nil {
+ rect = BitmapRect{}
+ ok = false
+ }
+ }()
+ bpp := bppFromWire(wb.BitsPerPixel)
+ data := wb.BitmapDataStream
+ originalLen := len(data)
+ if wb.Flags&bitmapNoProcessing != 0 {
+ // Surface command 已是自上而下 BGRA // Logan.Li
+ } else if wb.Flags&bitmapCompression != 0 {
+ data = rle.DecompressInto(data, nil, int(wb.Width), int(wb.Height), bpp)
+ if len(data) == 0 {
+ trace.Logf("bitmap decode failed compressed rect=%dx%d@%d,%d wireBpp=%d bpp=%d flags=0x%04x original=%d", wb.Width, wb.Height, wb.DestLeft, wb.DestTop, wb.BitsPerPixel, bpp, wb.Flags, originalLen)
+ return BitmapRect{}, false
+ }
+ } else {
+ data = flipBitmapVertical(data, int(wb.Width), bpp, int(wb.Height))
+ }
+ if len(data) < int(wb.Width)*int(wb.Height)*bpp {
+ trace.Logf("bitmap decode short rect=%dx%d@%d,%d wireBpp=%d bpp=%d flags=0x%04x original=%d decoded=%d", wb.Width, wb.Height, wb.DestLeft, wb.DestTop, wb.BitsPerPixel, bpp, wb.Flags, originalLen, len(data))
+ return BitmapRect{}, false
+ }
+ if isMostlyBlack(data, bpp) && int(wb.Width) >= 128 && int(wb.Height) >= 128 {
+ trace.Logf("bitmap decode mostly black rect=%dx%d@%d,%d wireBpp=%d bpp=%d flags=0x%04x original=%d decoded=%d", wb.Width, wb.Height, wb.DestLeft, wb.DestTop, wb.BitsPerPixel, bpp, wb.Flags, originalLen, len(data))
+ }
+ return BitmapRect{
+ DestLeft: int(wb.DestLeft),
+ DestTop: int(wb.DestTop),
+ Width: int(wb.Width),
+ Height: int(wb.Height),
+ BitsPerPixel: bpp,
+ Data: data,
+ }, true
+}
+
+func isMostlyBlack(data []byte, bpp int) bool {
+ if len(data) == 0 || bpp <= 0 {
+ return false
+ }
+ limit := len(data)
+ if limit > 256*1024 {
+ limit = 256 * 1024
+ }
+ nonBlack := 0
+ for i := 0; i+bpp-1 < limit; i += bpp {
+ switch bpp {
+ case 1:
+ if data[i] > 4 {
+ nonBlack++
+ }
+ case 2:
+ if data[i] > 0 || data[i+1] > 0 {
+ nonBlack++
+ }
+ default:
+ if data[i] > 4 || data[i+1] > 4 || data[i+2] > 4 {
+ nonBlack++
+ }
+ }
+ if nonBlack > 32 {
+ return false
+ }
+ }
+ return true
+}
+
+func flipBitmapVertical(data []byte, width, bpp, height int) []byte {
+ out := append([]byte(nil), data...)
+ stride := width * bpp
+ tmp := make([]byte, stride)
+ for y := 0; y < height/2; y++ {
+ top := y * stride
+ bot := (height - 1 - y) * stride
+ copy(tmp, out[top:top+stride])
+ copy(out[top:top+stride], out[bot:bot+stride])
+ copy(out[bot:bot+stride], tmp)
+ }
+ return out
+}
+
+func bitmapRectPNG(rect BitmapRect) (string, error) {
+ img := image.NewRGBA(image.Rect(0, 0, rect.Width, rect.Height))
+ pix := img.Pix
+ data := rect.Data
+ switch rect.BitsPerPixel {
+ case 1, 2:
+ n := rect.Width * rect.Height
+ for i := 0; i < n && i*2+1 < len(data); i++ {
+ // 保持 PNG 兼容路径与 native display 的 16bpp 大端 RGB565 解析一致。 // Logan.Li
+ c := binary.BigEndian.Uint16(data[i*2 : i*2+2])
+ r := uint8((c >> 11) & 0x1F)
+ g := uint8((c >> 5) & 0x3F)
+ b := uint8(c & 0x1F)
+ off := i * 4
+ pix[off] = (r << 3) | (r >> 2)
+ pix[off+1] = (g << 2) | (g >> 4)
+ pix[off+2] = (b << 3) | (b >> 2)
+ pix[off+3] = 0xFF
+ }
+ default:
+ stride := rect.BitsPerPixel
+ n := rect.Width * rect.Height
+ for i := 0; i < n; i++ {
+ s := i * stride
+ if s+2 >= len(data) {
+ break
+ }
+ off := i * 4
+ pix[off+2] = data[s]
+ pix[off+1] = data[s+1]
+ pix[off] = data[s+2]
+ pix[off+3] = 0xFF
+ }
+ }
+ var buf bytes.Buffer
+ if err := png.Encode(&buf, img); err != nil {
+ return "", err
+ }
+ return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()), nil
+}
+
+func binaryLittleUint16(b []byte) uint16 {
+ return uint16(b[0]) | uint16(b[1])<<8
+}
diff --git a/backend/pkg/rdp/client.go b/backend/pkg/rdp/client.go
new file mode 100644
index 0000000..10e7a34
--- /dev/null
+++ b/backend/pkg/rdp/client.go
@@ -0,0 +1,187 @@
+package rdp
+
+import (
+ "context"
+ "crypto/tls"
+ "fmt"
+ "io"
+ "net"
+ "time"
+
+ "ezops/pkg/rdp/credssp"
+ "ezops/pkg/rdp/transport"
+)
+
+// ProbeHandshake 执行 TCP + TPKT/X.224 + RDP 安全协议协商 // Logan.Li
+func ProbeHandshake(ctx context.Context, cfg Config) (HandshakeInfo, error) {
+ port := cfg.Port
+ if port == 0 {
+ port = 3389
+ }
+ address := fmt.Sprintf("%s:%d", cfg.Host, port)
+ start := time.Now()
+
+ var d net.Dialer
+ conn, err := d.DialContext(ctx, "tcp", address)
+ if err != nil {
+ return HandshakeInfo{Address: address, Duration: time.Since(start)}, err
+ }
+ defer conn.Close()
+ _ = conn.SetDeadline(time.Now().Add(15 * time.Second))
+
+ if err := transport.WriteTPKT(conn, transport.BuildConnectionRequest()); err != nil {
+ return HandshakeInfo{Address: address, Duration: time.Since(start)}, err
+ }
+ payload, err := transport.ReadTPKT(conn)
+ if err != nil {
+ return HandshakeInfo{Address: address, Duration: time.Since(start)}, err
+ }
+ result, err := transport.ParseConnectionConfirm(payload)
+ if err != nil {
+ return HandshakeInfo{Address: address, Duration: time.Since(start)}, err
+ }
+ return HandshakeInfo{
+ Address: address,
+ SelectedProtocol: result.ProtocolName(),
+ Duration: time.Since(start),
+ }, nil
+}
+
+// ProbeTLSHandshake 执行 X.224 协商后升级 TLS,用于验证 Windows 10/Server NLA 前置链路 // Logan.Li
+func ProbeTLSHandshake(ctx context.Context, cfg Config) (HandshakeInfo, error) {
+ port := cfg.Port
+ if port == 0 {
+ port = 3389
+ }
+ address := fmt.Sprintf("%s:%d", cfg.Host, port)
+ start := time.Now()
+
+ var d net.Dialer
+ conn, err := d.DialContext(ctx, "tcp", address)
+ if err != nil {
+ return HandshakeInfo{Address: address, Duration: time.Since(start)}, err
+ }
+ defer conn.Close()
+ _ = conn.SetDeadline(time.Now().Add(15 * time.Second))
+
+ if err := transport.WriteTPKT(conn, transport.BuildConnectionRequestWithCookie(cfg.Username)); err != nil {
+ return HandshakeInfo{Address: address, Duration: time.Since(start)}, err
+ }
+ payload, err := transport.ReadTPKT(conn)
+ if err != nil {
+ return HandshakeInfo{Address: address, Duration: time.Since(start)}, err
+ }
+ result, err := transport.ParseConnectionConfirm(payload)
+ if err != nil {
+ return HandshakeInfo{Address: address, Duration: time.Since(start)}, err
+ }
+ if result.SelectedProtocol != transport.ProtocolTLS && result.SelectedProtocol != transport.ProtocolCredSSP {
+ return HandshakeInfo{Address: address, SelectedProtocol: result.ProtocolName(), Duration: time.Since(start)}, nil
+ }
+ tlsConn, err := transport.UpgradeTLS(conn, cfg.Host, 15*time.Second)
+ if err != nil {
+ return HandshakeInfo{Address: address, SelectedProtocol: result.ProtocolName(), Duration: time.Since(start)}, err
+ }
+ state := tlsConn.ConnectionState()
+ certSubject := ""
+ if len(state.PeerCertificates) > 0 {
+ certSubject = state.PeerCertificates[0].Subject.String()
+ }
+ _ = tlsConn.SetReadDeadline(time.Now().Add(300 * time.Millisecond))
+ _, _ = io.ReadAll(tlsConn)
+ return HandshakeInfo{
+ Address: address,
+ SelectedProtocol: result.ProtocolName(),
+ Duration: time.Since(start),
+ TLSCipher: transport.TLSCipherSuiteName(state.CipherSuite),
+ CertSubject: certSubject,
+ }, nil
+}
+
+// ProbeCredSSP 执行到 CredSSP/NTLMv2 登录完成,用于验证 Windows 10/Server NLA 凭据 // Logan.Li
+func ProbeCredSSP(ctx context.Context, cfg Config) (HandshakeInfo, error) {
+ port := cfg.Port
+ if port == 0 {
+ port = 3389
+ }
+ address := fmt.Sprintf("%s:%d", cfg.Host, port)
+ start := time.Now()
+
+ strategies := []credssp.Options{
+ {Version: 6, PubKeyStyle: "binding"},
+ {Version: 6, PubKeyStyle: "binding", UseCBT: true},
+ {Version: 6, PubKeyStyle: "binding", ForceMIC: true},
+ {Version: 2, PubKeyStyle: "raw"},
+ }
+ var lastErr error
+ for _, opts := range strategies {
+ info, err := probeCredSSPOnce(ctx, cfg, address, opts)
+ if err == nil {
+ info.Duration = time.Since(start)
+ return info, nil
+ }
+ lastErr = err
+ }
+ if lastErr == nil {
+ lastErr = fmt.Errorf("CredSSP 认证失败")
+ }
+ return HandshakeInfo{Address: address, Duration: time.Since(start)}, lastErr
+}
+
+func probeCredSSPOnce(ctx context.Context, cfg Config, address string, opts credssp.Options) (HandshakeInfo, error) {
+ tlsConn, result, err := dialCredSSPOnce(ctx, cfg, address, opts)
+ if err != nil {
+ return HandshakeInfo{Address: address}, err
+ }
+ defer tlsConn.Close()
+ state := tlsConn.ConnectionState()
+ certSubject := ""
+ if len(state.PeerCertificates) > 0 {
+ certSubject = state.PeerCertificates[0].Subject.String()
+ }
+ return HandshakeInfo{
+ Address: address,
+ SelectedProtocol: result.ProtocolName(),
+ TLSCipher: transport.TLSCipherSuiteName(state.CipherSuite),
+ CertSubject: certSubject,
+ }, nil
+}
+
+func dialCredSSPOnce(ctx context.Context, cfg Config, address string, opts credssp.Options) (*tls.Conn, transport.NegotiationResult, error) {
+ var d net.Dialer
+ conn, err := d.DialContext(ctx, "tcp", address)
+ if err != nil {
+ return nil, transport.NegotiationResult{}, err
+ }
+ _ = conn.SetDeadline(time.Now().Add(30 * time.Second))
+
+ if err := transport.WriteTPKT(conn, transport.BuildConnectionRequestWithCookie(cfg.Username)); err != nil {
+ _ = conn.Close()
+ return nil, transport.NegotiationResult{}, err
+ }
+ payload, err := transport.ReadTPKT(conn)
+ if err != nil {
+ _ = conn.Close()
+ return nil, transport.NegotiationResult{}, err
+ }
+ result, err := transport.ParseConnectionConfirm(payload)
+ if err != nil {
+ _ = conn.Close()
+ return nil, transport.NegotiationResult{}, err
+ }
+ if result.SelectedProtocol != transport.ProtocolCredSSP {
+ _ = conn.Close()
+ return nil, result, fmt.Errorf("目标未选择 CredSSP/NLA: %s", result.ProtocolName())
+ }
+ tlsConn, err := transport.UpgradeTLS(conn, cfg.Host, 30*time.Second)
+ if err != nil {
+ _ = conn.Close()
+ return nil, result, err
+ }
+ if err := credssp.AuthenticateWithOptions(tlsConn, cfg.Domain, cfg.Username, cfg.Password, opts); err != nil {
+ _ = tlsConn.Close()
+ return nil, result, err
+ }
+ _ = tlsConn.SetDeadline(time.Time{})
+ return tlsConn, result, nil
+}
diff --git a/backend/pkg/rdp/credssp/asn1.go b/backend/pkg/rdp/credssp/asn1.go
new file mode 100644
index 0000000..d02ebe1
--- /dev/null
+++ b/backend/pkg/rdp/credssp/asn1.go
@@ -0,0 +1,74 @@
+package credssp
+
+import "encoding/asn1"
+
+const defaultCredSSPVersion = 6
+
+// negoToken 是 CredSSP TSRequest 中的单个协商令牌 // Logan.Li
+type negoToken struct {
+ Data []byte `asn1:"explicit,tag:0"`
+}
+
+// tsRequest 是 MS-CSSP 2.2.1.2 TSRequest 的最小实现 // Logan.Li
+type tsRequest struct {
+ Version int `asn1:"explicit,tag:0"`
+ NegoTokens []negoToken `asn1:"optional,explicit,tag:1"`
+ AuthInfo []byte `asn1:"optional,explicit,tag:2"`
+ PubKeyAuth []byte `asn1:"optional,explicit,tag:3"`
+ ErrorCode int `asn1:"optional,explicit,tag:4"`
+ ClientNonce []byte `asn1:"optional,explicit,tag:5"`
+}
+
+type tsCredentials struct {
+ CredType int `asn1:"explicit,tag:0"`
+ Credentials []byte `asn1:"explicit,tag:1"`
+}
+
+type tsPasswordCreds struct {
+ DomainName []byte `asn1:"explicit,tag:0"`
+ UserName []byte `asn1:"explicit,tag:1"`
+ Password []byte `asn1:"explicit,tag:2"`
+}
+
+func encodeTSRequest(tokens [][]byte, authInfo, pubKeyAuth []byte) ([]byte, error) {
+ return encodeTSRequestWithNonce(tokens, authInfo, pubKeyAuth, nil)
+}
+
+func encodeTSRequestWithNonce(tokens [][]byte, authInfo, pubKeyAuth, clientNonce []byte) ([]byte, error) {
+ return encodeTSRequestVersion(defaultCredSSPVersion, tokens, authInfo, pubKeyAuth, clientNonce)
+}
+
+func encodeTSRequestVersion(version int, tokens [][]byte, authInfo, pubKeyAuth, clientNonce []byte) ([]byte, error) {
+ req := tsRequest{Version: version}
+ for _, token := range tokens {
+ req.NegoTokens = append(req.NegoTokens, negoToken{Data: token})
+ }
+ if len(authInfo) > 0 {
+ req.AuthInfo = authInfo
+ }
+ if len(pubKeyAuth) > 0 {
+ req.PubKeyAuth = pubKeyAuth
+ }
+ if len(clientNonce) > 0 {
+ req.ClientNonce = clientNonce
+ }
+ return asn1.Marshal(req)
+}
+
+func decodeTSRequest(data []byte) (*tsRequest, error) {
+ var req tsRequest
+ _, err := asn1.Unmarshal(data, &req)
+ return &req, err
+}
+
+func encodePasswordCredentials(domain, user, password string) ([]byte, error) {
+ pass, err := asn1.Marshal(tsPasswordCreds{
+ DomainName: utf16le(domain),
+ UserName: utf16le(user),
+ Password: utf16le(password),
+ })
+ if err != nil {
+ return nil, err
+ }
+ return asn1.Marshal(tsCredentials{CredType: 1, Credentials: pass})
+}
diff --git a/backend/pkg/rdp/credssp/credssp.go b/backend/pkg/rdp/credssp/credssp.go
new file mode 100644
index 0000000..5ba6c39
--- /dev/null
+++ b/backend/pkg/rdp/credssp/credssp.go
@@ -0,0 +1,332 @@
+package credssp
+
+import (
+ "crypto/md5"
+ "crypto/rsa"
+ "crypto/sha1"
+ "crypto/sha256"
+ "crypto/tls"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/asn1"
+ "fmt"
+ "io"
+ "math/big"
+ "strings"
+)
+
+// Options 控制 CredSSP 兼容策略,仅调试和特殊 Windows 版本需要覆盖 // Logan.Li
+type Options struct {
+ Version int
+ PubKeyStyle string
+ ForceMIC bool
+ NoKeyExch bool
+ UseCBT bool
+ OmitVersion bool
+}
+
+// ChallengeInfo 暴露 NTLM challenge 诊断信息,不包含凭据 // Logan.Li
+type ChallengeInfo struct {
+ Version int
+ TargetName string
+ Flags uint32
+ TargetInfoBytes int
+ RequiresMIC bool
+ DomainName string
+ ComputerName string
+}
+
+// ReadChallengeInfo 只读取 CredSSP/NTLM challenge,不发送认证数据 // Logan.Li
+func ReadChallengeInfo(conn *tls.Conn, version int) (ChallengeInfo, error) {
+ if version == 0 {
+ version = defaultCredSSPVersion
+ }
+ negotiate := buildNegotiate()
+ req, err := encodeTSRequestVersion(version, [][]byte{negotiate}, nil, nil, nil)
+ if err != nil {
+ return ChallengeInfo{}, err
+ }
+ if _, err := conn.Write(req); err != nil {
+ return ChallengeInfo{}, err
+ }
+ challengeReq, err := readTSRequest(conn)
+ if err != nil {
+ return ChallengeInfo{}, err
+ }
+ if len(challengeReq.NegoTokens) == 0 {
+ return ChallengeInfo{}, fmt.Errorf("CredSSP challenge 缺少 nego token")
+ }
+ challenge, err := parseChallenge(challengeReq.NegoTokens[0].Data)
+ if err != nil {
+ return ChallengeInfo{}, err
+ }
+ return ChallengeInfo{
+ Version: challengeReq.Version,
+ TargetName: challenge.TargetName,
+ Flags: challenge.Flags,
+ TargetInfoBytes: len(challenge.TargetInfo),
+ RequiresMIC: requiresMIC(challenge.TargetInfo),
+ DomainName: utf16leString(findAVPair(challenge.TargetInfo, msvAvNbDomainName)),
+ ComputerName: utf16leString(findAVPair(challenge.TargetInfo, msvAvNbComputerName)),
+ }, nil
+}
+
+// Authenticate 在已升级 TLS 的 RDP 连接上执行最小 CredSSP/NTLMv2 认证 // Logan.Li
+func Authenticate(conn *tls.Conn, domain, user, password string) error {
+ return AuthenticateWithOptions(conn, domain, user, password, Options{Version: defaultCredSSPVersion, PubKeyStyle: "binding"})
+}
+
+// AuthenticateWithOptions 使用指定 CredSSP 版本和 pubKeyAuth 算法执行认证 // Logan.Li
+func AuthenticateWithOptions(conn *tls.Conn, domain, user, password string, opts Options) error {
+ if opts.Version == 0 {
+ opts.Version = defaultCredSSPVersion
+ }
+ if opts.PubKeyStyle == "" {
+ opts.PubKeyStyle = "binding"
+ }
+ domain, user = splitDomainUser(domain, user)
+ negotiate := buildNegotiate()
+ req, err := encodeTSRequestVersion(opts.Version, [][]byte{negotiate}, nil, nil, nil)
+ if err != nil {
+ return err
+ }
+ if _, err := conn.Write(req); err != nil {
+ return err
+ }
+
+ challengeReq, err := readTSRequest(conn)
+ if err != nil {
+ return fmt.Errorf("读取 NTLM challenge 失败: %w", err)
+ }
+ if len(challengeReq.NegoTokens) == 0 {
+ return fmt.Errorf("CredSSP challenge 缺少 nego token")
+ }
+ challenge, err := parseChallenge(challengeReq.NegoTokens[0].Data)
+ if err != nil {
+ return err
+ }
+ ntlmOpts := ntlmOptions{
+ ForceMIC: opts.ForceMIC,
+ DisableKeyExch: opts.NoKeyExch,
+ OmitVersion: opts.OmitVersion,
+ }
+ if opts.UseCBT {
+ ntlmOpts.ChannelBindings = channelBindingsHash(conn)
+ }
+ auth, sec, err := buildAuthenticate(challenge, domain, user, password, "AIOPS", negotiate, ntlmOpts)
+ if err != nil {
+ return err
+ }
+ pubKey, err := peerPublicKey(conn, opts.PubKeyStyle)
+ if err != nil {
+ return err
+ }
+ clientNonce := randomBytes(32)
+ pubKeyAuthPlain := buildPubKeyAuth(pubKey, clientNonce, opts.PubKeyStyle)
+ pubKeyAuth := sec.encrypt(pubKeyAuthPlain)
+ var nonce []byte
+ if opts.PubKeyStyle == "binding" {
+ nonce = clientNonce
+ }
+ req, err = encodeTSRequestVersion(opts.Version, [][]byte{auth}, nil, pubKeyAuth, nonce)
+ if err != nil {
+ return err
+ }
+ if _, err := conn.Write(req); err != nil {
+ return err
+ }
+
+ serverReq, err := readTSRequest(conn)
+ if err != nil {
+ return fmt.Errorf("读取服务端公钥校验失败(flags=0x%08x mic=%v targetInfo=%d): %w", challenge.Flags, requiresMIC(challenge.TargetInfo), len(challenge.TargetInfo), err)
+ }
+ if serverReq.ErrorCode != 0 {
+ return fmt.Errorf("CredSSP 返回错误码: %s", ntStatusMessage(serverReq.ErrorCode))
+ }
+
+ creds, err := encodePasswordCredentials(domain, user, password)
+ if err != nil {
+ return err
+ }
+ encryptedCreds := sec.encrypt(creds)
+ req, err = encodeTSRequestVersion(opts.Version, nil, encryptedCreds, nil, nil)
+ if err != nil {
+ return err
+ }
+ _, err = conn.Write(req)
+ return err
+}
+
+func buildPubKeyAuth(pubKey, clientNonce []byte, style string) []byte {
+ switch style {
+ case "raw-plus-one":
+ out := append([]byte(nil), pubKey...)
+ if len(out) > 0 {
+ out[0]++
+ }
+ return out
+ case "binding":
+ return credSSPBindingHash(clientNonce, pubKey)
+ default:
+ return pubKey
+ }
+}
+
+func ntStatusMessage(code int) string {
+ u := uint32(code)
+ switch u {
+ case 0xC0000064:
+ return "0xC0000064 用户不存在"
+ case 0xC000006A:
+ return "0xC000006A 密码错误"
+ case 0xC000006D:
+ return "0xC000006D 用户名或认证信息无效"
+ case 0xC000006E:
+ return "0xC000006E 账号登录限制"
+ case 0xC0000070:
+ return "0xC0000070 工作站限制"
+ case 0xC0000071:
+ return "0xC0000071 密码已过期"
+ case 0xC0000072:
+ return "0xC0000072 账号已禁用"
+ case 0xC000015B:
+ return "0xC000015B 当前用户未被授予远程登录权限"
+ case 0xC0000193:
+ return "0xC0000193 账号已过期"
+ case 0xC0000234:
+ return "0xC0000234 账号已锁定,请先在 Windows/域控侧解锁后再连接"
+ default:
+ return fmt.Sprintf("0x%08X", u)
+ }
+}
+
+func credSSPBindingHash(clientNonce, pubKey []byte) []byte {
+ h := sha256.New()
+ h.Write([]byte("CredSSP Client-To-Server Binding Hash\x00"))
+ h.Write(clientNonce)
+ h.Write(pubKey)
+ return h.Sum(nil)
+}
+
+func channelBindingsHash(conn *tls.Conn) []byte {
+ state := conn.ConnectionState()
+ if len(state.PeerCertificates) == 0 {
+ return nil
+ }
+ cert := state.PeerCertificates[0]
+ alg := cert.SignatureAlgorithm.String()
+ var digest []byte
+ switch {
+ case strings.Contains(alg, "MD5"):
+ sum := md5.Sum(cert.Raw)
+ digest = sum[:]
+ case strings.Contains(alg, "SHA1"):
+ sum := sha1.Sum(cert.Raw)
+ digest = sum[:]
+ default:
+ sum := sha256.Sum256(cert.Raw)
+ digest = sum[:]
+ }
+ appData := append([]byte("tls-server-end-point:"), digest...)
+ gss := make([]byte, 16+len(appData))
+ copy(gss[16:], appData)
+ gss[12] = byte(len(appData))
+ gss[13] = byte(len(appData) >> 8)
+ gss[14] = byte(len(appData) >> 16)
+ gss[15] = byte(len(appData) >> 24)
+ sum := md5.Sum(gss)
+ return sum[:]
+}
+
+func readTSRequest(r io.Reader) (*tsRequest, error) {
+ der, err := readDER(r)
+ if err != nil {
+ return nil, err
+ }
+ return decodeTSRequest(der)
+}
+
+func readDER(r io.Reader) ([]byte, error) {
+ header := make([]byte, 2)
+ if _, err := io.ReadFull(r, header); err != nil {
+ return nil, err
+ }
+ if header[0] != 0x30 {
+ return nil, fmt.Errorf("unexpected DER sequence tag: 0x%x", header[0])
+ }
+ length := int(header[1])
+ lenBytes := 0
+ if header[1]&0x80 != 0 {
+ lenBytes = int(header[1] & 0x7f)
+ if lenBytes == 0 || lenBytes > 4 {
+ return nil, fmt.Errorf("invalid DER length bytes: %d", lenBytes)
+ }
+ raw := make([]byte, lenBytes)
+ if _, err := io.ReadFull(r, raw); err != nil {
+ return nil, err
+ }
+ length = 0
+ for _, b := range raw {
+ length = length<<8 | int(b)
+ }
+ header = append(header, raw...)
+ }
+ body := make([]byte, length)
+ if _, err := io.ReadFull(r, body); err != nil {
+ return nil, err
+ }
+ return append(header, body...), nil
+}
+
+func peerPublicKey(conn *tls.Conn, style string) ([]byte, error) {
+ state := conn.ConnectionState()
+ if len(state.PeerCertificates) == 0 {
+ return nil, fmt.Errorf("RDP TLS 证书为空")
+ }
+ cert := state.PeerCertificates[0]
+ if style == "rsa-compat" {
+ if pub, ok := cert.PublicKey.(*rsa.PublicKey); ok {
+ return asn1.Marshal(rsaPublicKeyCompat{N: pub.N, E: pub.E})
+ }
+ }
+ // MS-CSSP v5/v6 绑定哈希必须使用 X.509 SubjectPublicKey,而非完整 SPKI // Logan.Li
+ return subjectPublicKey(cert)
+}
+
+func subjectPublicKey(cert *x509.Certificate) ([]byte, error) {
+ if pub, ok := cert.PublicKey.(*rsa.PublicKey); ok {
+ return asn1.Marshal(*pub)
+ }
+ var spki struct {
+ Algorithm pkix.AlgorithmIdentifier
+ PublicKey asn1.BitString
+ }
+ if _, err := asn1.Unmarshal(cert.RawSubjectPublicKeyInfo, &spki); err != nil {
+ return nil, fmt.Errorf("解析 TLS SubjectPublicKey 失败: %w", err)
+ }
+ if len(spki.PublicKey.Bytes) == 0 {
+ return nil, fmt.Errorf("TLS SubjectPublicKey 为空")
+ }
+ return spki.PublicKey.Bytes, nil
+}
+
+type rsaPublicKeyCompat struct {
+ N *big.Int `asn1:"explicit,tag:0"`
+ E int `asn1:"explicit,tag:1"`
+}
+
+func splitDomainUser(domain, user string) (string, string) {
+ if strings.Contains(user, "\\") {
+ parts := strings.Split(user, "\\")
+ if len(parts) >= 2 {
+ return parts[0], parts[len(parts)-1]
+ }
+ }
+ if strings.Contains(user, "/") {
+ parts := strings.Split(user, "/")
+ if len(parts) >= 2 {
+ return parts[0], parts[len(parts)-1]
+ }
+ }
+ return domain, user
+}
diff --git a/backend/pkg/rdp/credssp/credssp_test.go b/backend/pkg/rdp/credssp/credssp_test.go
new file mode 100644
index 0000000..142dec4
--- /dev/null
+++ b/backend/pkg/rdp/credssp/credssp_test.go
@@ -0,0 +1,21 @@
+package credssp
+
+import "testing"
+
+func TestEncodeDecodeTSRequest(t *testing.T) {
+ nego := buildNegotiate()
+ req, err := encodeTSRequest([][]byte{nego}, nil, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ decoded, err := decodeTSRequest(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if decoded.Version != defaultCredSSPVersion {
+ t.Fatalf("version=%d", decoded.Version)
+ }
+ if len(decoded.NegoTokens) != 1 || string(decoded.NegoTokens[0].Data[:8]) != "NTLMSSP\x00" {
+ t.Fatalf("invalid nego token")
+ }
+}
diff --git a/backend/pkg/rdp/credssp/ntlm.go b/backend/pkg/rdp/credssp/ntlm.go
new file mode 100644
index 0000000..ffc4d37
--- /dev/null
+++ b/backend/pkg/rdp/credssp/ntlm.go
@@ -0,0 +1,386 @@
+package credssp
+
+import (
+ "bytes"
+ "crypto/hmac"
+ "crypto/md5"
+ "crypto/rand"
+ "crypto/rc4"
+ "encoding/binary"
+ "fmt"
+ "strings"
+ "time"
+
+ "golang.org/x/crypto/md4"
+)
+
+const (
+ ntlmNegotiateUnicode uint32 = 0x00000001
+ ntlmRequestTarget uint32 = 0x00000004
+ ntlmNegotiateSign uint32 = 0x00000010
+ ntlmNegotiateSeal uint32 = 0x00000020
+ ntlmNegotiateNTLM uint32 = 0x00000200
+ ntlmNegotiateAlwaysSign uint32 = 0x00008000
+ ntlmNegotiateExtendedSessionSecurity uint32 = 0x00080000
+ ntlmNegotiateTargetInfo uint32 = 0x00800000
+ ntlmNegotiateVersion uint32 = 0x02000000
+ ntlmNegotiate128 uint32 = 0x20000000
+ ntlmNegotiateKeyExch uint32 = 0x40000000
+ ntlmNegotiate56 uint32 = 0x80000000
+
+ msvAvEOL uint16 = 0x0000
+ msvAvNbComputerName uint16 = 0x0001
+ msvAvNbDomainName uint16 = 0x0002
+ msvAvFlags uint16 = 0x0006
+ msvAvTimestamp uint16 = 0x0007
+ msvChannelBindings uint16 = 0x000A
+)
+
+const ntlmClientFlags = ntlmNegotiateUnicode |
+ ntlmRequestTarget |
+ ntlmNegotiateSign |
+ ntlmNegotiateSeal |
+ ntlmNegotiateNTLM |
+ ntlmNegotiateAlwaysSign |
+ ntlmNegotiateExtendedSessionSecurity |
+ ntlmNegotiateTargetInfo |
+ ntlmNegotiateVersion |
+ ntlmNegotiate128 |
+ ntlmNegotiateKeyExch
+
+type ntlmChallenge struct {
+ Raw []byte
+ Flags uint32
+ TargetName string
+ ServerChallenge []byte
+ TargetInfo []byte
+ Timestamp []byte
+}
+
+type ntlmOptions struct {
+ ForceMIC bool
+ DisableKeyExch bool
+ ChannelBindings []byte
+ OmitVersion bool
+}
+
+type ntlmSecurity struct {
+ clientSigningKey []byte
+ serverSigningKey []byte
+ clientSealing *rc4.Cipher
+ serverSealing *rc4.Cipher
+ seq uint32
+}
+
+func buildNegotiate() []byte {
+ buf := bytes.NewBuffer(nil)
+ buf.Write([]byte("NTLMSSP\x00"))
+ writeU32(buf, 1)
+ writeU32(buf, ntlmClientFlags)
+ writeSecBuffer(buf, 0, 0)
+ writeSecBuffer(buf, 0, 0)
+ buf.Write(ntlmVersion())
+ return buf.Bytes()
+}
+
+func parseChallenge(data []byte) (*ntlmChallenge, error) {
+ if len(data) < 48 || string(data[:8]) != "NTLMSSP\x00" || binary.LittleEndian.Uint32(data[8:12]) != 2 {
+ return nil, fmt.Errorf("invalid NTLM challenge")
+ }
+ targetNameLen := int(binary.LittleEndian.Uint16(data[12:14]))
+ targetNameOffset := int(binary.LittleEndian.Uint32(data[16:20]))
+ targetInfoLen := int(binary.LittleEndian.Uint16(data[40:42]))
+ targetInfoOffset := int(binary.LittleEndian.Uint32(data[44:48]))
+ if targetInfoOffset < 0 || targetInfoOffset+targetInfoLen > len(data) {
+ return nil, fmt.Errorf("invalid NTLM target info")
+ }
+ targetName := ""
+ if targetNameOffset >= 0 && targetNameOffset+targetNameLen <= len(data) {
+ targetName = utf16leString(data[targetNameOffset : targetNameOffset+targetNameLen])
+ }
+ targetInfo := data[targetInfoOffset : targetInfoOffset+targetInfoLen]
+ return &ntlmChallenge{
+ Raw: append([]byte(nil), data...),
+ Flags: binary.LittleEndian.Uint32(data[20:24]),
+ TargetName: targetName,
+ ServerChallenge: append([]byte(nil), data[24:32]...),
+ TargetInfo: append([]byte(nil), targetInfo...),
+ Timestamp: findAVPair(targetInfo, msvAvTimestamp),
+ }, nil
+}
+
+func buildAuthenticate(ch *ntlmChallenge, domain, user, password, workstation string, negotiate []byte, opts ntlmOptions) ([]byte, *ntlmSecurity, error) {
+ if domain == "" {
+ domain = ch.TargetName
+ }
+ if domain == "" {
+ domain = utf16leString(findAVPair(ch.TargetInfo, msvAvNbDomainName))
+ }
+ respKey := ntowfv2(password, user, domain)
+ clientChallenge := randomBytes(8)
+ timestamp := ch.Timestamp
+ if len(timestamp) == 0 {
+ timestamp = windowsFileTime(time.Now())
+ }
+
+ blob := bytes.NewBuffer(nil)
+ blob.Write([]byte{0x01, 0x01, 0, 0, 0, 0, 0, 0})
+ blob.Write(timestamp)
+ blob.Write(clientChallenge)
+ blob.Write([]byte{0, 0, 0, 0})
+ targetInfo := ch.TargetInfo
+ if len(opts.ChannelBindings) == 16 {
+ targetInfo = withChannelBindings(ch.TargetInfo, opts.ChannelBindings)
+ }
+ blob.Write(targetInfo)
+ blob.Write([]byte{0, 0, 0, 0})
+
+ ntProof := hmacMD5(respKey, append(append([]byte(nil), ch.ServerChallenge...), blob.Bytes()...))
+ ntResp := append(ntProof, blob.Bytes()...)
+ lmResp := append(hmacMD5(respKey, append(append([]byte(nil), ch.ServerChallenge...), clientChallenge...)), clientChallenge...)
+ sessionBaseKey := hmacMD5(respKey, ntProof)
+ exportedSessionKey := randomBytes(16)
+ encryptedSessionKey := rc4k(sessionBaseKey, exportedSessionKey)
+
+ domainBytes := utf16le(domain)
+ userBytes := utf16le(user)
+ workstationBytes := utf16le(workstation)
+ flags := ch.Flags & ntlmClientFlags
+ flags |= ntlmNegotiateUnicode | ntlmNegotiateExtendedSessionSecurity | ntlmNegotiateNTLM
+ if opts.DisableKeyExch {
+ flags &^= ntlmNegotiateKeyExch
+ exportedSessionKey = sessionBaseKey
+ encryptedSessionKey = nil
+ }
+
+ includeMIC := opts.ForceMIC || requiresMIC(ch.TargetInfo)
+ baseLen := 72
+ if opts.OmitVersion {
+ baseLen = 64
+ }
+ if includeMIC {
+ baseLen += 16
+ }
+ payload := bytes.NewBuffer(nil)
+ offset := uint32(baseLen)
+ lmOffset := offset
+ payload.Write(lmResp)
+ offset += uint32(len(lmResp))
+ ntOffset := offset
+ payload.Write(ntResp)
+ offset += uint32(len(ntResp))
+ domainOffset := offset
+ payload.Write(domainBytes)
+ offset += uint32(len(domainBytes))
+ userOffset := offset
+ payload.Write(userBytes)
+ offset += uint32(len(userBytes))
+ workstationOffset := offset
+ payload.Write(workstationBytes)
+ offset += uint32(len(workstationBytes))
+ sessionOffset := offset
+ if len(encryptedSessionKey) > 0 {
+ payload.Write(encryptedSessionKey)
+ }
+
+ msg := bytes.NewBuffer(nil)
+ msg.Write([]byte("NTLMSSP\x00"))
+ writeU32(msg, 3)
+ writeSecBuffer(msg, uint16(len(lmResp)), lmOffset)
+ writeSecBuffer(msg, uint16(len(ntResp)), ntOffset)
+ writeSecBuffer(msg, uint16(len(domainBytes)), domainOffset)
+ writeSecBuffer(msg, uint16(len(userBytes)), userOffset)
+ writeSecBuffer(msg, uint16(len(workstationBytes)), workstationOffset)
+ writeSecBuffer(msg, uint16(len(encryptedSessionKey)), sessionOffset)
+ writeU32(msg, flags)
+ if !opts.OmitVersion {
+ msg.Write(ntlmVersion())
+ }
+ micPos := -1
+ if includeMIC {
+ micPos = msg.Len()
+ msg.Write(make([]byte, 16))
+ }
+ msg.Write(payload.Bytes())
+
+ if includeMIC {
+ micInput := bytes.NewBuffer(nil)
+ micInput.Write(negotiate)
+ micInput.Write(ch.Raw)
+ micInput.Write(msg.Bytes())
+ mic := hmacMD5(exportedSessionKey, micInput.Bytes())
+ copy(msg.Bytes()[micPos:micPos+16], mic)
+ }
+
+ clientSeal, _ := rc4.NewCipher(md5sum(append(exportedSessionKey, []byte("session key to client-to-server sealing key magic constant\x00")...)))
+ serverSeal, _ := rc4.NewCipher(md5sum(append(exportedSessionKey, []byte("session key to server-to-client sealing key magic constant\x00")...)))
+ sec := &ntlmSecurity{
+ clientSigningKey: md5sum(append(exportedSessionKey, []byte("session key to client-to-server signing key magic constant\x00")...)),
+ serverSigningKey: md5sum(append(exportedSessionKey, []byte("session key to server-to-client signing key magic constant\x00")...)),
+ clientSealing: clientSeal,
+ serverSealing: serverSeal,
+ }
+ return msg.Bytes(), sec, nil
+}
+
+func (s *ntlmSecurity) encrypt(data []byte) []byte {
+ sealed := make([]byte, len(data))
+ s.clientSealing.XORKeyStream(sealed, data)
+ macInput := bytes.NewBuffer(nil)
+ writeU32(macInput, s.seq)
+ macInput.Write(data)
+ checksum := hmacMD5(s.clientSigningKey, macInput.Bytes())[:8]
+ encryptedChecksum := make([]byte, 8)
+ s.clientSealing.XORKeyStream(encryptedChecksum, checksum)
+ out := bytes.NewBuffer(nil)
+ writeU32(out, 1)
+ out.Write(encryptedChecksum)
+ writeU32(out, s.seq)
+ out.Write(sealed)
+ s.seq++
+ return out.Bytes()
+}
+
+func ntowfv2(password, user, domain string) []byte {
+ h := md4.New()
+ h.Write(utf16le(password))
+ return hmacMD5(h.Sum(nil), utf16le(strings.ToUpper(user)+domain))
+}
+
+func hmacMD5(key, data []byte) []byte {
+ h := hmac.New(md5.New, key)
+ h.Write(data)
+ return h.Sum(nil)
+}
+
+func md5sum(data []byte) []byte {
+ sum := md5.Sum(data)
+ return sum[:]
+}
+
+func rc4k(key, data []byte) []byte {
+ c, _ := rc4.NewCipher(key)
+ out := make([]byte, len(data))
+ c.XORKeyStream(out, data)
+ return out
+}
+
+func randomBytes(n int) []byte {
+ buf := make([]byte, n)
+ _, _ = rand.Read(buf)
+ return buf
+}
+
+func windowsFileTime(t time.Time) []byte {
+ v := uint64(t.UnixNano()/100) + 116444736000000000
+ out := make([]byte, 8)
+ binary.LittleEndian.PutUint64(out, v)
+ return out
+}
+
+func findAVPair(data []byte, id uint16) []byte {
+ for len(data) >= 4 {
+ avID := binary.LittleEndian.Uint16(data[0:2])
+ avLen := int(binary.LittleEndian.Uint16(data[2:4]))
+ data = data[4:]
+ if avLen > len(data) {
+ return nil
+ }
+ if avID == msvAvEOL {
+ return nil
+ }
+ if avID == id {
+ return append([]byte(nil), data[:avLen]...)
+ }
+ data = data[avLen:]
+ }
+ return nil
+}
+
+func requiresMIC(targetInfo []byte) bool {
+ flags := findAVPair(targetInfo, msvAvFlags)
+ if len(flags) < 4 {
+ return false
+ }
+ return binary.LittleEndian.Uint32(flags)&0x00000002 != 0
+}
+
+func withChannelBindings(targetInfo, hash []byte) []byte {
+ out := bytes.NewBuffer(nil)
+ inserted := false
+ for len(targetInfo) >= 4 {
+ avID := binary.LittleEndian.Uint16(targetInfo[0:2])
+ avLen := int(binary.LittleEndian.Uint16(targetInfo[2:4]))
+ if 4+avLen > len(targetInfo) {
+ return targetInfo
+ }
+ if avID == msvAvEOL {
+ if !inserted {
+ writeU16(out, msvChannelBindings)
+ writeU16(out, 16)
+ out.Write(hash[:16])
+ }
+ out.Write(targetInfo[:4])
+ return out.Bytes()
+ }
+ if avID == msvChannelBindings {
+ writeU16(out, msvChannelBindings)
+ writeU16(out, 16)
+ out.Write(hash[:16])
+ inserted = true
+ } else {
+ out.Write(targetInfo[:4+avLen])
+ }
+ targetInfo = targetInfo[4+avLen:]
+ }
+ if !inserted {
+ writeU16(out, msvChannelBindings)
+ writeU16(out, 16)
+ out.Write(hash[:16])
+ }
+ out.Write([]byte{0, 0, 0, 0})
+ return out.Bytes()
+}
+
+func writeSecBuffer(buf *bytes.Buffer, length uint16, offset uint32) {
+ writeU16(buf, length)
+ writeU16(buf, length)
+ writeU32(buf, offset)
+}
+
+func writeU16(buf *bytes.Buffer, v uint16) {
+ _ = binary.Write(buf, binary.LittleEndian, v)
+}
+
+func writeU32(buf *bytes.Buffer, v uint32) {
+ _ = binary.Write(buf, binary.LittleEndian, v)
+}
+
+func ntlmVersion() []byte {
+ out := []byte{10, 0, 0, 0, 0, 0, 0, 15}
+ binary.LittleEndian.PutUint16(out[2:4], 19045)
+ return out
+}
+
+func utf16le(s string) []byte {
+ out := bytes.NewBuffer(nil)
+ for _, r := range s {
+ if r > 0xffff {
+ r = '?'
+ }
+ writeU16(out, uint16(r))
+ }
+ return out.Bytes()
+}
+
+func utf16leString(data []byte) string {
+ runes := make([]rune, 0, len(data)/2)
+ for i := 0; i+1 < len(data); i += 2 {
+ v := binary.LittleEndian.Uint16(data[i : i+2])
+ if v == 0 {
+ continue
+ }
+ runes = append(runes, rune(v))
+ }
+ return string(runes)
+}
diff --git a/backend/pkg/rdp/credssp/pubkey_test.go b/backend/pkg/rdp/credssp/pubkey_test.go
new file mode 100644
index 0000000..ce6fb89
--- /dev/null
+++ b/backend/pkg/rdp/credssp/pubkey_test.go
@@ -0,0 +1,55 @@
+package credssp
+
+import (
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/asn1"
+ "math/big"
+ "testing"
+ "time"
+)
+
+func TestSubjectPublicKeyDiffersFromPKIX(t *testing.T) {
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatal(err)
+ }
+ certDER, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{
+ SerialNumber: big.NewInt(1),
+ Subject: pkix.Name{CommonName: "rdp-test"},
+ NotBefore: time.Now().Add(-time.Hour),
+ NotAfter: time.Now().Add(time.Hour),
+ }, &x509.Certificate{
+ SerialNumber: big.NewInt(1),
+ Subject: pkix.Name{CommonName: "rdp-test"},
+ NotBefore: time.Now().Add(-time.Hour),
+ NotAfter: time.Now().Add(time.Hour),
+ }, &key.PublicKey, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ cert, err := x509.ParseCertificate(certDER)
+ if err != nil {
+ t.Fatal(err)
+ }
+ subjectKey, err := subjectPublicKey(cert)
+ if err != nil {
+ t.Fatal(err)
+ }
+ pkixKey, err := x509.MarshalPKIXPublicKey(cert.PublicKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(subjectKey) == string(pkixKey) {
+ t.Fatal("SubjectPublicKey 不应等于完整 SPKI")
+ }
+ rsaDER, err := asn1.Marshal(*cert.PublicKey.(*rsa.PublicKey))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(subjectKey) != string(rsaDER) {
+ t.Fatalf("SubjectPublicKey 应与 RSA PKCS#1 DER 一致,len=%d/%d", len(subjectKey), len(rsaDER))
+ }
+}
\ No newline at end of file
diff --git a/backend/pkg/rdp/display/framebuffer.go b/backend/pkg/rdp/display/framebuffer.go
new file mode 100644
index 0000000..91ff09c
--- /dev/null
+++ b/backend/pkg/rdp/display/framebuffer.go
@@ -0,0 +1,145 @@
+package display
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/binary"
+ "image"
+ "image/draw"
+ "image/png"
+)
+
+// Bitmap 表示协议层解码后的 BGRA/RGB565 小块,display 只负责合成和输出 // Logan.Li
+type Bitmap struct {
+ X int
+ Y int
+ Width int
+ Height int
+ BitsPerPixel int
+ Data []byte
+}
+
+// Frame 表示一次合成后的脏区域输出 // Logan.Li
+type Frame struct {
+ X int
+ Y int
+ Width int
+ Height int
+ Image string
+ RGBA []byte
+}
+
+// Framebuffer 借鉴 Guacamole/bring 的 display/layer 思路,在后端维护完整画布 // Logan.Li
+type Framebuffer struct {
+ width int
+ height int
+ img *image.RGBA
+ dirty image.Rectangle
+}
+
+// NewFramebuffer 创建固定尺寸远端画布,所有 RDP 更新先合成到这里 // Logan.Li
+func NewFramebuffer(width, height int) *Framebuffer {
+ if width <= 0 {
+ width = 1280
+ }
+ if height <= 0 {
+ height = 800
+ }
+ return &Framebuffer{
+ width: width,
+ height: height,
+ img: image.NewRGBA(image.Rect(0, 0, width, height)),
+ }
+}
+
+// Draw 合成一个 bitmap 小块,并扩大脏矩形 // Logan.Li
+func (f *Framebuffer) Draw(b Bitmap) {
+ if b.Width <= 0 || b.Height <= 0 || len(b.Data) == 0 {
+ return
+ }
+ dstRect := image.Rect(b.X, b.Y, b.X+b.Width, b.Y+b.Height).Intersect(f.img.Bounds())
+ if dstRect.Empty() {
+ return
+ }
+ src := bitmapToRGBA(b)
+ srcPoint := image.Pt(dstRect.Min.X-b.X, dstRect.Min.Y-b.Y)
+ draw.Draw(f.img, dstRect, src, srcPoint, draw.Src)
+ if f.dirty.Empty() {
+ f.dirty = dstRect
+ } else {
+ f.dirty = f.dirty.Union(dstRect)
+ }
+}
+
+// Flush 输出当前脏区域 PNG,并清空脏矩形 // Logan.Li
+func (f *Framebuffer) Flush() (Frame, bool) {
+ frame, ok := f.FlushRGBA()
+ if !ok {
+ return Frame{}, false
+ }
+ 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 Frame{}, false
+ }
+ frame.Image = "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
+ return frame, true
+}
+
+// FlushRGBA 输出当前脏区域原始 RGBA,避免实时 RDP 路径重复 PNG/base64 编解码。 // Logan.Li
+func (f *Framebuffer) FlushRGBA() (Frame, bool) {
+ if f.dirty.Empty() {
+ return Frame{}, false
+ }
+ rect := f.dirty.Intersect(f.img.Bounds())
+ f.dirty = image.Rectangle{}
+ if rect.Empty() {
+ return Frame{}, false
+ }
+ crop := image.NewRGBA(image.Rect(0, 0, rect.Dx(), rect.Dy()))
+ draw.Draw(crop, crop.Bounds(), f.img, rect.Min, draw.Src)
+ return Frame{
+ X: rect.Min.X,
+ Y: rect.Min.Y,
+ Width: rect.Dx(),
+ Height: rect.Dy(),
+ RGBA: append([]byte(nil), crop.Pix...),
+ }, true
+}
+
+func bitmapToRGBA(b Bitmap) *image.RGBA {
+ img := image.NewRGBA(image.Rect(0, 0, b.Width, b.Height))
+ pix := img.Pix
+ switch b.BitsPerPixel {
+ case 1, 2:
+ n := b.Width * b.Height
+ for i := 0; i < n && i*2+1 < len(b.Data); i++ {
+ // grdp 的 RLE 输出 16bpp 使用大端 RGB565,按小端会导致蓝紫/绿色偏色。 // Logan.Li
+ c := binary.BigEndian.Uint16(b.Data[i*2 : i*2+2])
+ r := uint8((c >> 11) & 0x1F)
+ g := uint8((c >> 5) & 0x3F)
+ bl := uint8(c & 0x1F)
+ off := i * 4
+ pix[off] = (r << 3) | (r >> 2)
+ pix[off+1] = (g << 2) | (g >> 4)
+ pix[off+2] = (bl << 3) | (bl >> 2)
+ pix[off+3] = 0xFF
+ }
+ default:
+ stride := b.BitsPerPixel
+ n := b.Width * b.Height
+ for i := 0; i < n; i++ {
+ s := i * stride
+ if s+2 >= len(b.Data) {
+ break
+ }
+ off := i * 4
+ pix[off] = b.Data[s+2]
+ pix[off+1] = b.Data[s+1]
+ pix[off+2] = b.Data[s]
+ pix[off+3] = 0xFF
+ }
+ }
+ return img
+}
diff --git a/backend/pkg/rdp/domain/types.go b/backend/pkg/rdp/domain/types.go
new file mode 100644
index 0000000..df19d44
--- /dev/null
+++ b/backend/pkg/rdp/domain/types.go
@@ -0,0 +1,126 @@
+package domain
+
+import (
+ "context"
+ "encoding/json"
+ "sync"
+ "time"
+)
+
+// Config 是后端 RDP 渲染引擎的连接配置,凭据只在服务端使用 // Logan.Li
+type Config struct {
+ Host string
+ Port int
+ Username string
+ Password string
+ Domain string
+ Security string
+ Width int
+ Height int
+ MCSMode string
+ Debug bool
+}
+
+// ClientEvent 是浏览器发回后端的输入事件 // Logan.Li
+type ClientEvent struct {
+ Type string `json:"type"`
+ X int `json:"x,omitempty"`
+ Y int `json:"y,omitempty"`
+ Button int `json:"button,omitempty"`
+ DeltaY int `json:"delta_y,omitempty"`
+ Code string `json:"code,omitempty"`
+ Key string `json:"key,omitempty"`
+ Down bool `json:"down,omitempty"`
+ Width int `json:"width,omitempty"`
+ Height int `json:"height,omitempty"`
+ Text string `json:"text,omitempty"`
+}
+
+// ServerFrame 是后端推给浏览器 Canvas 的统一帧协议 // Logan.Li
+type ServerFrame struct {
+ Type string `json:"type"`
+ X int `json:"x,omitempty"`
+ Y int `json:"y,omitempty"`
+ Width int `json:"width,omitempty"`
+ Height int `json:"height,omitempty"`
+ Image string `json:"image,omitempty"`
+ Message string `json:"message,omitempty"`
+ Text string `json:"text,omitempty"`
+ RGBA []byte `json:"-"`
+}
+
+// FrameSink 接收 RDP 引擎输出,控制器负责写入 WebSocket // Logan.Li
+type FrameSink interface {
+ SendFrame(ServerFrame) error
+}
+
+// Engine 隔离自研 RDP 协议实现,避免控制器关心握手和图形帧细节 // Logan.Li
+type Engine interface {
+ Run(ctx context.Context, cfg Config, sink FrameSink, events <-chan ClientEvent) error
+}
+
+// HandshakeInfo 记录轻量 RDP 第一阶段握手结果,用于前端诊断 // Logan.Li
+type HandshakeInfo struct {
+ Address string
+ SelectedProtocol string
+ Duration time.Duration
+ TLSCipher string
+ CertSubject string
+}
+
+// MemoryRecorder 保存图形帧摘要,后续可替换为 PNG/JPEG 序列录屏 // Logan.Li
+type MemoryRecorder struct {
+ mu sync.Mutex
+ events []ServerFrame
+ limit int
+}
+
+// NewMemoryRecorder 创建内存帧记录器,限制录屏摘要常驻内存大小 // Logan.Li
+func NewMemoryRecorder(limit int) *MemoryRecorder {
+ if limit <= 0 {
+ limit = 500
+ }
+ return &MemoryRecorder{limit: limit}
+}
+
+// Append 记录一帧 RDP 输出,超过上限时丢弃最早帧 // Logan.Li
+func (r *MemoryRecorder) Append(frame ServerFrame) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ // 实时帧只用于 WebSocket 推送,审计内存索引保留元数据避免拖动窗口时触发大对象 GC。 // Logan.Li
+ frame.RGBA = nil
+ if frame.Type == "bitmap" {
+ frame.Image = ""
+ }
+ if len(r.events) >= r.limit {
+ copy(r.events, r.events[1:])
+ r.events[len(r.events)-1] = frame
+ return
+ }
+ r.events = append(r.events, frame)
+}
+
+// AppendSnapshot 记录节流后的图形快照,用于 RDP 历史回放。 // Logan.Li
+func (r *MemoryRecorder) AppendSnapshot(frame ServerFrame) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ frame.RGBA = nil
+ if len(r.events) >= r.limit {
+ copy(r.events, r.events[1:])
+ r.events[len(r.events)-1] = frame
+ return
+ }
+ r.events = append(r.events, frame)
+}
+
+// JSON 输出前端回放需要的轻量帧序列 // Logan.Li
+func (r *MemoryRecorder) JSON() string {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ b, _ := json.Marshal(struct {
+ Version int `json:"version"`
+ Time time.Time `json:"time"`
+ Frames []ServerFrame `json:"frames"`
+ }{Version: 1, Time: time.Now(), Frames: r.events})
+ return string(b)
+}
diff --git a/backend/pkg/rdp/engine.go b/backend/pkg/rdp/engine.go
new file mode 100644
index 0000000..2d1d352
--- /dev/null
+++ b/backend/pkg/rdp/engine.go
@@ -0,0 +1,46 @@
+package rdp
+
+import (
+ "ezops/pkg/rdp/domain"
+ "log"
+)
+
+// Config 是后端 RDP 渲染引擎的连接配置,根包保留别名兼容控制器 // Logan.Li
+type Config = domain.Config
+
+// ClientEvent 是浏览器发回后端的输入事件,根包保留别名兼容控制器 // Logan.Li
+type ClientEvent = domain.ClientEvent
+
+// ServerFrame 是后端推给浏览器 Canvas 的统一帧协议,根包保留别名兼容控制器 // Logan.Li
+type ServerFrame = domain.ServerFrame
+
+// FrameSink 接收 RDP 引擎输出,根包保留别名兼容控制器 // Logan.Li
+type FrameSink = domain.FrameSink
+
+// Engine 隔离自研 RDP 协议实现,根包保留别名兼容控制器 // Logan.Li
+type Engine = domain.Engine
+
+// HandshakeInfo 记录轻量 RDP 第一阶段握手结果,根包保留别名兼容诊断入口 // Logan.Li
+type HandshakeInfo = domain.HandshakeInfo
+
+// MemoryRecorder 保存图形帧摘要,根包保留别名兼容审计回放 // Logan.Li
+type MemoryRecorder = domain.MemoryRecorder
+
+const EngineRDP = "rdp"
+
+// SelectedEngineName 返回当前 RDP 引擎名称,固定使用 AIOps 自研 RDP 引擎。 // Logan.Li
+func SelectedEngineName() string {
+ return EngineRDP
+}
+
+// NewEngine 创建自研 RDP 图形引擎,避免依赖第三方协议栈。 // Logan.Li
+func NewEngine() Engine {
+ name := SelectedEngineName()
+ log.Printf("[RDP] selected engine=%s", name)
+ return &SessionEngine{}
+}
+
+// NewMemoryRecorder 创建内存帧记录器,根包保留构造函数兼容审计回放 // Logan.Li
+func NewMemoryRecorder(limit int) *MemoryRecorder {
+ return domain.NewMemoryRecorder(limit)
+}
diff --git a/backend/pkg/rdp/fastpath_recv.go b/backend/pkg/rdp/fastpath_recv.go
new file mode 100644
index 0000000..f2d1e1b
--- /dev/null
+++ b/backend/pkg/rdp/fastpath_recv.go
@@ -0,0 +1,264 @@
+package rdp
+
+import (
+ "bytes"
+ "fmt"
+
+ "ezops/pkg/rdp/trace"
+)
+
+const (
+ fastPathOutputCompressionUsed = 0xC0
+ fastPathFragmentSingle = 0x00
+ fastPathFragmentLast = 0x10
+ fastPathFragmentFirst = 0x20
+ fastPathFragmentNext = 0x30
+)
+
+// FastPathReceiver 解析服务端 Fast-Path 更新并产出位图块 // Logan.Li
+type FastPathReceiver struct {
+ mppc *MppcDecompressor
+ fragments bytes.Buffer
+ frameIDs []uint32
+ updateStats map[byte]int
+}
+
+func NewFastPathReceiver() *FastPathReceiver {
+ return &FastPathReceiver{mppc: NewMppcDecompressor(), updateStats: make(map[byte]int)}
+}
+
+func (f *FastPathReceiver) Handle(secFlag byte, payload []byte) ([]BitmapRect, error) {
+ _ = secFlag
+ r := bytes.NewReader(payload)
+ var rects []BitmapRect
+ for r.Len() > 0 {
+ updateHeader, err := readU8(r)
+ if err != nil {
+ return rects, err
+ }
+ updateCode := updateHeader & 0x0f
+ fragmentation := updateHeader & 0x30
+ compression := updateHeader & 0xC0
+ f.updateStats[updateCode]++
+
+ var compressionFlags byte
+ if compression == fastPathOutputCompressionUsed {
+ compressionFlags, err = readU8(r)
+ if err != nil {
+ return rects, err
+ }
+ }
+ size, err := readLE16(r)
+ if err != nil {
+ return rects, err
+ }
+ if f.updateStats[updateCode] <= 8 || f.updateStats[updateCode]%100 == 0 {
+ trace.Logf("fastpath update code=%s(0x%x) count=%d size=%d frag=0x%02x compression=0x%02x flags=0x%02x", fastPathUpdateName(updateCode), updateCode, f.updateStats[updateCode], size, fragmentation, compression, compressionFlags)
+ }
+ block := make([]byte, int(size))
+ if _, err = r.Read(block); err != nil {
+ return rects, err
+ }
+
+ if fragmentation != fastPathFragmentSingle {
+ if fragmentation == fastPathFragmentFirst {
+ f.fragments.Reset()
+ }
+ f.fragments.Write(block)
+ if fragmentation != fastPathFragmentLast {
+ continue
+ }
+ block = append([]byte(nil), f.fragments.Bytes()...)
+ }
+ if compressionFlags != 0 {
+ decompressed, err := f.mppc.Decompress(compressionFlags, block)
+ if err != nil {
+ continue
+ }
+ block = decompressed
+ }
+
+ switch updateCode {
+ case fastPathUpdateBitmap:
+ bs, err := parseFastPathBitmap(block)
+ if err != nil {
+ return rects, err
+ }
+ rects = append(rects, bs...)
+ case fastPathUpdateSurfaceCmds:
+ bs, frameIDs, err := parseSurfaceCommands(block)
+ if err != nil {
+ return rects, err
+ }
+ f.frameIDs = append(f.frameIDs, frameIDs...)
+ rects = append(rects, bs...)
+ default:
+ trace.Logf("fastpath skip update code=%s(0x%x) size=%d", fastPathUpdateName(updateCode), updateCode, len(block))
+ }
+ }
+ return rects, nil
+}
+
+func fastPathUpdateName(code byte) string {
+ switch code {
+ case 0x0:
+ return "orders"
+ case fastPathUpdateBitmap:
+ return "bitmap"
+ case 0x2:
+ return "palette"
+ case 0x3:
+ return "synchronize"
+ case fastPathUpdateSurfaceCmds:
+ return "surface"
+ case 0x5:
+ return "ptr_null"
+ case 0x6:
+ return "ptr_default"
+ case 0x8:
+ return "ptr_position"
+ case 0x9:
+ return "color"
+ case 0x0a:
+ return "ptr_cached"
+ case 0x0b:
+ return "pointer"
+ case 0x0c:
+ return "large_pointer"
+ default:
+ return "unknown"
+ }
+}
+
+// TakeFrameIDs 取出需要确认的 Surface frame,避免服务端因队列满停止推帧 // Logan.Li
+func (f *FastPathReceiver) TakeFrameIDs() []uint32 {
+ if len(f.frameIDs) == 0 {
+ return nil
+ }
+ out := append([]uint32(nil), f.frameIDs...)
+ f.frameIDs = f.frameIDs[:0]
+ return out
+}
+
+func (f *FastPathReceiver) HandleSlowPath(payload []byte) ([]BitmapRect, error) {
+ ind, err := parseMCSDataIndication(payload)
+ if err != nil {
+ return nil, err
+ }
+ if len(ind.payload) < 6 {
+ return nil, nil
+ }
+ return parseSlowPathBitmap(ind.payload[4:])
+}
+
+func parseSurfaceCommands(data []byte) ([]BitmapRect, []uint32, error) {
+ r := bytes.NewReader(data)
+ var out []BitmapRect
+ var frameIDs []uint32
+ for r.Len() >= 2 {
+ cmdType, err := readLE16(r)
+ if err != nil {
+ return out, frameIDs, err
+ }
+ switch cmdType {
+ case 0x0001, 0x0006:
+ wb, err := readSurfaceBits(r)
+ if err != nil {
+ return out, frameIDs, err
+ }
+ if rect, ok := decodeWireBitmap(wb); ok {
+ out = append(out, rect)
+ }
+ case 0x0004:
+ frameAction, err := readLE16(r)
+ if err != nil {
+ return out, frameIDs, err
+ }
+ frameID, err := readLE32(r)
+ if err != nil {
+ return out, frameIDs, err
+ }
+ trace.Logf("surface frame marker action=%d frameID=%d", frameAction, frameID)
+ if frameAction == 0x0001 {
+ frameIDs = append(frameIDs, frameID)
+ }
+ default:
+ return out, frameIDs, fmt.Errorf("未知 SurfaceCmd: 0x%04x", cmdType)
+ }
+ }
+ return out, frameIDs, nil
+}
+
+func readSurfaceBits(r *bytes.Reader) (wireBitmap, error) {
+ destLeft, err := readLE16(r)
+ if err != nil {
+ return wireBitmap{}, err
+ }
+ destTop, err := readLE16(r)
+ if err != nil {
+ return wireBitmap{}, err
+ }
+ destRight, err := readLE16(r)
+ if err != nil {
+ return wireBitmap{}, err
+ }
+ destBottom, err := readLE16(r)
+ if err != nil {
+ return wireBitmap{}, err
+ }
+ bpp, err := readU8(r)
+ if err != nil {
+ return wireBitmap{}, err
+ }
+ flags, err := readU8(r)
+ if err != nil {
+ return wireBitmap{}, err
+ }
+ if _, err = readU8(r); err != nil {
+ return wireBitmap{}, err
+ }
+ codecID, err := readU8(r)
+ if err != nil {
+ return wireBitmap{}, err
+ }
+ width, err := readLE16(r)
+ if err != nil {
+ return wireBitmap{}, err
+ }
+ height, err := readLE16(r)
+ if err != nil {
+ return wireBitmap{}, err
+ }
+ dataLen, err := readLE32(r)
+ if err != nil {
+ return wireBitmap{}, err
+ }
+ if flags&0x01 != 0 {
+ if dataLen < 24 {
+ return wireBitmap{}, fmt.Errorf("SurfaceBits 扩展头长度非法: %d", dataLen)
+ }
+ if _, err = r.Seek(24, 1); err != nil {
+ return wireBitmap{}, err
+ }
+ dataLen -= 24
+ }
+ data := make([]byte, int(dataLen))
+ if _, err = r.Read(data); err != nil {
+ return wireBitmap{}, err
+ }
+ if codecID != 0 {
+ trace.Logf("surface skip unsupported codec=%d rect=%dx%d@%d,%d data=%d flags=0x%02x", codecID, width, height, destLeft, destTop, dataLen, flags)
+ return wireBitmap{}, nil
+ }
+ trace.Logf("surface bits codec=%d rect=%dx%d@%d,%d data=%d flags=0x%02x", codecID, width, height, destLeft, destTop, dataLen, flags)
+ return wireBitmap{
+ DestLeft: destLeft, DestTop: destTop, DestRight: destRight, DestBottom: destBottom,
+ Width: width, Height: height, BitsPerPixel: uint16(bpp), Flags: uint16(flags) | bitmapNoProcessing,
+ BitmapDataStream: data,
+ }, nil
+}
+
+func readU8(r *bytes.Reader) (byte, error) {
+ b, err := r.ReadByte()
+ return b, err
+}
diff --git a/backend/pkg/rdp/input_fastpath.go b/backend/pkg/rdp/input_fastpath.go
new file mode 100644
index 0000000..195f9a2
--- /dev/null
+++ b/backend/pkg/rdp/input_fastpath.go
@@ -0,0 +1,161 @@
+package rdp
+
+import (
+ "io"
+ "sync"
+ "time"
+ "unicode/utf16"
+)
+
+const (
+ fastPathInputEventScancode = 0
+ fastPathInputEventMouse = 1
+ fastPathInputEventUnicode = 4
+
+ fastPathInputKBRelease = 0x01
+ fastPathInputKBExtended = 0x02
+ fastPathInputKBExtended1 = 0x04
+
+ ptrFlagsMove = 0x0800
+ ptrFlagsDown = 0x8000
+ ptrFlagsBtn1 = 0x1000
+ ptrFlagsBtn2 = 0x2000
+ ptrFlagsBtn3 = 0x4000
+ ptrFlagsWheel = 0x0200
+)
+
+// InputSender 通过 Fast-Path Input 向远端发送键鼠事件 // Logan.Li
+type InputSender struct {
+ w io.Writer
+ mu *sync.Mutex
+}
+
+func NewInputSender(w io.Writer) *InputSender {
+ return &InputSender{w: w}
+}
+
+func NewInputSenderWithLock(w io.Writer, mu *sync.Mutex) *InputSender {
+ return &InputSender{w: w, mu: mu}
+}
+
+func (s *InputSender) Send(events []byte) error {
+ if s.mu != nil {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ }
+ return WriteFastPathInput(s.w, events)
+}
+
+func (s *InputSender) Key(scancode int, down bool) error {
+ flags := byte(0)
+ if !down {
+ flags |= fastPathInputKBRelease
+ }
+ code := uint16(scancode)
+ if scancode&0xFF00 == 0xE000 {
+ flags |= fastPathInputKBExtended
+ code = uint16(scancode & 0xFF)
+ }
+ buf := []byte{byte(fastPathInputEventScancode<<5) | flags}
+ buf = append(buf, byte(code))
+ return s.Send(buf)
+}
+
+func (s *InputSender) MouseMove(x, y int) error {
+ return s.mouseEvent(ptrFlagsMove, x, y)
+}
+
+func (s *InputSender) MouseButton(x, y, button int, down bool) error {
+ flags := ptrFlagsDown
+ // 浏览器 MouseEvent.button 使用 0/1/2 表示左/中/右键,RDP BTN2 才是右键。 // Logan.Li
+ switch button {
+ case 2:
+ flags |= ptrFlagsBtn2
+ case 1:
+ flags |= ptrFlagsBtn3
+ default:
+ flags |= ptrFlagsBtn1
+ }
+ if !down {
+ flags &^= ptrFlagsDown
+ }
+ return s.mouseEvent(flags, x, y)
+}
+
+func (s *InputSender) MouseWheel(x, y, deltaY int) error {
+ scroll := -deltaY * 120 / 100
+ if scroll == 0 && deltaY != 0 {
+ if deltaY > 0 {
+ scroll = -120
+ } else {
+ scroll = 120
+ }
+ }
+ flags := uint16(ptrFlagsWheel | 0x0001)
+ if scroll < 0 {
+ flags |= 0x0100
+ scroll = -scroll
+ }
+ buf := []byte{byte(fastPathInputEventMouse << 5)}
+ buf = append(buf, byte(flags), byte(flags>>8))
+ buf = append(buf, byte(x), byte(x>>8), byte(y), byte(y>>8))
+ buf = append(buf, byte(scroll), byte(scroll>>8))
+ return s.Send(buf)
+}
+
+func (s *InputSender) mouseEvent(flags int, x, y int) error {
+ buf := []byte{byte(fastPathInputEventMouse << 5)}
+ buf = append(buf, byte(flags), byte(flags>>8))
+ buf = append(buf, byte(x), byte(x>>8), byte(y), byte(y>>8))
+ return s.Send(buf)
+}
+
+func (s *InputSender) TypeText(text string) error {
+ for _, r := range utf16.Encode([]rune(text)) {
+ if err := s.unicodeKey(uint16(r), true); err != nil {
+ return err
+ }
+ if err := s.unicodeKey(uint16(r), false); err != nil {
+ return err
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ return nil
+}
+
+func (s *InputSender) unicodeKey(code uint16, down bool) error {
+ flags := byte(0)
+ if !down {
+ flags |= fastPathInputKBRelease
+ }
+ buf := []byte{byte(fastPathInputEventUnicode<<5) | flags, byte(code), byte(code >> 8)}
+ return s.Send(buf)
+}
+
+func (s *InputSender) AutoExec(command string) error {
+ if err := s.Key(0xE05B, true); err != nil {
+ return err
+ }
+ if err := s.Key(0xE05B, false); err != nil {
+ return err
+ }
+ time.Sleep(80 * time.Millisecond)
+ if err := s.Key(0xE05B, true); err != nil {
+ return err
+ }
+ if err := s.Key(0x0015, true); err != nil {
+ return err
+ }
+ if err := s.Key(0x0015, false); err != nil {
+ return err
+ }
+ if err := s.Key(0xE05B, false); err != nil {
+ return err
+ }
+ time.Sleep(200 * time.Millisecond)
+ if err := s.TypeText(command); err != nil {
+ return err
+ }
+ time.Sleep(80 * time.Millisecond)
+ return s.Key(0x001C, true)
+}
diff --git a/backend/pkg/rdp/keymap.go b/backend/pkg/rdp/keymap.go
new file mode 100644
index 0000000..49e60c0
--- /dev/null
+++ b/backend/pkg/rdp/keymap.go
@@ -0,0 +1,121 @@
+package rdp
+
+// ScanCodeFromJS 将浏览器 KeyboardEvent.code 转为 RDP 扫描码 // Logan.Li
+func ScanCodeFromJS(code string) (int, bool) {
+ sc, ok := jsScanCodes[code]
+ return sc, ok
+}
+
+// jsScanCodes 将浏览器 KeyboardEvent.code 映射为 RDP 扫描码;扩展键使用 0xE0xx 约定 // Logan.Li
+var jsScanCodes = map[string]int{
+ "Escape": 0x0001,
+ "Digit1": 0x0002,
+ "Digit2": 0x0003,
+ "Digit3": 0x0004,
+ "Digit4": 0x0005,
+ "Digit5": 0x0006,
+ "Digit6": 0x0007,
+ "Digit7": 0x0008,
+ "Digit8": 0x0009,
+ "Digit9": 0x000A,
+ "Digit0": 0x000B,
+ "Minus": 0x000C,
+ "Equal": 0x000D,
+ "Backspace": 0x000E,
+ "Tab": 0x000F,
+ "KeyQ": 0x0010,
+ "KeyW": 0x0011,
+ "KeyE": 0x0012,
+ "KeyR": 0x0013,
+ "KeyT": 0x0014,
+ "KeyY": 0x0015,
+ "KeyU": 0x0016,
+ "KeyI": 0x0017,
+ "KeyO": 0x0018,
+ "KeyP": 0x0019,
+ "BracketLeft": 0x001A,
+ "BracketRight": 0x001B,
+ "Enter": 0x001C,
+ "ControlLeft": 0x001D,
+ "KeyA": 0x001E,
+ "KeyS": 0x001F,
+ "KeyD": 0x0020,
+ "KeyF": 0x0021,
+ "KeyG": 0x0022,
+ "KeyH": 0x0023,
+ "KeyJ": 0x0024,
+ "KeyK": 0x0025,
+ "KeyL": 0x0026,
+ "Semicolon": 0x0027,
+ "Quote": 0x0028,
+ "Backquote": 0x0029,
+ "ShiftLeft": 0x002A,
+ "Backslash": 0x002B,
+ "KeyZ": 0x002C,
+ "KeyX": 0x002D,
+ "KeyC": 0x002E,
+ "KeyV": 0x002F,
+ "KeyB": 0x0030,
+ "KeyN": 0x0031,
+ "KeyM": 0x0032,
+ "Comma": 0x0033,
+ "Period": 0x0034,
+ "Slash": 0x0035,
+ "ShiftRight": 0x0036,
+ "NumpadMultiply": 0x0037,
+ "AltLeft": 0x0038,
+ "Space": 0x0039,
+ "CapsLock": 0x003A,
+ "F1": 0x003B,
+ "F2": 0x003C,
+ "F3": 0x003D,
+ "F4": 0x003E,
+ "F5": 0x003F,
+ "F6": 0x0040,
+ "F7": 0x0041,
+ "F8": 0x0042,
+ "F9": 0x0043,
+ "F10": 0x0044,
+ "ScrollLock": 0x0046,
+ "Numpad7": 0x0047,
+ "Numpad8": 0x0048,
+ "Numpad9": 0x0049,
+ "NumpadSubtract": 0x004A,
+ "Numpad4": 0x004B,
+ "Numpad5": 0x004C,
+ "Numpad6": 0x004D,
+ "NumpadAdd": 0x004E,
+ "Numpad1": 0x004F,
+ "Numpad2": 0x0050,
+ "Numpad3": 0x0051,
+ "Numpad0": 0x0052,
+ "NumpadDecimal": 0x0053,
+ "F11": 0x0057,
+ "F12": 0x0058,
+ "NumpadEqual": 0x0059,
+ "NumpadEnter": 0xE01C,
+ "ControlRight": 0xE01D,
+ "NumpadDivide": 0xE035,
+ "PrintScreen": 0xE037,
+ "AltRight": 0xE038,
+ "NumLock": 0xE045,
+ "Pause": 0xE046,
+ "Home": 0xE047,
+ "ArrowUp": 0xE048,
+ "PageUp": 0xE049,
+ "ArrowLeft": 0xE04B,
+ "ArrowRight": 0xE04D,
+ "End": 0xE04F,
+ "ArrowDown": 0xE050,
+ "PageDown": 0xE051,
+ "Insert": 0xE052,
+ "Delete": 0xE053,
+ "ContextMenu": 0xE05D,
+ "MetaLeft": 0xE05B,
+ "MetaRight": 0xE05C,
+ "IntlRo": 0x0073,
+ "KanaMode": 0x0070,
+ "IntlYen": 0x007D,
+ "Convert": 0x0079,
+ "NonConvert": 0x007B,
+}
diff --git a/backend/pkg/rdp/mcs_probe.go b/backend/pkg/rdp/mcs_probe.go
new file mode 100644
index 0000000..77690ae
--- /dev/null
+++ b/backend/pkg/rdp/mcs_probe.go
@@ -0,0 +1,1664 @@
+package rdp
+
+import (
+ "bytes"
+ "context"
+ "encoding/binary"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "time"
+ "unicode/utf16"
+
+ "ezops/pkg/rdp/credssp"
+ "ezops/pkg/rdp/transport"
+)
+
+const (
+ mcsConnectInitial = 0x65
+ mcsConnectResponse = 0x66
+
+ mcsErectDomainRequest = 1
+ mcsAttachUserRequest = 10
+ mcsAttachUserConfirm = 11
+ mcsChannelJoinRequest = 14
+ mcsChannelJoinConfirm = 15
+ mcsSendDataRequest = 25
+ mcsSendDataIndication = 26
+ mcsGlobalChannelID = 1003
+ mcsUserChannelIDOffset = 1001
+
+ gccCSCore = 0xC001
+ gccCSSecurity = 0xC002
+ gccCSNet = 0xC003
+ gccSCCore = 0x0C01
+ gccSCSecurity = 0x0C02
+ gccSCNet = 0x0C03
+
+ secFlagInfoPkt = 0x0040
+ secFlagLicensePkt = 0x0080
+
+ licenseErrorAlert = 0xff
+ licenseStatusValid = 0x00000007
+ licenseStateNoTransit = 0x00000002
+ pduTypeDemandActive = 0x0011
+ pduTypeConfirmActive = 0x0013
+ pduTypeDeactivateAll = 0x0016
+ pduTypeData = 0x0017
+ pduTypeServerRedirect = 0x001a
+ pduType2Synchronize = 0x1f
+ pduType2Control = 0x14
+ pduType2FontList = 0x27
+ pduType2FontMap = 0x28
+ pduType2RefreshRect = 0x21
+ pduType2SetErrorInfo = 0x2f
+ pduType2SuppressOutput = 0x23
+ pduType2SaveSessionInfo = 0x26
+ pduType2FrameAcknowledge = 0x38
+ ctrlActionGrantedControl = 0x0002
+)
+
+var errFinalizeActive = errors.New("RDP 会话已进入图形更新流")
+
+// MCSProbeInfo 记录 NLA 后 MCS/GCC 连接期响应,用于确认图形会话层入口是否跑通 // Logan.Li
+type MCSProbeInfo struct {
+ HandshakeInfo
+ UserDataBytes int
+ ServerCoreVersion uint32
+ ServerChannelID uint16
+ ServerChannelCount uint16
+ ServerChannelIDs []uint16
+ ServerEncryption uint32
+ ServerEncryptionLvl uint32
+ UserChannelID uint16
+ MessageChannelID uint16
+ JoinedChannels []uint16
+ FirstDataChannelID uint16
+ FirstSecurityFlags uint16
+ FirstPayloadBytes int
+ LicenseMessageType uint8
+ LicenseErrorCode uint32
+ LicenseState uint32
+ DemandPDUType uint16
+ DemandSharedID uint32
+ DemandCapabilities uint16
+ DemandSource string
+ PostConfirmTypes []uint8
+}
+
+// ProbeMCSConnect 复用同一条 CredSSP TLS 连接发送 MCS Connect Initial 并解析 Connect Response // Logan.Li
+func ProbeMCSConnect(ctx context.Context, cfg Config) (MCSProbeInfo, error) {
+ port := cfg.Port
+ if port == 0 {
+ port = 3389
+ }
+ address := fmt.Sprintf("%s:%d", cfg.Host, port)
+ start := time.Now()
+ rdpDebugf(cfg, "probe start address=%s mode=%q", address, cfg.MCSMode)
+ strategies := []credssp.Options{
+ {Version: 6, PubKeyStyle: "binding"},
+ {Version: 6, PubKeyStyle: "binding", UseCBT: true},
+ {Version: 6, PubKeyStyle: "binding", ForceMIC: true},
+ {Version: 2, PubKeyStyle: "raw"},
+ }
+ var lastErr error
+ for _, opts := range strategies {
+ rdpDebugf(cfg, "strategy start version=%d pubkey=%s cbt=%v mic=%v", opts.Version, opts.PubKeyStyle, opts.UseCBT, opts.ForceMIC)
+ info, err := probeMCSConnectOnce(ctx, cfg, address, opts)
+ if err == nil {
+ info.Duration = time.Since(start)
+ rdpDebugf(cfg, "probe ok duration=%s joined=%v post=%v", info.Duration.Round(time.Millisecond), info.JoinedChannels, info.PostConfirmTypes)
+ return info, nil
+ }
+ rdpDebugf(cfg, "strategy failed version=%d pubkey=%s err=%v", opts.Version, opts.PubKeyStyle, err)
+ lastErr = err
+ }
+ if lastErr == nil {
+ lastErr = fmt.Errorf("MCS Connect Initial 失败")
+ }
+ return MCSProbeInfo{HandshakeInfo: HandshakeInfo{Address: address, Duration: time.Since(start)}}, lastErr
+}
+
+func probeMCSConnectOnce(ctx context.Context, cfg Config, address string, opts credssp.Options) (MCSProbeInfo, error) {
+ start := time.Now()
+ tlsConn, result, err := dialCredSSPOnce(ctx, cfg, address, opts)
+ if err != nil {
+ return MCSProbeInfo{}, err
+ }
+ defer tlsConn.Close()
+ rdpDebugf(cfg, "credssp ok protocol=%s elapsed=%s", result.ProtocolName(), time.Since(start).Round(time.Millisecond))
+ _ = tlsConn.SetDeadline(time.Now().Add(30 * time.Second))
+ rdpDebugf(cfg, "send MCS ConnectInitial")
+ if err := transport.WriteX224Data(tlsConn, buildMCSConnectInitial(cfg)); err != nil {
+ return MCSProbeInfo{}, err
+ }
+ payload, err := transport.ReadX224Data(tlsConn)
+ if err != nil {
+ return MCSProbeInfo{}, err
+ }
+ if cfg.Debug {
+ debugGCCServerBlocks(cfg, payload)
+ }
+ mcsInfo, err := parseMCSConnectResponse(payload)
+ if err != nil {
+ return MCSProbeInfo{}, err
+ }
+ rdpDebugf(cfg, "connect response userData=%d channel=%d channels=%v enc=%d/%d", mcsInfo.UserDataBytes, mcsInfo.ServerChannelID, mcsInfo.ServerChannelIDs, mcsInfo.ServerEncryption, mcsInfo.ServerEncryptionLvl)
+ userID, err := attachMCSUser(tlsConn)
+ if err != nil {
+ return MCSProbeInfo{}, err
+ }
+ mcsInfo.UserChannelID = userID
+ rdpDebugf(cfg, "attach user ok userID=%d", userID)
+ joinIDs := append([]uint16{mcsGlobalChannelID, userID}, mcsInfo.ServerChannelIDs...)
+ if mcsInfo.MessageChannelID != 0 {
+ joinIDs = append(joinIDs, mcsInfo.MessageChannelID)
+ }
+ for _, channelID := range joinIDs {
+ joined, err := joinMCSChannel(tlsConn, userID, channelID)
+ if err != nil {
+ return MCSProbeInfo{}, err
+ }
+ mcsInfo.JoinedChannels = append(mcsInfo.JoinedChannels, joined)
+ rdpDebugf(cfg, "join channel ok channel=%d", joined)
+ }
+ first, demand, err := sendClientInfoAndReadDemand(tlsConn, cfg, userID)
+ if err != nil {
+ return MCSProbeInfo{}, err
+ }
+ mcsInfo.FirstDataChannelID = first.channelID
+ mcsInfo.FirstSecurityFlags = first.securityFlags
+ mcsInfo.FirstPayloadBytes = len(first.payload)
+ mcsInfo.LicenseMessageType = first.licenseMsgType
+ mcsInfo.LicenseErrorCode = first.licenseErrorCode
+ mcsInfo.LicenseState = first.licenseState
+ mcsInfo.DemandPDUType = demand.pduType
+ mcsInfo.DemandSharedID = demand.sharedID
+ mcsInfo.DemandCapabilities = demand.capabilityCount
+ mcsInfo.DemandSource = string(demand.sourceDescriptor)
+ rdpDebugf(cfg, "demand active shared=0x%08x caps=%d source=%q firstSecurity=0x%04x firstPayload=%d", demand.sharedID, demand.capabilityCount, string(demand.sourceDescriptor), first.securityFlags, len(first.payload))
+ postTypes, err := sendConfirmActiveAndFinalize(tlsConn, cfg, userID, demand)
+ if err != nil {
+ return MCSProbeInfo{}, fmt.Errorf("Client Finalize 失败: shared=0x%08x caps=%d source=%q: %w", demand.sharedID, demand.capabilityCount, string(demand.sourceDescriptor), err)
+ }
+ mcsInfo.PostConfirmTypes = postTypes
+ state := tlsConn.ConnectionState()
+ certSubject := ""
+ if len(state.PeerCertificates) > 0 {
+ certSubject = state.PeerCertificates[0].Subject.String()
+ }
+ mcsInfo.HandshakeInfo = HandshakeInfo{
+ Address: address,
+ SelectedProtocol: result.ProtocolName(),
+ TLSCipher: transport.TLSCipherSuiteName(state.CipherSuite),
+ CertSubject: certSubject,
+ }
+ return mcsInfo, nil
+}
+
+func debugGCCServerBlocks(cfg Config, mcsPayload []byte) {
+ info, err := parseMCSConnectResponse(mcsPayload)
+ if err != nil {
+ rdpDebugf(cfg, "debug gcc parse failed err=%v", err)
+ return
+ }
+ rdpDebugf(cfg, "debug gcc summary userData=%d core=0x%08x scNet=%d msg=%d", info.UserDataBytes, info.ServerCoreVersion, info.ServerChannelID, info.MessageChannelID)
+}
+
+type mcsDataIndication struct {
+ channelID uint16
+ securityFlags uint16
+ payload []byte
+ licenseMsgType uint8
+ licenseErrorCode uint32
+ licenseState uint32
+}
+
+type demandActiveProbe struct {
+ pduType uint16
+ sharedID uint32
+ capabilityCount uint16
+ sourceDescriptor []byte
+}
+
+func sendClientInfoAndReadDemand(rw io.ReadWriter, cfg Config, userID uint16) (mcsDataIndication, demandActiveProbe, error) {
+ infoPDU := buildClientInfoPDU(cfg)
+ rdpDebugf(cfg, "send ClientInfo userID=%d bytes=%d", userID, len(infoPDU))
+ if err := transport.WriteX224Data(rw, buildMCSSendDataRequest(userID, mcsGlobalChannelID, infoPDU)); err != nil {
+ return mcsDataIndication{}, demandActiveProbe{}, err
+ }
+ payload, err := transport.ReadX224Data(rw)
+ if err != nil {
+ return mcsDataIndication{}, demandActiveProbe{}, err
+ }
+ first, err := parseMCSDataIndication(payload)
+ if err != nil {
+ return mcsDataIndication{}, demandActiveProbe{}, err
+ }
+ if first.securityFlags&secFlagLicensePkt != 0 {
+ rdpDebugf(cfg, "license packet security=0x%04x payload=%d", first.securityFlags, len(first.payload))
+ if err := parseLicensePacket(&first); err != nil {
+ return first, demandActiveProbe{}, err
+ }
+ payload, err = transport.ReadX224Data(rw)
+ if err != nil {
+ return first, demandActiveProbe{}, err
+ }
+ }
+ next, err := parseMCSDataIndication(payload)
+ if err != nil {
+ return first, demandActiveProbe{}, err
+ }
+ demand, err := parseDemandActiveProbe(next.payload)
+ if err != nil {
+ return first, demandActiveProbe{}, err
+ }
+ return first, demand, nil
+}
+
+func sendConfirmActiveAndFinalize(rw io.ReadWriter, cfg Config, userID uint16, demand demandActiveProbe) ([]uint8, error) {
+ rdpDebugf(cfg, "finalize start userID=%d shared=0x%08x", userID, demand.sharedID)
+ pdus := []struct {
+ name string
+ data []byte
+ }{
+ {"ConfirmActive", buildConfirmActivePDU(cfg, userID, demand)},
+ {"Synchronize", buildDataPDU(userID, demand.sharedID, pduType2Synchronize, buildSynchronizeData(userID))},
+ {"ControlCooperate", buildDataPDU(userID, demand.sharedID, pduType2Control, buildControlData(0x0004))},
+ {"ControlRequest", buildDataPDU(userID, demand.sharedID, pduType2Control, buildControlData(0x0001))},
+ {"FontList", buildDataPDU(userID, demand.sharedID, pduType2FontList, buildFontListData())},
+ }
+ var types []uint8
+ for _, pdu := range pdus {
+ rdpDebugf(cfg, "finalize send %s bytes=%d", pdu.name, len(pdu.data))
+ if err := transport.WriteX224Data(rw, buildMCSSendDataRequest(userID, mcsGlobalChannelID, pdu.data)); err != nil {
+ return nil, fmt.Errorf("发送 %s 失败: %w", pdu.name, err)
+ }
+ if isRDPStepMode(cfg) {
+ if stepTypes, err := readFinalizeStep(rw, pdu.name, 500*time.Millisecond); err != nil {
+ return append(types, stepTypes...), err
+ } else if len(stepTypes) > 0 {
+ types = append(types, stepTypes...)
+ }
+ }
+ }
+ readTypes, err := waitFinalizeResponses(rw, cfg, 5*time.Second)
+ if err != nil {
+ return types, err
+ }
+ types = append(types, readTypes...)
+ return types, nil
+}
+
+func isRDPStepMode(cfg Config) bool {
+ return cfg.MCSMode == "step" || cfg.MCSMode == "step-legacy" || cfg.MCSMode == "step-bpp32" || cfg.MCSMode == "step-minimal" || cfg.MCSMode == "step-plain-info" || cfg.MCSMode == "step-grdp-comment" || cfg.MCSMode == "step-grdp-modern"
+}
+
+func readFinalizeStep(rw io.ReadWriter, name string, timeout time.Duration) ([]uint8, error) {
+ conn, ok := rw.(interface{ SetReadDeadline(time.Time) error })
+ if !ok {
+ return nil, nil
+ }
+ _ = conn.SetReadDeadline(time.Now().Add(timeout))
+ defer conn.SetReadDeadline(time.Time{})
+ payload, err := transport.ReadX224Data(rw)
+ if err != nil {
+ return nil, nil
+ }
+ data, err := parseMCSDataIndication(payload)
+ if err != nil {
+ return nil, fmt.Errorf("%s 后响应解析失败: %w", name, err)
+ }
+ got, err := parseShareDataPDUInfo(data.payload)
+ if err != nil {
+ return nil, fmt.Errorf("%s 后 Share Data 解析失败: %w", name, err)
+ }
+ if got.type2 == pduType2SetErrorInfo {
+ return []uint8{got.type2}, fmt.Errorf("%s 后服务端返回 ErrorInfo: code=0x%08x(%s) raw=%s", name, got.errorInfo, rdpErrorInfoName(got.errorInfo), got.rawPrefix)
+ }
+ return []uint8{got.type2}, nil
+}
+
+func waitFinalizeResponses(rw io.ReadWriter, cfg Config, timeout time.Duration) ([]uint8, error) {
+ if conn, ok := rw.(interface{ SetReadDeadline(time.Time) error }); ok {
+ _ = conn.SetReadDeadline(time.Now().Add(timeout))
+ defer conn.SetReadDeadline(time.Time{})
+ }
+ expected := []finalizeExpectation{
+ {type2: pduType2Synchronize},
+ {type2: pduType2Control, action: 0x0004},
+ {type2: pduType2Control, action: ctrlActionGrantedControl},
+ {type2: pduType2FontMap},
+ }
+ var types []uint8
+ for _, want := range expected {
+ for {
+ payload, err := readNextSlowPathPayload(rw, cfg, timeout)
+ if err != nil {
+ if errors.Is(err, errFinalizeActive) || len(types) > 0 {
+ rdpDebugf(cfg, "finalize accept active session types=%v err=%v", types, err)
+ return types, nil
+ }
+ return types, fmt.Errorf("读取 Client Finalize 响应失败: %w", err)
+ }
+ data, err := parseMCSDataIndication(payload)
+ if err != nil {
+ return types, err
+ }
+ got, err := parseShareDataPDUInfo(data.payload)
+ if err != nil {
+ return types, err
+ }
+ types = append(types, got.type2)
+ rdpDebugf(cfg, "finalize recv type2=0x%02x action=0x%04x raw=%s", got.type2, got.controlAction, got.rawPrefix)
+ if got.type2 == pduType2SetErrorInfo {
+ return types, fmt.Errorf("服务端返回 ErrorInfo: code=0x%08x(%s) seen=%v raw=%s", got.errorInfo, rdpErrorInfoName(got.errorInfo), types, got.rawPrefix)
+ }
+ if got.type2 == pduType2SaveSessionInfo {
+ continue
+ }
+ if got.type2 != want.type2 {
+ continue
+ }
+ if want.type2 == pduType2Control && got.controlAction != want.action {
+ continue
+ }
+ break
+ }
+ }
+ return types, nil
+}
+
+func readNextSlowPathPayload(r io.Reader, cfg Config, timeout time.Duration) ([]byte, error) {
+ deadline := time.Now().Add(timeout)
+ seenFastPath := false
+ for {
+ pkt, err := ReadWirePacket(r)
+ if err != nil {
+ if seenFastPath {
+ return nil, errFinalizeActive
+ }
+ return nil, err
+ }
+ if !pkt.FastPath {
+ return pkt.Payload, nil
+ }
+ seenFastPath = true
+ rdpDebugf(cfg, "finalize skip fastpath sec=0x%02x payload=%d", pkt.SecFlag, len(pkt.Payload))
+ if time.Now().After(deadline) {
+ return nil, errFinalizeActive
+ }
+ }
+}
+
+type finalizeExpectation struct {
+ type2 uint8
+ action uint16
+}
+
+func buildConfirmActivePDU(cfg Config, userID uint16, demand demandActiveProbe) []byte {
+ source := demand.sourceDescriptor
+ if len(source) == 0 {
+ source = []byte("rdpy")
+ }
+ body := bytes.Buffer{}
+ writeLE32(&body, demand.sharedID)
+ writeLE16(&body, 0x03ea)
+ writeLE16(&body, uint16(len(source)))
+ caps, capCount := buildConfirmCapabilities(cfg)
+ rdpDebugf(cfg, "build ConfirmActive sourceLen=%d caps=%d capsBytes=%d", len(source), capCount, len(caps))
+ debugCapabilityList(cfg, caps)
+ writeLE16(&body, uint16(len(caps)+4))
+ body.Write(source)
+ writeLE16(&body, capCount)
+ writeLE16(&body, 0)
+ body.Write(caps)
+ pdu := buildShareControlPDU(userID, pduTypeConfirmActive, body.Bytes())
+ prefixLen := len(pdu)
+ if prefixLen > 96 {
+ prefixLen = 96
+ }
+ rdpDebugf(cfg, "ConfirmActive pduLen=%d prefix=%s", len(pdu), hex.EncodeToString(pdu[:prefixLen]))
+ return pdu
+}
+
+func rdpDebugf(cfg Config, format string, args ...interface{}) {
+ if !cfg.Debug {
+ return
+ }
+ _, _ = fmt.Fprintf(os.Stderr, "[rdp] "+format+"\n", args...)
+}
+
+func rdpErrorInfoName(code uint32) string {
+ switch code {
+ case 0x000010EA:
+ return "ERRINFO_BAD_CAPABILITIES"
+ case 0x000010E9:
+ return "ERRINFO_NO_CURSOR_CACHE"
+ case 0x000010E8:
+ return "ERRINFO_CAPABILITY_SET_TOO_LARGE"
+ case 0x000010E7:
+ return "ERRINFO_CAPABILITY_SET_TOO_SMALL"
+ case 0x000010D4:
+ return "ERRINFO_CONFIRM_ACTIVE_HAS_WRONG_SHAREID"
+ case 0x000010D5:
+ return "ERRINFO_CONFIRM_ACTIVE_HAS_WRONG_ORIGINATOR"
+ default:
+ return "UNKNOWN"
+ }
+}
+
+func debugCapabilityList(cfg Config, caps []byte) {
+ if !cfg.Debug {
+ return
+ }
+ offset := 0
+ index := 0
+ for offset+4 <= len(caps) {
+ capType := binary.LittleEndian.Uint16(caps[offset : offset+2])
+ capLen := int(binary.LittleEndian.Uint16(caps[offset+2 : offset+4]))
+ if capLen < 4 || offset+capLen > len(caps) {
+ rdpDebugf(cfg, "cap[%02d] invalid offset=%d type=0x%04x len=%d total=%d", index, offset, capType, capLen, len(caps))
+ return
+ }
+ body := caps[offset+4 : offset+capLen]
+ prefixLen := len(body)
+ if prefixLen > 24 {
+ prefixLen = 24
+ }
+ rdpDebugf(cfg, "cap[%02d] type=0x%04x len=%d body=%s", index, capType, capLen, hex.EncodeToString(body[:prefixLen]))
+ offset += capLen
+ index++
+ }
+ if offset != len(caps) {
+ rdpDebugf(cfg, "cap trailing bytes=%d offset=%d total=%d", len(caps)-offset, offset, len(caps))
+ }
+}
+
+func buildConfirmCapabilities(cfg Config) ([]byte, uint16) {
+ width := cfg.Width
+ if width <= 0 {
+ width = 1280
+ }
+ height := cfg.Height
+ if height <= 0 {
+ height = 800
+ }
+ out := bytes.Buffer{}
+ count := uint16(0)
+ addCapability := func(capType uint16, body []byte) {
+ writeCapability(&out, capType, body)
+ count++
+ }
+ if cfg.MCSMode == "grdp-comment" || cfg.MCSMode == "step-grdp-comment" {
+ return buildGRDPCommentCapabilities(cfg, width, height)
+ }
+ addCapability(0x0001, buildGeneralCapability())
+ bitmap := bytes.Buffer{}
+ preferredBPP := uint16(24)
+ if cfg.MCSMode == "bpp32" || cfg.MCSMode == "step-bpp32" || cfg.MCSMode == "grdp-modern" || cfg.MCSMode == "step-grdp-modern" || cfg.MCSMode == "grdp-raw" || cfg.MCSMode == "step-grdp-raw" {
+ preferredBPP = 32
+ }
+ writeLE16(&bitmap, preferredBPP)
+ writeLE16(&bitmap, 1)
+ writeLE16(&bitmap, 1)
+ writeLE16(&bitmap, 1)
+ writeLE16(&bitmap, uint16(width))
+ writeLE16(&bitmap, uint16(height))
+ writeLE16(&bitmap, 0)
+ writeLE16(&bitmap, 1)
+ if cfg.MCSMode == "grdp-raw" || cfg.MCSMode == "step-grdp-raw" {
+ writeLE16(&bitmap, 0)
+ } else {
+ writeLE16(&bitmap, 1)
+ }
+ writeLE16(&bitmap, 0)
+ writeLE16(&bitmap, 1)
+ writeLE16(&bitmap, 0)
+ addCapability(0x0002, bitmap.Bytes())
+ if cfg.MCSMode == "basic" {
+ addCapability(0x000d, buildInputCapability())
+ return out.Bytes(), count
+ }
+ if cfg.MCSMode == "legacy" || cfg.MCSMode == "step-legacy" {
+ addCapability(0x0003, mustHex("00000000000000000000000000000000010014000000010000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008403000000000000000000"))
+ addCapability(0x0005, mustHex("0000000002000200"))
+ addCapability(0x0007, mustHex("0000000000000000"))
+ addCapability(0x0008, buildPointerCapability(128))
+ addCapability(0x0009, mustHex("00000000"))
+ addCapability(0x000a, mustHex("06000000"))
+ addCapability(0x000c, mustHex("01000000"))
+ addCapability(0x000d, buildInputCapability())
+ addCapability(0x000e, mustHex("01000000"))
+ addCapability(0x000f, mustHex("01000000"))
+ addCapability(0x0010, make([]byte, 48))
+ addCapability(0x0014, mustHex("0000000040060000"))
+ return out.Bytes(), count
+ }
+ if cfg.MCSMode == "minimal" || cfg.MCSMode == "step-minimal" {
+ addCapability(0x0003, mustHex("00000000000000000000000000000000010014000000010000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008403000000000000000000"))
+ addCapability(0x0008, mustHex("00001400"))
+ addCapability(0x000d, buildInputCapability())
+ addCapability(0x000e, mustHex("01000000"))
+ return out.Bytes(), count
+ }
+ if cfg.MCSMode == "grdp-modern" || cfg.MCSMode == "step-grdp-modern" || cfg.MCSMode == "grdp-raw" || cfg.MCSMode == "step-grdp-raw" {
+ addCapability(0x0003, buildGRDPOrderCapability())
+ addCapability(0x0005, mustHex("0000000002000200"))
+ addCapability(0x0007, mustHex("0000000000000000"))
+ addCapability(0x0008, buildPointerCapability(20))
+ addCapability(0x0009, mustHex("00000000"))
+ addCapability(0x000a, mustHex("06000000"))
+ addCapability(0x000c, mustHex("01000000"))
+ addCapability(0x000d, buildInputCapability())
+ addCapability(0x000e, mustHex("01000000"))
+ addCapability(0x000f, mustHex("01000000"))
+ addCapability(0x0010, buildGlyphNoneCapability())
+ addCapability(0x001d, buildBitmapCodecsCapability())
+ addCapability(0x0013, buildBitmapCacheRev2CapabilityWithPersist(2))
+ addCapability(0x0014, mustHex("0000000040060000"))
+ addCapability(0x001a, mustHex("00003f00"))
+ addCapability(0x0017, mustHex("ff000000"))
+ addCapability(0x001b, mustHex("0100"))
+ addCapability(0x0019, mustHex("0100"))
+ addCapability(0x001c, mustHex("5200000000000000"))
+ addCapability(0x001e, mustHex("02000000"))
+ return out.Bytes(), count
+ }
+ addCapability(0x0003, mustHex("00000000000000000000000000000000010014000000010000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008403000000000000000000"))
+ addCapability(0x0008, buildPointerCapability(128))
+ addCapability(0x000c, mustHex("00000000"))
+ addCapability(0x000d, buildInputCapability())
+ addCapability(0x000f, mustHex("00000000"))
+ addCapability(0x0010, make([]byte, 48))
+ addCapability(0x0013, buildBitmapCacheRev2Capability())
+ addCapability(0x0011, mustHex("0000000000000000"))
+ addCapability(0x0014, mustHex("0000000000000000"))
+ addCapability(0x001a, mustHex("00000000"))
+ return out.Bytes(), count
+}
+
+func buildGRDPCommentCapabilities(cfg Config, width int, height int) ([]byte, uint16) {
+ out := bytes.Buffer{}
+ count := uint16(0)
+ addCapability := func(capType uint16, body []byte) {
+ writeCapability(&out, capType, body)
+ count++
+ }
+ addCapability(0x0001, buildGeneralCapability())
+ bitmap := bytes.Buffer{}
+ writeLE16(&bitmap, 24)
+ writeLE16(&bitmap, 1)
+ writeLE16(&bitmap, 1)
+ writeLE16(&bitmap, 1)
+ writeLE16(&bitmap, uint16(width))
+ writeLE16(&bitmap, uint16(height))
+ writeLE16(&bitmap, 0)
+ writeLE16(&bitmap, 1)
+ writeLE16(&bitmap, 1)
+ writeLE16(&bitmap, 0)
+ writeLE16(&bitmap, 1)
+ writeLE16(&bitmap, 0)
+ addCapability(0x0002, bitmap.Bytes())
+ addCapability(0x0003, mustHex("000000000000000000000000000000000000000000000000010014000000010000000a0000000000000000000000000000000000000000000000000000000000000000000000000000000000008403000000000000000000"))
+ addCapability(0x0004, make([]byte, 36))
+ addCapability(0x0008, mustHex("00001400"))
+ addCapability(0x000c, mustHex("00000000"))
+ addCapability(0x000d, buildInputCapability())
+ addCapability(0x000f, mustHex("00000000"))
+ addCapability(0x0010, buildGlyphNoneCapability())
+ addCapability(0x0011, mustHex("0000000000000000"))
+ addCapability(0x0014, mustHex("0000000000000000"))
+ addCapability(0x001a, mustHex("00000000"))
+ rdpDebugf(cfg, "grdp-comment caps=%d bytes=%d", count, out.Len())
+ return out.Bytes(), count
+}
+
+func buildGRDPOrderCapability() []byte {
+ out := bytes.Buffer{}
+ out.Write(make([]byte, 16))
+ writeLE32(&out, 0)
+ writeLE16(&out, 1)
+ writeLE16(&out, 20)
+ writeLE16(&out, 0)
+ writeLE16(&out, 1)
+ writeLE16(&out, 0)
+ writeLE16(&out, 0x00aa)
+ orderSupport := make([]byte, 32)
+ // native 暂不处理 Orders,能力集必须全关,避免服务端只发订单导致桌面黑屏 // Logan.Li
+ out.Write(orderSupport)
+ writeLE16(&out, 0)
+ writeLE16(&out, 0x0004)
+ writeLE32(&out, 0)
+ writeLE32(&out, 480*480)
+ writeLE16(&out, 0)
+ writeLE16(&out, 0)
+ writeLE16(&out, 0x04e4)
+ writeLE16(&out, 0)
+ return out.Bytes()
+}
+
+func buildGeneralCapability() []byte {
+ out := bytes.Buffer{}
+ writeLE16(&out, 0x0001)
+ writeLE16(&out, 0x0003)
+ writeLE16(&out, 0x0200)
+ writeLE16(&out, 0)
+ writeLE16(&out, 0x0002)
+ writeLE16(&out, 0x041d)
+ writeLE16(&out, 0)
+ writeLE16(&out, 0)
+ writeLE16(&out, 0)
+ out.WriteByte(1)
+ out.WriteByte(1)
+ return out.Bytes()
+}
+
+func buildPointerCapability(cacheSize uint16) []byte {
+ out := bytes.Buffer{}
+ writeLE16(&out, 1)
+ writeLE16(&out, cacheSize)
+ writeLE16(&out, cacheSize)
+ return out.Bytes()
+}
+
+func buildBitmapCacheRev2Capability() []byte {
+ return buildBitmapCacheRev2CapabilityWithPersist(0)
+}
+
+func buildBitmapCacheRev2CapabilityWithPersist(persist uint16) []byte {
+ out := bytes.Buffer{}
+ writeLE16(&out, persist)
+ out.WriteByte(0)
+ out.WriteByte(5)
+ for _, cells := range []uint32{0x258, 0x258, 0x800, 0x1000, 0x800} {
+ writeLE32(&out, cells)
+ }
+ out.Write(make([]byte, 12))
+ return out.Bytes()
+}
+
+func buildGlyphNoneCapability() []byte {
+ out := bytes.Buffer{}
+ out.Write(make([]byte, 40))
+ writeLE32(&out, 0)
+ writeLE16(&out, 0)
+ writeLE16(&out, 0)
+ return out.Bytes()
+}
+
+func buildBitmapCodecsCapability() []byte {
+ out := bytes.Buffer{}
+ out.WriteByte(2)
+ out.Write(mustHex("b91b8dca0f004f15589fae2d1a87e2d6"))
+ out.WriteByte(1)
+ writeLE16(&out, 3)
+ out.Write([]byte{1, 1, 3})
+ out.Write(mustHex("122f777672bd6344afb3b73c9c6f7886"))
+ out.WriteByte(3)
+ rfx := buildRemoteFXProperties()
+ writeLE16(&out, uint16(len(rfx)))
+ out.Write(rfx)
+ return out.Bytes()
+}
+
+func buildRemoteFXProperties() []byte {
+ return mustHex("29000000000000001d000000c0cb080000000100c1cb1500000001c0cf010008000001400001010104")
+}
+
+func buildInputCapability() []byte {
+ out := bytes.Buffer{}
+ writeLE16(&out, 0x003d)
+ writeLE16(&out, 0)
+ writeLE32(&out, 0x00000409)
+ writeLE32(&out, 4)
+ writeLE32(&out, 0)
+ writeLE32(&out, 12)
+ out.Write(make([]byte, 64))
+ return out.Bytes()
+}
+
+func writeCapability(w io.Writer, capType uint16, body []byte) {
+ writeLE16(w, capType)
+ writeLE16(w, uint16(len(body)+4))
+ _, _ = w.Write(body)
+}
+
+func buildShareControlPDU(userID uint16, pduType uint16, body []byte) []byte {
+ out := bytes.Buffer{}
+ writeLE16(&out, uint16(len(body)+6))
+ writeLE16(&out, pduType)
+ writeLE16(&out, userID)
+ out.Write(body)
+ return out.Bytes()
+}
+
+func buildDataPDU(userID uint16, sharedID uint32, type2 uint8, body []byte) []byte {
+ data := bytes.Buffer{}
+ writeLE32(&data, sharedID)
+ data.WriteByte(0)
+ data.WriteByte(1)
+ writeLE16(&data, uint16(len(body)+4))
+ data.WriteByte(type2)
+ data.WriteByte(0)
+ writeLE16(&data, 0)
+ data.Write(body)
+ return buildShareControlPDU(userID, pduTypeData, data.Bytes())
+}
+
+func buildSynchronizeData(userID uint16) []byte {
+ out := bytes.Buffer{}
+ writeLE16(&out, 1)
+ writeLE16(&out, userID)
+ return out.Bytes()
+}
+
+func buildControlData(action uint16) []byte {
+ out := bytes.Buffer{}
+ writeLE16(&out, action)
+ writeLE16(&out, 0)
+ writeLE32(&out, 0)
+ return out.Bytes()
+}
+
+func buildFontListData() []byte {
+ out := bytes.Buffer{}
+ writeLE16(&out, 0)
+ writeLE16(&out, 0)
+ writeLE16(&out, 0x0003)
+ writeLE16(&out, 0x0032)
+ return out.Bytes()
+}
+
+func attachMCSUser(rw io.ReadWriter) (uint16, error) {
+ if err := transport.WriteX224Data(rw, buildMCSErectDomainRequest()); err != nil {
+ return 0, err
+ }
+ if err := transport.WriteX224Data(rw, buildMCSAttachUserRequest()); err != nil {
+ return 0, err
+ }
+ payload, err := transport.ReadX224Data(rw)
+ if err != nil {
+ return 0, err
+ }
+ return parseMCSAttachUserConfirm(payload)
+}
+
+func joinMCSChannel(rw io.ReadWriter, userID uint16, channelID uint16) (uint16, error) {
+ if err := transport.WriteX224Data(rw, buildMCSChannelJoinRequest(userID, channelID)); err != nil {
+ return 0, err
+ }
+ payload, err := transport.ReadX224Data(rw)
+ if err != nil {
+ return 0, err
+ }
+ return parseMCSChannelJoinConfirm(payload, userID, channelID)
+}
+
+func buildMCSErectDomainRequest() []byte {
+ out := bytes.Buffer{}
+ writeMCSPDUHeader(&out, mcsErectDomainRequest)
+ writePERInteger(&out, 0)
+ writePERInteger(&out, 0)
+ return out.Bytes()
+}
+
+func buildMCSAttachUserRequest() []byte {
+ out := bytes.Buffer{}
+ writeMCSPDUHeader(&out, mcsAttachUserRequest)
+ return out.Bytes()
+}
+
+func buildMCSChannelJoinRequest(userID uint16, channelID uint16) []byte {
+ out := bytes.Buffer{}
+ writeMCSPDUHeader(&out, mcsChannelJoinRequest)
+ writeBE16Value(&out, userID-mcsUserChannelIDOffset)
+ writeBE16Value(&out, channelID)
+ return out.Bytes()
+}
+
+func buildMCSSendDataRequest(userID uint16, channelID uint16, data []byte) []byte {
+ out := bytes.Buffer{}
+ writeMCSPDUHeader(&out, mcsSendDataRequest)
+ writeBE16Value(&out, userID-mcsUserChannelIDOffset)
+ writeBE16Value(&out, channelID)
+ out.WriteByte(0x70)
+ writePERLength(&out, len(data))
+ out.Write(data)
+ return out.Bytes()
+}
+
+func parseMCSAttachUserConfirm(data []byte) (uint16, error) {
+ r := bytes.NewReader(data)
+ pdu, err := readMCSPDUHeader(r)
+ if err != nil {
+ return 0, err
+ }
+ if pdu != mcsAttachUserConfirm {
+ return 0, fmt.Errorf("MCS AttachUserConfirm 类型错误: %d", pdu)
+ }
+ result, err := r.ReadByte()
+ if err != nil {
+ return 0, err
+ }
+ if result != 0 {
+ return 0, fmt.Errorf("MCS AttachUser 被拒绝: %d", result)
+ }
+ userID, err := readBE16Value(r)
+ if err != nil {
+ return 0, err
+ }
+ return userID + mcsUserChannelIDOffset, nil
+}
+
+func parseMCSChannelJoinConfirm(data []byte, expectedUserID uint16, expectedChannelID uint16) (uint16, error) {
+ r := bytes.NewReader(data)
+ pdu, err := readMCSPDUHeader(r)
+ if err != nil {
+ return 0, err
+ }
+ if pdu != mcsChannelJoinConfirm {
+ return 0, fmt.Errorf("MCS ChannelJoinConfirm 类型错误: %d", pdu)
+ }
+ result, err := r.ReadByte()
+ if err != nil {
+ return 0, err
+ }
+ userID, err := readBE16Value(r)
+ if err != nil {
+ return 0, err
+ }
+ userID += mcsUserChannelIDOffset
+ channelID, err := readBE16Value(r)
+ if err != nil {
+ return 0, err
+ }
+ if userID != expectedUserID {
+ return 0, fmt.Errorf("MCS ChannelJoin 用户不匹配: got=%d want=%d", userID, expectedUserID)
+ }
+ if channelID != expectedChannelID {
+ return 0, fmt.Errorf("MCS ChannelJoin 通道不匹配: got=%d want=%d", channelID, expectedChannelID)
+ }
+ if result != 0 && (channelID == mcsGlobalChannelID || channelID == expectedUserID) {
+ return 0, fmt.Errorf("MCS 必需通道加入失败: channel=%d result=%d", channelID, result)
+ }
+ return channelID, nil
+}
+
+func parseMCSDataIndication(data []byte) (mcsDataIndication, error) {
+ r := bytes.NewReader(data)
+ pdu, err := readMCSPDUHeader(r)
+ if err != nil {
+ return mcsDataIndication{}, err
+ }
+ if pdu != mcsSendDataIndication {
+ return mcsDataIndication{}, fmt.Errorf("MCS DataIndication 类型错误: %d", pdu)
+ }
+ if _, err := readBE16Value(r); err != nil {
+ return mcsDataIndication{}, err
+ }
+ channelID, err := readBE16Value(r)
+ if err != nil {
+ return mcsDataIndication{}, err
+ }
+ if _, err := r.ReadByte(); err != nil {
+ return mcsDataIndication{}, err
+ }
+ size, err := readPERLength(r)
+ if err != nil {
+ return mcsDataIndication{}, err
+ }
+ if int(size) > r.Len() {
+ return mcsDataIndication{}, io.ErrUnexpectedEOF
+ }
+ payload := make([]byte, int(size))
+ _, _ = io.ReadFull(r, payload)
+ flags := uint16(0)
+ if len(payload) >= 4 {
+ flags = binary.LittleEndian.Uint16(payload[0:2])
+ }
+ return mcsDataIndication{channelID: channelID, securityFlags: flags, payload: payload}, nil
+}
+
+func parseLicensePacket(data *mcsDataIndication) error {
+ if len(data.payload) < 8 {
+ return fmt.Errorf("License PDU 太短: %d", len(data.payload))
+ }
+ body := data.payload[4:]
+ data.licenseMsgType = body[0]
+ if len(body) >= 12 && data.licenseMsgType == licenseErrorAlert {
+ data.licenseErrorCode = binary.LittleEndian.Uint32(body[4:8])
+ data.licenseState = binary.LittleEndian.Uint32(body[8:12])
+ if data.licenseErrorCode == licenseStatusValid && data.licenseState == licenseStateNoTransit {
+ return nil
+ }
+ return fmt.Errorf("License ErrorAlert: code=0x%08x state=0x%08x", data.licenseErrorCode, data.licenseState)
+ }
+ return fmt.Errorf("暂未实现 License msgType=0x%02x 的响应", data.licenseMsgType)
+}
+
+func parseDemandActiveProbe(payload []byte) (demandActiveProbe, error) {
+ r := bytes.NewReader(payload)
+ totalLength, err := readLE16(r)
+ if err != nil {
+ return demandActiveProbe{}, err
+ }
+ pduType, err := readLE16(r)
+ if err != nil {
+ return demandActiveProbe{}, err
+ }
+ if int(totalLength) > len(payload) {
+ return demandActiveProbe{}, fmt.Errorf("Share Control 长度超出数据: total=%d payload=%d", totalLength, len(payload))
+ }
+ if pduType != pduTypeDemandActive {
+ return demandActiveProbe{pduType: pduType}, fmt.Errorf("等待 DemandActive,收到 PDU type=0x%04x", pduType)
+ }
+ if _, err := readLE16(r); err != nil {
+ return demandActiveProbe{}, err
+ }
+ sharedID, err := readLE32(r)
+ if err != nil {
+ return demandActiveProbe{}, err
+ }
+ sourceLen, err := readLE16(r)
+ if err != nil {
+ return demandActiveProbe{}, err
+ }
+ if _, err := readLE16(r); err != nil {
+ return demandActiveProbe{}, err
+ }
+ if int(sourceLen) > r.Len() {
+ return demandActiveProbe{}, io.ErrUnexpectedEOF
+ }
+ source := make([]byte, int(sourceLen))
+ _, _ = io.ReadFull(r, source)
+ capabilityCount, err := readLE16(r)
+ if err != nil {
+ return demandActiveProbe{}, err
+ }
+ return demandActiveProbe{pduType: pduType, sharedID: sharedID, capabilityCount: capabilityCount, sourceDescriptor: source}, nil
+}
+
+func parseShareDataPDUType(payload []byte) (uint8, error) {
+ info, err := parseShareDataPDUInfo(payload)
+ if err != nil {
+ return 0, err
+ }
+ return info.type2, nil
+}
+
+type shareDataPDUInfo struct {
+ type2 uint8
+ controlAction uint16
+ errorInfo uint32
+ rawPrefix string
+}
+
+func parseShareDataPDUInfo(payload []byte) (shareDataPDUInfo, error) {
+ if len(payload) < 18 {
+ return shareDataPDUInfo{}, fmt.Errorf("Share Data PDU 太短: %d", len(payload))
+ }
+ pduType := binary.LittleEndian.Uint16(payload[2:4])
+ if pduType != pduTypeData {
+ return shareDataPDUInfo{}, fmt.Errorf("等待 Data PDU,收到 type=0x%04x", pduType)
+ }
+ prefixLen := len(payload)
+ if prefixLen > 32 {
+ prefixLen = 32
+ }
+ info := shareDataPDUInfo{type2: payload[14], rawPrefix: hex.EncodeToString(payload[:prefixLen])}
+ if info.type2 == pduType2Control {
+ if len(payload) < 20 {
+ return shareDataPDUInfo{}, fmt.Errorf("Control Data PDU 太短: %d", len(payload))
+ }
+ info.controlAction = binary.LittleEndian.Uint16(payload[18:20])
+ }
+ if info.type2 == pduType2SetErrorInfo {
+ if len(payload) < 22 {
+ return shareDataPDUInfo{}, fmt.Errorf("ErrorInfo Data PDU 太短: %d", len(payload))
+ }
+ info.errorInfo = binary.LittleEndian.Uint32(payload[18:22])
+ }
+ return info, nil
+}
+
+func mustHex(value string) []byte {
+ data, err := hex.DecodeString(value)
+ if err != nil {
+ panic(err)
+ }
+ return data
+}
+
+func buildClientInfoPDU(cfg Config) []byte {
+ info := buildRDPInfo(cfg)
+ out := bytes.Buffer{}
+ writeLE16(&out, secFlagInfoPkt)
+ writeLE16(&out, 0)
+ out.Write(info)
+ return out.Bytes()
+}
+
+func buildRDPInfo(cfg Config) []byte {
+ domain := utf16LENull(cfg.Domain)
+ user := utf16LENull(cfg.Username)
+ password := utf16LENull(cfg.Password)
+ alternateShell := []byte{0, 0}
+ workingDir := []byte{0, 0}
+ flags := uint32(0x00000001 | 0x00000002 | 0x00000008 | 0x00000010 | 0x00000020 | 0x00000040 | 0x00000100 | 0x00004000 | 0x00010000 | 0x00020000)
+ if cfg.MCSMode == "plain-info" || cfg.MCSMode == "step-plain-info" {
+ flags = 0x00000001 | 0x00000002 | 0x00000008 | 0x00004000
+ }
+
+ out := bytes.Buffer{}
+ writeLE32(&out, 0)
+ writeLE32(&out, flags)
+ writeLE16(&out, uint16(len(domain)-2))
+ writeLE16(&out, uint16(len(user)-2))
+ writeLE16(&out, uint16(len(password)-2))
+ writeLE16(&out, uint16(len(alternateShell)-2))
+ writeLE16(&out, uint16(len(workingDir)-2))
+ out.Write(domain)
+ out.Write(user)
+ out.Write(password)
+ out.Write(alternateShell)
+ out.Write(workingDir)
+ out.Write(buildExtendedInfo())
+ return out.Bytes()
+}
+
+func buildExtendedInfo() []byte {
+ clientAddress := []byte{0, 0}
+ clientDir := []byte{0, 0}
+ out := bytes.Buffer{}
+ writeLE16(&out, 0x0002)
+ writeLE16(&out, uint16(len(clientAddress)))
+ out.Write(clientAddress)
+ writeLE16(&out, uint16(len(clientDir)))
+ out.Write(clientDir)
+ out.Write(make([]byte, 172))
+ writeLE32(&out, 0)
+ writeLE32(&out, 0x00000001|0x00000002|0x00000004|0x00000080|0x00000100)
+ return out.Bytes()
+}
+
+func buildMCSConnectInitial(cfg Config) []byte {
+ userData := bytes.Buffer{}
+ userData.Write(buildGCCClientCore(cfg))
+ userData.Write(buildGCCClientNetwork())
+ userData.Write(buildGCCClientSecurity())
+ userData.Write(buildGCCClientMessageChannel())
+ conference := buildGCCConferenceCreate(userData.Bytes())
+
+ body := bytes.Buffer{}
+ writeBEROctetString(bodyWriter(&body), []byte{0x01})
+ writeBEROctetString(bodyWriter(&body), []byte{0x01})
+ writeBERBoolean(bodyWriter(&body), true)
+ writeBERDomainParameters(bodyWriter(&body), 34, 2, 0, 0xffff)
+ writeBERDomainParameters(bodyWriter(&body), 1, 1, 1, 0x0420)
+ writeBERDomainParameters(bodyWriter(&body), 0xffff, 0xfc17, 0xffff, 0xffff)
+ writeBEROctetString(bodyWriter(&body), conference)
+
+ out := bytes.Buffer{}
+ writeBERApplicationTag(bodyWriter(&out), mcsConnectInitial, body.Len())
+ out.Write(body.Bytes())
+ return out.Bytes()
+}
+
+func buildGCCClientCore(cfg Config) []byte {
+ width := cfg.Width
+ if width <= 0 {
+ width = 1280
+ }
+ height := cfg.Height
+ if height <= 0 {
+ height = 800
+ }
+ highColorDepth := uint16(24)
+ supportedColorDepths := uint16(0x000f)
+ if cfg.MCSMode == "bpp32" || cfg.MCSMode == "step-bpp32" || cfg.MCSMode == "grdp-modern" || cfg.MCSMode == "step-grdp-modern" {
+ highColorDepth = 32
+ supportedColorDepths = 0x001f
+ }
+ body := bytes.Buffer{}
+ writeLE32(&body, 0x00080007)
+ writeLE16(&body, uint16(width))
+ writeLE16(&body, uint16(height))
+ writeLE16(&body, 0xCA01)
+ writeLE16(&body, 0xAA03)
+ writeLE32(&body, 0x00000409)
+ writeLE32(&body, 22621)
+ body.Write(fixedUTF16LE("AIOPS", 32))
+ writeLE32(&body, 4)
+ writeLE32(&body, 0)
+ writeLE32(&body, 12)
+ body.Write(make([]byte, 64))
+ writeLE16(&body, 0xCA01)
+ writeLE16(&body, 1)
+ writeLE32(&body, 0)
+ writeLE16(&body, highColorDepth)
+ writeLE16(&body, supportedColorDepths)
+ writeLE16(&body, 0x00a3)
+ body.Write(make([]byte, 64))
+ body.WriteByte(0x06)
+ body.WriteByte(0)
+ writeLE32(&body, transport.ProtocolCredSSP)
+ return withGCCBlock(gccCSCore, body.Bytes())
+}
+
+func buildGCCClientNetwork() []byte {
+ body := bytes.Buffer{}
+ writeLE32(&body, 0)
+ return withGCCBlock(gccCSNet, body.Bytes())
+}
+
+func buildGCCClientSecurity() []byte {
+ body := bytes.Buffer{}
+ writeLE32(&body, 0x0000000b)
+ writeLE32(&body, 0)
+ return withGCCBlock(gccCSSecurity, body.Bytes())
+}
+
+func buildGCCClientMessageChannel() []byte {
+ body := bytes.Buffer{}
+ writeLE32(&body, 0)
+ return withGCCBlock(0xC006, body.Bytes())
+}
+
+func withGCCBlock(blockType uint16, body []byte) []byte {
+ out := bytes.Buffer{}
+ writeLE16(&out, blockType)
+ writeLE16(&out, uint16(len(body)+4))
+ out.Write(body)
+ return out.Bytes()
+}
+
+func buildGCCConferenceCreate(userData []byte) []byte {
+ out := bytes.Buffer{}
+ writePERChoice(&out, 0)
+ writePERObjectIdentifier(&out, []byte{0, 0, 20, 124, 0, 1})
+ writePERLength(&out, len(userData)+14)
+ writePERChoice(&out, 0)
+ out.WriteByte(0x08)
+ writePERNumericString(&out, "1", 1)
+ out.WriteByte(0)
+ out.WriteByte(1)
+ writePERChoice(&out, 0xc0)
+ writePEROctetStream(&out, []byte("Duca"), 4)
+ writePEROctetStream(&out, userData, 0)
+ return out.Bytes()
+}
+
+func parseMCSConnectResponse(data []byte) (MCSProbeInfo, error) {
+ r := bytes.NewReader(data)
+ if err := readBERApplicationTag(r, mcsConnectResponse); err != nil {
+ return MCSProbeInfo{}, err
+ }
+ if _, err := readBEREnumerated(r); err != nil {
+ return MCSProbeInfo{}, err
+ }
+ if _, err := readBERInteger(r); err != nil {
+ return MCSProbeInfo{}, err
+ }
+ if err := skipBERDomainParameters(r); err != nil {
+ return MCSProbeInfo{}, err
+ }
+ userData, err := readBEROctetString(r)
+ if err != nil {
+ return MCSProbeInfo{}, err
+ }
+ info := MCSProbeInfo{UserDataBytes: len(userData)}
+ parseGCCConferenceResponse(userData, &info)
+ return info, nil
+}
+
+func parseGCCConferenceResponse(data []byte, info *MCSProbeInfo) {
+ r := bytes.NewReader(data)
+ _, _ = r.ReadByte()
+ _ = readPERObjectIdentifier(r, []byte{0, 0, 20, 124, 0, 1})
+ _, _ = readPERLength(r)
+ _, _ = r.ReadByte()
+ _, _ = readBE16(r)
+ _, _ = readPERInteger(r)
+ _, _ = r.ReadByte()
+ _, _ = r.ReadByte()
+ _, _ = r.ReadByte()
+ if !readPEROctetStreamMatch(r, []byte("McDn"), 4) {
+ return
+ }
+ total, _ := readPERLength(r)
+ for total >= 4 && r.Len() >= 4 {
+ blockType, _ := readLE16(r)
+ blockLen, _ := readLE16(r)
+ if blockLen < 4 || int(blockLen-4) > r.Len() {
+ return
+ }
+ body := make([]byte, int(blockLen-4))
+ _, _ = io.ReadFull(r, body)
+ total -= blockLen
+ parseGCCServerBlock(blockType, body, info)
+ }
+}
+
+func parseGCCServerBlock(blockType uint16, body []byte, info *MCSProbeInfo) {
+ r := bytes.NewReader(body)
+ switch blockType {
+ case gccSCCore:
+ info.ServerCoreVersion, _ = readLE32(r)
+ case gccSCSecurity:
+ info.ServerEncryption, _ = readLE32(r)
+ info.ServerEncryptionLvl, _ = readLE32(r)
+ case gccSCNet:
+ info.ServerChannelID, _ = readLE16(r)
+ info.ServerChannelCount, _ = readLE16(r)
+ for i := 0; i < int(info.ServerChannelCount) && r.Len() >= 2; i++ {
+ channelID, _ := readLE16(r)
+ info.ServerChannelIDs = append(info.ServerChannelIDs, channelID)
+ }
+ case 0x0C04:
+ info.MessageChannelID, _ = readLE16(r)
+ }
+}
+
+type bodyWriter interface {
+ Write([]byte) (int, error)
+ WriteByte(byte) error
+}
+
+func writeBERApplicationTag(w bodyWriter, tag int, size int) {
+ w.WriteByte(0x7f)
+ w.WriteByte(byte(tag))
+ writeBERLength(w, size)
+}
+
+func readBERApplicationTag(r *bytes.Reader, tag int) error {
+ first, err := r.ReadByte()
+ if err != nil {
+ return err
+ }
+ if first != 0x7f {
+ return fmt.Errorf("MCS BER application tag 头错误: 0x%x", first)
+ }
+ got, err := r.ReadByte()
+ if err != nil {
+ return err
+ }
+ if int(got) != tag {
+ return fmt.Errorf("MCS BER application tag 不匹配: 0x%x", got)
+ }
+ _, err = readBERLength(r)
+ return err
+}
+
+func writeBERDomainParameters(w bodyWriter, maxChannelIDs, maxUserIDs, maxTokenIDs, maxPDUSize int) {
+ body := bytes.Buffer{}
+ writeBERInteger(&body, maxChannelIDs)
+ writeBERInteger(&body, maxUserIDs)
+ writeBERInteger(&body, maxTokenIDs)
+ writeBERInteger(&body, 1)
+ writeBERInteger(&body, 0)
+ writeBERInteger(&body, 1)
+ writeBERInteger(&body, maxPDUSize)
+ writeBERInteger(&body, 2)
+ w.WriteByte(0x30)
+ writeBERLength(w, body.Len())
+ w.Write(body.Bytes())
+}
+
+func skipBERDomainParameters(r *bytes.Reader) error {
+ tag, err := r.ReadByte()
+ if err != nil {
+ return err
+ }
+ if tag != 0x30 {
+ return fmt.Errorf("MCS DomainParameters tag 错误: 0x%x", tag)
+ }
+ size, err := readBERLength(r)
+ if err != nil {
+ return err
+ }
+ if size > r.Len() {
+ return io.ErrUnexpectedEOF
+ }
+ _, err = r.Seek(int64(size), io.SeekCurrent)
+ return err
+}
+
+func writeBEROctetString(w bodyWriter, data []byte) {
+ w.WriteByte(0x04)
+ writeBERLength(w, len(data))
+ w.Write(data)
+}
+
+func readBEROctetString(r *bytes.Reader) ([]byte, error) {
+ tag, err := r.ReadByte()
+ if err != nil {
+ return nil, err
+ }
+ if tag != 0x04 {
+ return nil, fmt.Errorf("MCS octet string tag 错误: 0x%x", tag)
+ }
+ size, err := readBERLength(r)
+ if err != nil {
+ return nil, err
+ }
+ if size > r.Len() {
+ return nil, io.ErrUnexpectedEOF
+ }
+ out := make([]byte, size)
+ _, err = io.ReadFull(r, out)
+ return out, err
+}
+
+func writeBERBoolean(w bodyWriter, value bool) {
+ w.WriteByte(0x01)
+ w.WriteByte(1)
+ if value {
+ w.WriteByte(0xff)
+ return
+ }
+ w.WriteByte(0)
+}
+
+func writeBERInteger(w bodyWriter, value int) {
+ w.WriteByte(0x02)
+ switch {
+ case value <= 0xff:
+ w.WriteByte(1)
+ w.WriteByte(byte(value))
+ case value <= 0xffff:
+ w.WriteByte(2)
+ var b [2]byte
+ binary.BigEndian.PutUint16(b[:], uint16(value))
+ w.Write(b[:])
+ default:
+ w.WriteByte(4)
+ var b [4]byte
+ binary.BigEndian.PutUint32(b[:], uint32(value))
+ w.Write(b[:])
+ }
+}
+
+func readBERInteger(r *bytes.Reader) (int, error) {
+ tag, err := r.ReadByte()
+ if err != nil {
+ return 0, err
+ }
+ if tag != 0x02 {
+ return 0, fmt.Errorf("MCS integer tag 错误: 0x%x", tag)
+ }
+ size, err := readBERLength(r)
+ if err != nil {
+ return 0, err
+ }
+ if size > 4 || size > r.Len() {
+ return 0, io.ErrUnexpectedEOF
+ }
+ raw := make([]byte, size)
+ _, _ = io.ReadFull(r, raw)
+ value := 0
+ for _, b := range raw {
+ value = value<<8 | int(b)
+ }
+ return value, nil
+}
+
+func readBEREnumerated(r *bytes.Reader) (byte, error) {
+ tag, err := r.ReadByte()
+ if err != nil {
+ return 0, err
+ }
+ if tag != 0x0a {
+ return 0, fmt.Errorf("MCS enumerated tag 错误: 0x%x", tag)
+ }
+ size, err := readBERLength(r)
+ if err != nil {
+ return 0, err
+ }
+ if size != 1 {
+ return 0, fmt.Errorf("MCS enumerated 长度错误: %d", size)
+ }
+ return r.ReadByte()
+}
+
+func writeBERLength(w bodyWriter, size int) {
+ if size > 0x7f {
+ w.WriteByte(0x82)
+ var b [2]byte
+ binary.BigEndian.PutUint16(b[:], uint16(size))
+ w.Write(b[:])
+ return
+ }
+ w.WriteByte(byte(size))
+}
+
+func readBERLength(r *bytes.Reader) (int, error) {
+ first, err := r.ReadByte()
+ if err != nil {
+ return 0, err
+ }
+ if first&0x80 == 0 {
+ return int(first), nil
+ }
+ count := int(first & 0x7f)
+ if count == 0 || count > 2 || count > r.Len() {
+ return 0, fmt.Errorf("BER length 字节数非法: %d", count)
+ }
+ value := 0
+ for i := 0; i < count; i++ {
+ b, _ := r.ReadByte()
+ value = value<<8 | int(b)
+ }
+ return value, nil
+}
+
+func writePERChoice(w bodyWriter, choice byte) {
+ w.WriteByte(choice)
+}
+
+func writePERInteger(w bodyWriter, value int) {
+ if value <= 0xff {
+ writePERLength(w, 1)
+ w.WriteByte(byte(value))
+ return
+ }
+ if value <= 0xffff {
+ writePERLength(w, 2)
+ var b [2]byte
+ binary.BigEndian.PutUint16(b[:], uint16(value))
+ w.Write(b[:])
+ return
+ }
+ writePERLength(w, 4)
+ var b [4]byte
+ binary.BigEndian.PutUint32(b[:], uint32(value))
+ w.Write(b[:])
+}
+
+func writePERObjectIdentifier(w bodyWriter, oid []byte) {
+ w.WriteByte(5)
+ w.WriteByte((oid[0] << 4) | (oid[1] & 0x0f))
+ w.Write(oid[2:6])
+}
+
+func readPERObjectIdentifier(r *bytes.Reader, oid []byte) bool {
+ size, err := r.ReadByte()
+ if err != nil || size != 5 || r.Len() < 5 {
+ return false
+ }
+ raw := make([]byte, 5)
+ _, _ = io.ReadFull(r, raw)
+ got := []byte{raw[0] >> 4, raw[0] & 0x0f, raw[1], raw[2], raw[3], raw[4]}
+ return bytes.Equal(got, oid)
+}
+
+func writePERLength(w bodyWriter, size int) {
+ if size > 0x7f {
+ var b [2]byte
+ binary.BigEndian.PutUint16(b[:], uint16(size|0x8000))
+ w.Write(b[:])
+ return
+ }
+ w.WriteByte(byte(size))
+}
+
+func readPERLength(r *bytes.Reader) (uint16, error) {
+ first, err := r.ReadByte()
+ if err != nil {
+ return 0, err
+ }
+ if first&0x80 == 0 {
+ return uint16(first), nil
+ }
+ next, err := r.ReadByte()
+ if err != nil {
+ return 0, err
+ }
+ return uint16(first&0x7f)<<8 | uint16(next), nil
+}
+
+func writePERNumericString(w bodyWriter, s string, minValue int) {
+ length := len(s)
+ encodedLength := minValue
+ if length >= minValue {
+ encodedLength = length - minValue
+ }
+ writePERLength(w, encodedLength)
+ for i := 0; i < length; i += 2 {
+ left := (s[i] - '0') % 10
+ right := byte(0)
+ if i+1 < length {
+ right = (s[i+1] - '0') % 10
+ }
+ w.WriteByte((left << 4) | right)
+ }
+}
+
+func writePEROctetStream(w bodyWriter, data []byte, minValue int) {
+ encodedLength := minValue
+ if len(data) >= minValue {
+ encodedLength = len(data) - minValue
+ }
+ writePERLength(w, encodedLength)
+ w.Write(data)
+}
+
+func readPEROctetStreamMatch(r *bytes.Reader, expected []byte, minValue int) bool {
+ encodedLength, err := readPERLength(r)
+ if err != nil {
+ return false
+ }
+ size := int(encodedLength) + minValue
+ if size != len(expected) || size > r.Len() {
+ return false
+ }
+ raw := make([]byte, size)
+ _, _ = io.ReadFull(r, raw)
+ return bytes.Equal(raw, expected)
+}
+
+func readPERInteger(r *bytes.Reader) (uint32, error) {
+ size, err := readPERLength(r)
+ if err != nil {
+ return 0, err
+ }
+ if int(size) > r.Len() {
+ return 0, io.ErrUnexpectedEOF
+ }
+ switch size {
+ case 1:
+ b, _ := r.ReadByte()
+ return uint32(b), nil
+ case 2:
+ return readBE16(r)
+ case 4:
+ return readBE32(r)
+ default:
+ _, _ = r.Seek(int64(size), io.SeekCurrent)
+ return 0, nil
+ }
+}
+
+func fixedUTF16LE(value string, size int) []byte {
+ out := make([]byte, size)
+ encoded := utf16.Encode([]rune(value))
+ for i, r := range encoded {
+ offset := i * 2
+ if offset+1 >= len(out) {
+ break
+ }
+ binary.LittleEndian.PutUint16(out[offset:offset+2], r)
+ }
+ return out
+}
+
+func utf16LENull(value string) []byte {
+ out := bytes.Buffer{}
+ for _, r := range utf16.Encode([]rune(value)) {
+ writeLE16(&out, r)
+ }
+ writeLE16(&out, 0)
+ return out.Bytes()
+}
+
+func writeLE16(w io.Writer, value uint16) {
+ var b [2]byte
+ binary.LittleEndian.PutUint16(b[:], value)
+ _, _ = w.Write(b[:])
+}
+
+func writeLE32(w io.Writer, value uint32) {
+ var b [4]byte
+ binary.LittleEndian.PutUint32(b[:], value)
+ _, _ = w.Write(b[:])
+}
+
+func readLE16(r io.Reader) (uint16, error) {
+ var b [2]byte
+ _, err := io.ReadFull(r, b[:])
+ return binary.LittleEndian.Uint16(b[:]), err
+}
+
+func readLE32(r io.Reader) (uint32, error) {
+ var b [4]byte
+ _, err := io.ReadFull(r, b[:])
+ return binary.LittleEndian.Uint32(b[:]), err
+}
+
+func readBE16(r io.Reader) (uint32, error) {
+ var b [2]byte
+ _, err := io.ReadFull(r, b[:])
+ return uint32(binary.BigEndian.Uint16(b[:])), err
+}
+
+func readBE32(r io.Reader) (uint32, error) {
+ var b [4]byte
+ _, err := io.ReadFull(r, b[:])
+ return binary.BigEndian.Uint32(b[:]), err
+}
+
+func writeMCSPDUHeader(w bodyWriter, pduType byte) {
+ w.WriteByte(pduType << 2)
+}
+
+func readMCSPDUHeader(r *bytes.Reader) (byte, error) {
+ header, err := r.ReadByte()
+ if err != nil {
+ return 0, err
+ }
+ return header >> 2, nil
+}
+
+func writeBE16Value(w bodyWriter, value uint16) {
+ var b [2]byte
+ binary.BigEndian.PutUint16(b[:], value)
+ w.Write(b[:])
+}
+
+func readBE16Value(r io.Reader) (uint16, error) {
+ var b [2]byte
+ _, err := io.ReadFull(r, b[:])
+ return binary.BigEndian.Uint16(b[:]), err
+}
diff --git a/backend/pkg/rdp/mppc.go b/backend/pkg/rdp/mppc.go
new file mode 100644
index 0000000..375dceb
--- /dev/null
+++ b/backend/pkg/rdp/mppc.go
@@ -0,0 +1,125 @@
+package rdp
+
+// MPPC 解压器实现 RDP 64K 字典压缩(MS-RDPBCGR §3.1.8.4.1)// Logan.Li
+
+const mppcHistorySize = 65536
+
+const (
+ mppcBig = 0x01
+ mppcCompressed = 0x20
+ mppcReset = 0x40
+ mppcFlushed = 0x80
+)
+
+// MppcDecompressor 维护单连接 MPPC 历史窗口 // Logan.Li
+type MppcDecompressor struct {
+ history [mppcHistorySize]byte
+ offset int
+}
+
+func NewMppcDecompressor() *MppcDecompressor {
+ return &MppcDecompressor{}
+}
+
+func (d *MppcDecompressor) Decompress(flags byte, data []byte) ([]byte, error) {
+ if flags&mppcFlushed != 0 {
+ d.history = [mppcHistorySize]byte{}
+ d.offset = 0
+ } else if flags&mppcReset != 0 {
+ d.offset = 0
+ }
+ if flags&mppcCompressed == 0 {
+ for _, b := range data {
+ d.history[d.offset] = b
+ d.offset = (d.offset + 1) & (mppcHistorySize - 1)
+ }
+ return data, nil
+ }
+ output := make([]byte, 0, len(data)*3)
+ br := newMppcBitReader(data)
+outer:
+ for br.bitsLeft >= 9 {
+ if br.readBit() == 0 {
+ b := byte(br.readBits(8))
+ output = append(output, b)
+ d.history[d.offset] = b
+ d.offset = (d.offset + 1) & (mppcHistorySize - 1)
+ } else {
+ sel := (br.readBit() << 1) | br.readBit()
+ var copyOffset int
+ switch sel {
+ case 0:
+ if br.bitsLeft < 6 {
+ break outer
+ }
+ copyOffset = br.readBits(6)
+ case 1:
+ if br.bitsLeft < 8 {
+ break outer
+ }
+ copyOffset = 64 + br.readBits(8)
+ case 2:
+ if br.bitsLeft < 13 {
+ break outer
+ }
+ copyOffset = 320 + br.readBits(13)
+ case 3:
+ if br.bitsLeft < 16 {
+ break outer
+ }
+ copyOffset = 8192 + br.readBits(16)
+ }
+ copyLength := 3
+ for br.bitsLeft > 0 {
+ if br.readBit() == 0 {
+ break
+ }
+ copyLength++
+ }
+ src := (d.offset - copyOffset - 1 + mppcHistorySize) & (mppcHistorySize - 1)
+ for i := 0; i < copyLength; i++ {
+ b := d.history[(src+i)&(mppcHistorySize-1)]
+ output = append(output, b)
+ d.history[d.offset] = b
+ d.offset = (d.offset + 1) & (mppcHistorySize - 1)
+ }
+ }
+ }
+ return output, nil
+}
+
+type mppcBitReader struct {
+ data []byte
+ byteIdx int
+ mask byte
+ bitsLeft int
+}
+
+func newMppcBitReader(data []byte) *mppcBitReader {
+ return &mppcBitReader{data: data, mask: 0x80, bitsLeft: len(data) * 8}
+}
+
+func (r *mppcBitReader) readBit() int {
+ if r.bitsLeft == 0 {
+ return 0
+ }
+ r.bitsLeft--
+ bit := 0
+ if r.data[r.byteIdx]&r.mask != 0 {
+ bit = 1
+ }
+ r.mask >>= 1
+ if r.mask == 0 {
+ r.mask = 0x80
+ r.byteIdx++
+ }
+ return bit
+}
+
+func (r *mppcBitReader) readBits(n int) int {
+ result := 0
+ for i := 0; i < n; i++ {
+ result = (result << 1) | r.readBit()
+ }
+ return result
+}
\ No newline at end of file
diff --git a/backend/pkg/rdp/packet_io.go b/backend/pkg/rdp/packet_io.go
new file mode 100644
index 0000000..04f7d34
--- /dev/null
+++ b/backend/pkg/rdp/packet_io.go
@@ -0,0 +1,77 @@
+package rdp
+
+import (
+ "encoding/binary"
+ "fmt"
+ "io"
+
+ "ezops/pkg/rdp/transport"
+)
+
+const (
+ fastPathActionFastPath = 0x0
+ fastPathActionX224 = 0x3
+)
+
+// WirePacket 表示从 RDP 连接读取到的一层帧 // Logan.Li
+type WirePacket struct {
+ FastPath bool
+ SecFlag byte
+ Payload []byte
+}
+
+// ReadWirePacket 读取 TPKT/X.224 或 FastPath 输出帧 // Logan.Li
+func ReadWirePacket(r io.Reader) (WirePacket, error) {
+ var hdr [2]byte
+ if _, err := io.ReadFull(r, hdr[:]); err != nil {
+ return WirePacket{}, err
+ }
+ version := hdr[0]
+ if version == fastPathActionX224 {
+ var extHdr [2]byte
+ if _, err := io.ReadFull(r, extHdr[:]); err != nil {
+ return WirePacket{}, err
+ }
+ size := binary.BigEndian.Uint16(extHdr[:])
+ if size < 4 {
+ return WirePacket{}, fmt.Errorf("TPKT 长度非法: %d", size)
+ }
+ body := make([]byte, int(size)-4)
+ if _, err := io.ReadFull(r, body); err != nil {
+ return WirePacket{}, err
+ }
+ if len(body) < 3 || body[1] != 0xF0 {
+ return WirePacket{}, fmt.Errorf("非 X.224 Data PDU")
+ }
+ return WirePacket{Payload: body[3:]}, nil
+ }
+ secFlag := (version >> 6) & 0x3
+ length := int(hdr[1])
+ packetSize := 0
+ if length&0x80 != 0 {
+ var extByte [1]byte
+ if _, err := io.ReadFull(r, extByte[:]); err != nil {
+ return WirePacket{}, err
+ }
+ packetSize = ((length &^ 0x80) << 8) + int(extByte[0]) - 3
+ } else {
+ packetSize = length - 2
+ }
+ if packetSize < 0 {
+ return WirePacket{FastPath: true, SecFlag: secFlag}, nil
+ }
+ body := make([]byte, packetSize)
+ if _, err := io.ReadFull(r, body); err != nil {
+ return WirePacket{}, err
+ }
+ return WirePacket{FastPath: true, SecFlag: secFlag, Payload: body}, nil
+}
+
+// WriteFastPathInput 发送 Fast-Path Input 事件批次 // Logan.Li
+func WriteFastPathInput(w io.Writer, events []byte) error {
+ hdr := uint16(len(events)+3) | 0x8000
+ frame := make([]byte, 0, len(events)+3)
+ frame = append(frame, fastPathActionFastPath|0x04, byte(hdr>>8), byte(hdr))
+ frame = append(frame, events...)
+ return transport.WriteRaw(w, frame)
+}
diff --git a/backend/pkg/rdp/rle/bpp1.go b/backend/pkg/rdp/rle/bpp1.go
new file mode 100644
index 0000000..d7fa650
--- /dev/null
+++ b/backend/pkg/rdp/rle/bpp1.go
@@ -0,0 +1,255 @@
+package rle
+
+import (
+ "fmt"
+ "sync"
+)
+
+func decompress1(output *[]uint8, width, height int, input []uint8, size int) bool {
+ var (
+ prevline, line, count int
+ offset, code int
+ x int = width
+ opcode int
+ lastopcode int8 = -1
+ insertmix, bicolour, isfillormix bool
+ mixmask, mask uint8
+ colour1, colour2 uint8
+ mix uint8 = 0xff
+ fom_mask uint8
+ )
+ out := *output
+ for len(input) != 0 {
+ fom_mask = 0
+ code = CVAL(&input)
+ opcode = code >> 4
+ /* Handle different opcode forms */
+ switch opcode {
+ case 0xc, 0xd, 0xe:
+ opcode -= 6
+ count = int(code & 0xf)
+ offset = 16
+ break
+ case 0xf:
+ opcode = code & 0xf
+ if opcode < 9 {
+ count = int(CVAL(&input))
+ count |= int(CVAL(&input) << 8)
+ } else {
+ count = 1
+ if opcode < 0xb {
+ count = 8
+ }
+ }
+ offset = 0
+ break
+ default:
+ opcode >>= 1
+ count = int(code & 0x1f)
+ offset = 32
+ break
+ }
+ /* Handle strange cases for counts */
+ if offset != 0 {
+ isfillormix = ((opcode == 2) || (opcode == 7))
+ if count == 0 {
+ if isfillormix {
+ count = int(CVAL(&input)) + 1
+ } else {
+ count = int(CVAL(&input) + offset)
+ }
+ } else if isfillormix {
+ count <<= 3
+ }
+ }
+ /* Read preliminary data */
+ switch opcode {
+ case 0: /* Fill */
+ if (lastopcode == int8(opcode)) && !((x == width) && (prevline == 0)) {
+ insertmix = true
+ }
+ break
+ case 8: /* Bicolour */
+ colour1 = uint8(CVAL(&input))
+ colour2 = uint8(CVAL(&input))
+ break
+ case 3: /* Colour */
+ colour2 = uint8(CVAL(&input))
+ break
+ case 6: /* SetMix/Mix */
+ fallthrough
+ case 7: /* SetMix/FillOrMix */
+ mix = uint8(CVAL(&input))
+ opcode -= 5
+ break
+ case 9: /* FillOrMix_1 */
+ mask = 0x03
+ opcode = 0x02
+ fom_mask = 3
+ break
+ case 0x0a: /* FillOrMix_2 */
+ mask = 0x05
+ opcode = 0x02
+ fom_mask = 5
+ break
+ }
+ lastopcode = int8(opcode)
+ mixmask = 0
+ /* Output body */
+ for count > 0 {
+ if x >= width {
+ if height <= 0 {
+ return false
+ }
+
+ x = 0
+ height--
+ prevline = line
+ line = height * width
+ }
+ switch opcode {
+ case 0: /* Fill */
+ if insertmix {
+ if prevline == 0 {
+ out[x+line] = mix
+ } else {
+ out[x+line] = out[prevline+x] ^ mix
+ }
+ insertmix = false
+ count--
+ x++
+ }
+ n := min(count, width-x)
+ if prevline == 0 {
+ clear(out[x+line : x+line+n])
+ } else {
+ copy(out[x+line:x+line+n], out[prevline+x:prevline+x+n])
+ }
+ count -= n
+ x += n
+ break
+ case 1: /* Mix */
+ n := min(count, width-x)
+ if prevline == 0 {
+ seg := out[x+line : x+line+n]
+ for i := range seg {
+ seg[i] = mix
+ }
+ } else {
+ src := out[prevline+x : prevline+x+n]
+ dst := out[x+line : x+line+n]
+ for i := range dst {
+ dst[i] = src[i] ^ mix
+ }
+ }
+ count -= n
+ x += n
+ break
+ case 2: /* Fill or Mix */
+ if prevline == 0 {
+ for count > 0 && x < width {
+ mixmask <<= 1
+ if mixmask == 0 {
+ mask = fom_mask
+ if fom_mask == 0 {
+ mask = uint8(CVAL(&input))
+ mixmask = 1
+ }
+ }
+ if mask&mixmask != 0 {
+ out[x+line] = mix
+ } else {
+ out[x+line] = 0
+ }
+
+ count--
+ x++
+ }
+ } else {
+ for count > 0 && x < width {
+ mixmask = mixmask << 1
+ if mixmask == 0 {
+ mask = fom_mask
+ if fom_mask == 0 {
+ mask = uint8(CVAL(&input))
+ mixmask = 1
+ }
+ }
+ if mask&mixmask != 0 {
+ out[x+line] = out[prevline+x] ^ mix
+ } else {
+ out[x+line] = out[prevline+x]
+ }
+
+ count--
+ x++
+ }
+ }
+ break
+ case 3: /* Colour */
+ n := min(count, width-x)
+ seg := out[x+line : x+line+n]
+ for i := range seg {
+ seg[i] = colour2
+ }
+ count -= n
+ x += n
+ break
+ case 4: /* Copy */
+ for count > 0 && x < width {
+ n := min(count, width-x)
+ if len(input) < n {
+ return false
+ }
+ copy(out[x+line:x+line+n], input[:n])
+ input = input[n:]
+ count -= n
+ x += n
+ }
+ break
+ case 8: /* Bicolour */
+ for count > 0 && x < width {
+ if bicolour {
+ out[x+line] = colour2
+ bicolour = false
+ } else {
+ out[x+line] = colour1
+ bicolour = true
+ count++
+ }
+
+ count--
+ x++
+ }
+
+ break
+
+ case 0xd: /* White */
+ n := min(count, width-x)
+ seg := out[x+line : x+line+n]
+ for i := range seg {
+ seg[i] = 0xff
+ }
+ count -= n
+ x += n
+ break
+ case 0xe: /* Black */
+ n := min(count, width-x)
+ clear(out[x+line : x+line+n])
+ count -= n
+ x += n
+ break
+ default:
+ fmt.Printf("bitmap opcode 0x%x\n", opcode)
+ return false
+ }
+ }
+ }
+ return true
+}
+
+// decompress2Pool reuses the intermediate []uint16 buffer across calls to
+// decompress2, avoiding a large per-frame allocation.
+var decompress2Pool sync.Pool
+
+/* 2 byte bitmap decompress */
diff --git a/backend/pkg/rdp/rle/bpp2.go b/backend/pkg/rdp/rle/bpp2.go
new file mode 100644
index 0000000..cc88e9f
--- /dev/null
+++ b/backend/pkg/rdp/rle/bpp2.go
@@ -0,0 +1,272 @@
+package rle
+
+import (
+ "fmt"
+ "unsafe"
+)
+
+func decompress2(output *[]uint8, width, height int, input []uint8, size int) bool {
+ needed := width * height
+
+ var out []uint16
+ if v := decompress2Pool.Get(); v != nil {
+ out = v.([]uint16)
+ if cap(out) < needed {
+ out = make([]uint16, needed)
+ } else {
+ out = out[:needed]
+ }
+ } else {
+ out = make([]uint16, needed)
+ }
+ defer func() { decompress2Pool.Put(out[:cap(out)]) }()
+
+ var (
+ prevline, line, count int
+ offset, code int
+ x int = width
+ opcode int
+ lastopcode int = -1
+ insertmix, bicolour, isfillormix bool
+ mixmask, mask uint8
+ colour1, colour2 uint16
+ mix uint16 = 0xffff
+ fom_mask uint8
+ )
+
+ for len(input) != 0 {
+ fom_mask = 0
+ code = CVAL(&input)
+ opcode = code >> 4
+ /* Handle different opcode forms */
+ switch opcode {
+ case 0xc, 0xd, 0xe:
+ opcode -= 6
+ count = code & 0xf
+ offset = 16
+ break
+ case 0xf:
+ opcode = code & 0xf
+ if opcode < 9 {
+ count = CVAL(&input)
+ count |= CVAL(&input) << 8
+ } else {
+ count = 1
+ if opcode < 0xb {
+ count = 8
+ }
+ }
+ offset = 0
+ break
+ default:
+ opcode >>= 1
+ count = code & 0x1f
+ offset = 32
+ break
+ }
+
+ /* Handle strange cases for counts */
+ if offset != 0 {
+ isfillormix = ((opcode == 2) || (opcode == 7))
+ if count == 0 {
+ if isfillormix {
+ count = CVAL(&input) + 1
+ } else {
+ count = CVAL(&input) + offset
+ }
+ } else if isfillormix {
+ count <<= 3
+ }
+ }
+ /* Read preliminary data */
+ switch opcode {
+ case 0: /* Fill */
+ if (lastopcode == opcode) && !((x == width) && (prevline == 0)) {
+ insertmix = true
+ }
+ break
+ case 8: /* Bicolour */
+ CVAL2(&input, &colour1)
+ CVAL2(&input, &colour2)
+ break
+ case 3: /* Colour */
+ CVAL2(&input, &colour2)
+ break
+ case 6: /* SetMix/Mix */
+ fallthrough
+ case 7: /* SetMix/FillOrMix */
+ CVAL2(&input, &mix)
+ opcode -= 5
+ break
+ case 9: /* FillOrMix_1 */
+ mask = 0x03
+ opcode = 0x02
+ fom_mask = 3
+ break
+ case 0x0a: /* FillOrMix_2 */
+ mask = 0x05
+ opcode = 0x02
+ fom_mask = 5
+ break
+ }
+ lastopcode = opcode
+ mixmask = 0
+ /* Output body */
+ for count > 0 {
+ if x >= width {
+ if height <= 0 {
+ return false
+ }
+
+ x = 0
+ height--
+ prevline = line
+ line = height * width
+ }
+ switch opcode {
+ case 0: /* Fill */
+ if insertmix {
+ if prevline == 0 {
+ out[x+line] = mix
+ } else {
+ out[x+line] = out[prevline+x] ^ mix
+ }
+ insertmix = false
+ count--
+ x++
+ }
+ n := min(count, width-x)
+ if prevline == 0 {
+ clear(out[x+line : x+line+n])
+ } else {
+ copy(out[x+line:x+line+n], out[prevline+x:prevline+x+n])
+ }
+ count -= n
+ x += n
+ break
+ case 1: /* Mix */
+ n := min(count, width-x)
+ if prevline == 0 {
+ seg := out[x+line : x+line+n]
+ for i := range seg {
+ seg[i] = mix
+ }
+ } else {
+ src := out[prevline+x : prevline+x+n]
+ dst := out[x+line : x+line+n]
+ for i := range dst {
+ dst[i] = src[i] ^ mix
+ }
+ }
+ count -= n
+ x += n
+ break
+ case 2: /* Fill or Mix */
+ if prevline == 0 {
+ for count > 0 && x < width {
+ mixmask <<= 1
+ if mixmask == 0 {
+ mask = fom_mask
+ if fom_mask == 0 {
+ mask = uint8(CVAL(&input))
+ mixmask = 1
+ }
+ }
+ if mask&mixmask != 0 {
+ out[x+line] = mix
+ } else {
+ out[x+line] = 0
+ }
+
+ count--
+ x++
+ }
+ } else {
+ for count > 0 && x < width {
+ mixmask = mixmask << 1
+ if mixmask == 0 {
+ mask = fom_mask
+ if fom_mask == 0 {
+ mask = uint8(CVAL(&input))
+ mixmask = 1
+ }
+ }
+ if mask&mixmask != 0 {
+ out[x+line] = out[prevline+x] ^ mix
+ } else {
+ out[x+line] = out[prevline+x]
+ }
+
+ count--
+ x++
+ }
+ }
+ break
+ case 3: /* Colour */
+ n := min(count, width-x)
+ seg := out[x+line : x+line+n]
+ for i := range seg {
+ seg[i] = colour2
+ }
+ count -= n
+ x += n
+ break
+ case 4: /* Copy */
+ for count > 0 && x < width {
+ n := min(count, width-x)
+ if len(input) < n*2 {
+ return false
+ }
+ copy(out[x+line:x+line+n], unsafe.Slice((*uint16)(unsafe.Pointer(&input[0])), n))
+ input = input[n*2:]
+ count -= n
+ x += n
+ }
+
+ break
+ case 8: /* Bicolour */
+ for count > 0 && x < width {
+ if bicolour {
+ out[x+line] = colour2
+ bicolour = false
+ } else {
+ out[x+line] = colour1
+ bicolour = true
+ count++
+ }
+
+ count--
+ x++
+ }
+
+ break
+ case 0xd: /* White */
+ n2 := min(count, width-x)
+ seg2 := out[x+line : x+line+n2]
+ for i := range seg2 {
+ seg2[i] = 0xffff
+ }
+ count -= n2
+ x += n2
+ break
+ case 0xe: /* Black */
+ n3 := min(count, width-x)
+ clear(out[x+line : x+line+n3])
+ count -= n3
+ x += n3
+ break
+ default:
+ fmt.Printf("bitmap opcode 0x%x\n", opcode)
+ return false
+ }
+ }
+ }
+ outBytes := *output
+ for i, v := range out {
+ outBytes[i*2] = byte(v >> 8)
+ outBytes[i*2+1] = byte(v)
+ }
+ return true
+}
+
+// /* 3 byte bitmap decompress */
diff --git a/backend/pkg/rdp/rle/bpp3.go b/backend/pkg/rdp/rle/bpp3.go
new file mode 100644
index 0000000..de94a69
--- /dev/null
+++ b/backend/pkg/rdp/rle/bpp3.go
@@ -0,0 +1,430 @@
+package rle
+
+import "fmt"
+
+func decompress3(output *[]uint8, width, height int, input []uint8, size int) bool {
+ var (
+ prevline, line, count int
+ opcode, offset, code int
+ x int = width
+ lastopcode int = -1
+ insertmix, bicolour, isfillormix bool
+ mixmask, mask uint8
+ colour1 = [3]uint8{0, 0, 0}
+ colour2 = [3]uint8{0, 0, 0}
+ mix = [3]uint8{0xff, 0xff, 0xff}
+ fom_mask uint8
+ )
+ out := *output
+ for len(input) != 0 {
+ fom_mask = 0
+ code = CVAL(&input)
+ opcode = code >> 4
+ /* Handle different opcode forms */
+ switch opcode {
+ case 0xc, 0xd, 0xe:
+ opcode -= 6
+ count = code & 0xf
+ offset = 16
+ break
+ case 0xf:
+ opcode = code & 0xf
+ if opcode < 9 {
+ count = CVAL(&input)
+ count |= CVAL(&input) << 8
+ } else {
+ count = 1
+ if opcode < 0xb {
+ count = 8
+ }
+ }
+ offset = 0
+ break
+ default:
+ opcode >>= 1
+ count = code & 0x1f
+ offset = 32
+ break
+ }
+
+ /* Handle strange cases for counts */
+ if offset != 0 {
+ isfillormix = ((opcode == 2) || (opcode == 7))
+ if count == 0 {
+ if isfillormix {
+ count = CVAL(&input) + 1
+ } else {
+ count = CVAL(&input) + offset
+ }
+ } else if isfillormix {
+ count <<= 3
+ }
+ }
+ /* Read preliminary data */
+ switch opcode {
+ case 0: /* Fill */
+ if (lastopcode == opcode) && !((x == width) && (prevline == 0)) {
+ insertmix = true
+ }
+ break
+ case 8: /* Bicolour */
+ CVAL3(&input, &colour1)
+ CVAL3(&input, &colour2)
+ break
+ case 3: /* Colour */
+ CVAL3(&input, &colour2)
+ break
+ case 6: /* SetMix/Mix */
+ fallthrough
+ case 7: /* SetMix/FillOrMix */
+ CVAL3(&input, &mix)
+ opcode -= 5
+ break
+ case 9: /* FillOrMix_1 */
+ mask = 0x03
+ opcode = 0x02
+ fom_mask = 3
+ break
+ case 0x0a: /* FillOrMix_2 */
+ mask = 0x05
+ opcode = 0x02
+ fom_mask = 5
+ break
+ }
+
+ lastopcode = opcode
+ mixmask = 0
+ /* Output body */
+ for count > 0 {
+ if x >= width {
+ if height <= 0 {
+ return false
+ }
+
+ x = 0
+ height--
+ prevline = line
+ line = height * width * 3
+ }
+ switch opcode {
+ case 0: /* Fill */
+ if insertmix {
+ if prevline == 0 {
+ out[3*x+line] = mix[0]
+ out[3*x+line+1] = mix[1]
+ out[3*x+line+2] = mix[2]
+ } else {
+ out[3*x+line] = out[prevline+3*x] ^ mix[0]
+ out[3*x+line+1] = out[prevline+3*x+1] ^ mix[1]
+ out[3*x+line+2] = out[prevline+3*x+2] ^ mix[2]
+ }
+ insertmix = false
+ count--
+ x++
+ }
+ n := min(count, width-x)
+ if prevline == 0 {
+ clear(out[3*x+line : 3*x+line+3*n])
+ } else {
+ dstBase := 3*x + line
+ srcBase := prevline + 3*x
+ copy(out[dstBase:dstBase+3*n], out[srcBase:srcBase+3*n])
+ }
+ count -= n
+ x += n
+ break
+ case 1: /* Mix */
+ n := min(count, width-x)
+ dst1 := out[3*x+line : 3*x+line+3*n]
+ if prevline == 0 {
+ // Exponential-doubling copy: O(log n) memcpy calls
+ dst1[0], dst1[1], dst1[2] = mix[0], mix[1], mix[2]
+ for wrote := 3; wrote < len(dst1); {
+ wrote += copy(dst1[wrote:], dst1[:wrote])
+ }
+ } else {
+ src1 := out[prevline+3*x : prevline+3*x+3*n]
+ for i := 0; i+2 < len(dst1); i += 3 {
+ dst1[i] = src1[i] ^ mix[0]
+ dst1[i+1] = src1[i+1] ^ mix[1]
+ dst1[i+2] = src1[i+2] ^ mix[2]
+ }
+ }
+ count -= n
+ x += n
+ break
+ case 2: /* Fill or Mix */
+ if prevline == 0 {
+ base := 3*x + line
+ for count > 0 && x < width {
+ mixmask = mixmask << 1
+ if mixmask == 0 {
+ mask = fom_mask
+ if fom_mask == 0 {
+ mask = uint8(CVAL(&input))
+ mixmask = 1
+ }
+ }
+ if mask&mixmask != 0 {
+ out[base] = mix[0]
+ out[base+1] = mix[1]
+ out[base+2] = mix[2]
+ } else {
+ out[base] = 0
+ out[base+1] = 0
+ out[base+2] = 0
+ }
+ base += 3
+ count--
+ x++
+ }
+ } else {
+ base := 3*x + line
+ prev := prevline + 3*x
+ for count > 0 && x < width {
+ mixmask = mixmask << 1
+ if mixmask == 0 {
+ mask = fom_mask
+ if fom_mask == 0 {
+ mask = uint8(CVAL(&input))
+ mixmask = 1
+ }
+ }
+ if mask&mixmask != 0 {
+ out[base] = out[prev] ^ mix[0]
+ out[base+1] = out[prev+1] ^ mix[1]
+ out[base+2] = out[prev+2] ^ mix[2]
+ } else {
+ out[base] = out[prev]
+ out[base+1] = out[prev+1]
+ out[base+2] = out[prev+2]
+ }
+ base += 3
+ prev += 3
+ count--
+ x++
+ }
+ }
+ break
+ case 3: /* Colour */
+ n := min(count, width-x)
+ seg3 := out[3*x+line : 3*x+line+3*n]
+ seg3[0], seg3[1], seg3[2] = colour2[0], colour2[1], colour2[2]
+ for wrote := 3; wrote < len(seg3); {
+ wrote += copy(seg3[wrote:], seg3[:wrote])
+ }
+ count -= n
+ x += n
+ break
+ case 4: /* Copy */
+ for count > 0 && x < width {
+ n := min(count, width-x)
+ if len(input) < n*3 {
+ return false
+ }
+ copy(out[3*x+line:3*x+line+n*3], input[:n*3])
+ input = input[n*3:]
+ count -= n
+ x += n
+ }
+ break
+ case 8: /* Bicolour */
+ base8 := 3*x + line
+ for count > 0 && x < width {
+ if bicolour {
+ out[base8] = colour2[0]
+ out[base8+1] = colour2[1]
+ out[base8+2] = colour2[2]
+ bicolour = false
+ } else {
+ out[base8] = colour1[0]
+ out[base8+1] = colour1[1]
+ out[base8+2] = colour1[2]
+ bicolour = true
+ count++
+ }
+ base8 += 3
+ count--
+ x++
+ }
+ break
+ case 0xd: /* White */
+ n3 := min(count, width-x)
+ seg3 := out[3*x+line : 3*x+line+3*n3]
+ for i := range seg3 {
+ seg3[i] = 0xff
+ }
+ count -= n3
+ x += n3
+ break
+ case 0xe: /* Black */
+ n2 := min(count, width-x)
+ clear(out[3*x+line : 3*x+line+3*n2])
+ count -= n2
+ x += n2
+ break
+ default:
+ fmt.Printf("bitmap opcode 0x%x\n", opcode)
+ return false
+ }
+ }
+ }
+
+ return true
+}
+
+/* decompress a colour plane */
+func processPlane(in *[]uint8, width, height int, output *[]uint8, j int) (int, bool) {
+ var (
+ indexw int
+ indexh int
+ code int
+ collen int
+ replen int
+ color uint8
+ x uint8
+ revcode int
+ lastline int
+ thisline int
+ )
+ ln := len(*in)
+ out := *output // hoist pointer dereference; writes to out[i] affect the underlying array
+
+ readByte := func() (uint8, bool) {
+ if len(*in) == 0 {
+ return 0, false
+ }
+ v := (*in)[0]
+ *in = (*in)[1:]
+ return v, true
+ }
+ writePixel := func(pos int, value uint8) bool {
+ if pos < 0 || pos >= len(out) {
+ return false
+ }
+ out[pos] = value
+ return true
+ }
+
+ lastline = 0
+ indexh = 0
+ i := 0
+ for indexh < height {
+ thisline = j + (width * height * 4) - ((indexh + 1) * width * 4)
+ color = 0
+ indexw = 0
+ i = thisline
+
+ if lastline == 0 {
+ for indexw < width {
+ b, ok := readByte()
+ if !ok {
+ return ln - len(*in), false
+ }
+ code = int(b)
+ replen = int(code & 0xf)
+ collen = int((code >> 4) & 0xf)
+ revcode = (replen << 4) | collen
+ if (revcode <= 47) && (revcode >= 16) {
+ replen = revcode
+ collen = 0
+ }
+ for collen > 0 {
+ if indexw >= width {
+ return ln - len(*in), false
+ }
+ v, ok := readByte()
+ if !ok {
+ return ln - len(*in), false
+ }
+ color = v
+ if !writePixel(i, color) {
+ return ln - len(*in), false
+ }
+ i += 4
+
+ indexw++
+ collen--
+ }
+ for replen > 0 {
+ if indexw >= width {
+ break
+ }
+ if !writePixel(i, color) {
+ return ln - len(*in), false
+ }
+ i += 4
+ indexw++
+ replen--
+ }
+ }
+ } else {
+ // prevOffset is constant per row: indexw*4+lastline == i+(lastline-thisline)
+ // because i == thisline+indexw*4. Pre-computing it eliminates a multiply
+ // per pixel in both inner loops.
+ prevOffset := lastline - thisline
+ for indexw < width {
+ b, ok := readByte()
+ if !ok {
+ return ln - len(*in), false
+ }
+ code = int(b)
+ replen = int(code & 0xf)
+ collen = int((code >> 4) & 0xf)
+ revcode = (replen << 4) | collen
+ if (revcode <= 47) && (revcode >= 16) {
+ replen = revcode
+ collen = 0
+ }
+ for collen > 0 {
+ if indexw >= width {
+ return ln - len(*in), false
+ }
+ v, ok := readByte()
+ if !ok {
+ return ln - len(*in), false
+ }
+ x = v
+ if x&1 != 0 {
+ x = x >> 1
+ x = x + 1
+ color = -x
+ } else {
+ x = x >> 1
+ color = x
+ }
+ if i+prevOffset < 0 || i+prevOffset >= len(out) {
+ return ln - len(*in), false
+ }
+ x = out[i+prevOffset] + color
+ if !writePixel(i, x) {
+ return ln - len(*in), false
+ }
+ i += 4
+ indexw++
+ collen--
+ }
+ for replen > 0 {
+ if indexw >= width {
+ break
+ }
+ if i+prevOffset < 0 || i+prevOffset >= len(out) {
+ return ln - len(*in), false
+ }
+ x = out[i+prevOffset] + color
+ if !writePixel(i, x) {
+ return ln - len(*in), false
+ }
+ i += 4
+ indexw++
+ replen--
+ }
+ }
+ }
+ indexh++
+ lastline = thisline
+ }
+ return ln - len(*in), true
+}
+
+/* 4 byte bitmap decompress */
diff --git a/backend/pkg/rdp/rle/bpp4.go b/backend/pkg/rdp/rle/bpp4.go
new file mode 100644
index 0000000..d0d3062
--- /dev/null
+++ b/backend/pkg/rdp/rle/bpp4.go
@@ -0,0 +1,58 @@
+package rle
+
+func decompress4(output *[]uint8, width, height int, input []uint8, size int) bool {
+ var (
+ code int
+ onceBytes, total int
+ )
+
+ code = CVAL(&input)
+ rle := code&0x10 != 0
+ noAlpha := code&0x20 != 0
+
+ if !rle {
+ return false
+ }
+
+ total = 1
+ out := *output
+
+ if noAlpha {
+ // No alpha plane in the stream; fill alpha channel with 0xFF.
+ for i := 3; i < len(out); i += 4 {
+ out[i] = 0xFF
+ }
+ } else {
+ var ok bool
+ onceBytes, ok = processPlane(&input, width, height, output, 3)
+ if !ok {
+ return false
+ }
+ total += onceBytes
+ }
+
+ var ok bool
+ onceBytes, ok = processPlane(&input, width, height, output, 2)
+ if !ok {
+ return false
+ }
+ total += onceBytes
+
+ onceBytes, ok = processPlane(&input, width, height, output, 1)
+ if !ok {
+ return false
+ }
+ total += onceBytes
+
+ onceBytes, ok = processPlane(&input, width, height, output, 0)
+ if !ok {
+ return false
+ }
+ total += onceBytes
+
+ return true
+}
+
+// DecompressInto decompresses bitmap data into dst, reusing dst if it has
+// sufficient capacity (size = width*height*bpp). If dst is nil or too small
+// a new slice is allocated. Returns the (re)used output slice.
diff --git a/backend/pkg/rdp/rle/entry.go b/backend/pkg/rdp/rle/entry.go
new file mode 100644
index 0000000..eb4488f
--- /dev/null
+++ b/backend/pkg/rdp/rle/entry.go
@@ -0,0 +1,30 @@
+package rle
+
+import "fmt"
+
+func DecompressInto(input []uint8, dst []uint8, width, height int, bpp int) []uint8 {
+ size := width * height * bpp
+ if cap(dst) >= size {
+ dst = dst[:size]
+ } else {
+ dst = make([]uint8, size)
+ }
+ ok := true
+ switch bpp {
+ case 1:
+ ok = decompress1(&dst, width, height, input, size)
+ case 2:
+ ok = decompress2(&dst, width, height, input, size)
+ case 3:
+ ok = decompress3(&dst, width, height, input, size)
+ case 4:
+ ok = decompress4(&dst, width, height, input, size)
+ default:
+ fmt.Printf("bpp %d\n", bpp)
+ ok = false
+ }
+ if !ok {
+ return nil
+ }
+ return dst
+}
diff --git a/backend/pkg/rdp/rle/util.go b/backend/pkg/rdp/rle/util.go
new file mode 100644
index 0000000..e5b0e23
--- /dev/null
+++ b/backend/pkg/rdp/rle/util.go
@@ -0,0 +1,29 @@
+package rle
+
+import "unsafe"
+
+func CVAL(p *[]uint8) int {
+ a := int((*p)[0])
+ *p = (*p)[1:]
+ return a
+}
+
+func CVAL2(p *[]uint8, v *uint16) {
+ *v = *((*uint16)(unsafe.Pointer(&(*p)[0])))
+ *p = (*p)[2:]
+}
+
+func CVAL3(p *[]uint8, v *[3]uint8) {
+ (*v)[0] = (*p)[0]
+ (*v)[1] = (*p)[1]
+ (*v)[2] = (*p)[2]
+ *p = (*p)[3:]
+}
+
+func REPEAT(f func(), count *int, x *int, width int) {
+ for *count > 0 && *x < width {
+ f()
+ *count--
+ *x++
+ }
+}
\ No newline at end of file
diff --git a/backend/pkg/rdp/session_connect.go b/backend/pkg/rdp/session_connect.go
new file mode 100644
index 0000000..ff78e16
--- /dev/null
+++ b/backend/pkg/rdp/session_connect.go
@@ -0,0 +1,169 @@
+package rdp
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "time"
+
+ "ezops/pkg/rdp/credssp"
+ "ezops/pkg/rdp/transport"
+)
+
+// Session 保存已建立的 RDP 图形会话状态 // Logan.Li
+type Session struct {
+ Conn io.ReadWriter
+ UserID uint16
+ SharedID uint32
+ Width int
+ Height int
+}
+
+// OpenSession 完成 CredSSP + MCS/GCC 握手并进入可收帧状态 // Logan.Li
+func OpenSession(ctx context.Context, cfg Config) (*Session, error) {
+ port := cfg.Port
+ if port == 0 {
+ port = 3389
+ }
+ if cfg.MCSMode == "" {
+ cfg.MCSMode = strings.TrimSpace(os.Getenv("RDP_MCS_MODE"))
+ }
+ if cfg.MCSMode == "" {
+ cfg.MCSMode = "grdp-modern"
+ }
+ address := fmt.Sprintf("%s:%d", cfg.Host, port)
+ strategies := []credssp.Options{
+ {Version: 6, PubKeyStyle: "binding"},
+ {Version: 6, PubKeyStyle: "binding", UseCBT: true},
+ {Version: 6, PubKeyStyle: "binding", ForceMIC: true},
+ {Version: 2, PubKeyStyle: "raw"},
+ }
+ var lastErr error
+ for _, opts := range strategies {
+ sess, err := openSessionOnce(ctx, cfg, address, opts)
+ if err == nil {
+ return sess, nil
+ }
+ lastErr = err
+ }
+ if lastErr == nil {
+ lastErr = fmt.Errorf("RDP 会话建立失败")
+ }
+ return nil, lastErr
+}
+
+func openSessionOnce(ctx context.Context, cfg Config, address string, opts credssp.Options) (*Session, error) {
+ tlsConn, _, err := dialCredSSPOnce(ctx, cfg, address, opts)
+ if err != nil {
+ return nil, err
+ }
+ _ = tlsConn.SetDeadline(time.Now().Add(45 * time.Second))
+ if err := transport.WriteX224Data(tlsConn, buildMCSConnectInitial(cfg)); err != nil {
+ _ = tlsConn.Close()
+ return nil, err
+ }
+ payload, err := transport.ReadX224Data(tlsConn)
+ if err != nil {
+ _ = tlsConn.Close()
+ return nil, err
+ }
+ if _, err := parseMCSConnectResponse(payload); err != nil {
+ _ = tlsConn.Close()
+ return nil, err
+ }
+ userID, err := attachMCSUser(tlsConn)
+ if err != nil {
+ _ = tlsConn.Close()
+ return nil, err
+ }
+ mcsInfo, _ := parseMCSConnectResponse(payload)
+ joinIDs := append([]uint16{mcsGlobalChannelID, userID}, mcsInfo.ServerChannelIDs...)
+ for _, channelID := range joinIDs {
+ if _, err := joinMCSChannel(tlsConn, userID, channelID); err != nil {
+ _ = tlsConn.Close()
+ return nil, err
+ }
+ }
+ _, demand, err := sendClientInfoAndReadDemand(tlsConn, cfg, userID)
+ if err != nil {
+ _ = tlsConn.Close()
+ return nil, err
+ }
+ if _, err := sendConfirmActiveAndFinalize(tlsConn, cfg, userID, demand); err != nil {
+ _ = tlsConn.Close()
+ return nil, err
+ }
+ if err := requestDisplayUpdates(tlsConn, cfg, userID, demand.sharedID); err != nil {
+ _ = tlsConn.Close()
+ return nil, err
+ }
+ width := cfg.Width
+ if width <= 0 {
+ width = 1280
+ }
+ height := cfg.Height
+ if height <= 0 {
+ height = 800
+ }
+ _ = tlsConn.SetDeadline(time.Time{})
+ return &Session{
+ Conn: tlsConn,
+ UserID: userID,
+ SharedID: demand.sharedID,
+ Width: width,
+ Height: height,
+ }, nil
+}
+
+func requestDisplayUpdates(rw io.ReadWriter, cfg Config, userID uint16, sharedID uint32) error {
+ if conn, ok := rw.(interface{ SetReadDeadline(time.Time) error }); ok {
+ _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
+ pkt, err := ReadWirePacket(rw)
+ _ = conn.SetReadDeadline(time.Time{})
+ if err == nil && !pkt.FastPath {
+ _, _ = parseMCSDataIndication(pkt.Payload)
+ }
+ }
+ body := buildSuppressOutputBody(cfg, true)
+ pdu := buildDataPDU(userID, sharedID, pduType2SuppressOutput, body)
+ if err := transport.WriteX224Data(rw, buildMCSSendDataRequest(userID, mcsGlobalChannelID, pdu)); err != nil {
+ return fmt.Errorf("发送 SuppressOutput 失败: %w", err)
+ }
+ return nil
+}
+
+func buildSuppressOutputBody(cfg Config, allow bool) []byte {
+ width := cfg.Width
+ if width <= 0 {
+ width = 1280
+ }
+ height := cfg.Height
+ if height <= 0 {
+ height = 800
+ }
+ out := make([]byte, 0, 12)
+ if allow {
+ out = append(out, 1, 0, 0, 0)
+ } else {
+ out = append(out, 0, 0, 0, 0)
+ }
+ out = append(out, 0, 0, 0, 0, byte(width-1), byte((width-1)>>8), byte(height-1), byte((height-1)>>8))
+ return out
+}
+
+func buildRefreshRectBody(cfg Config) []byte {
+ width := cfg.Width
+ if width <= 0 {
+ width = 1280
+ }
+ height := cfg.Height
+ if height <= 0 {
+ height = 800
+ }
+ out := make([]byte, 0, 10)
+ out = append(out, 1, 0)
+ out = append(out, 0, 0, 0, 0, byte(width-1), byte((width-1)>>8), byte(height-1), byte((height-1)>>8))
+ return out
+}
diff --git a/backend/pkg/rdp/session_engine.go b/backend/pkg/rdp/session_engine.go
new file mode 100644
index 0000000..caeae0f
--- /dev/null
+++ b/backend/pkg/rdp/session_engine.go
@@ -0,0 +1,295 @@
+package rdp
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "sync"
+ "time"
+
+ "ezops/pkg/rdp/display"
+ "ezops/pkg/rdp/trace"
+ "ezops/pkg/rdp/transport"
+)
+
+// SessionEngine 是自研完整 RDP 图形会话引擎 // Logan.Li
+type SessionEngine struct{}
+
+func (e *SessionEngine) Run(ctx context.Context, cfg Config, sink FrameSink, events <-chan ClientEvent) error {
+ if cfg.Port == 0 {
+ cfg.Port = 3389
+ }
+ trace.Logf("engine=rdp connect target=%s:%d size=%dx%d security=%s", cfg.Host, cfg.Port, cfg.Width, cfg.Height, cfg.Security)
+ _ = sink.SendFrame(ServerFrame{
+ Type: "status",
+ Message: fmt.Sprintf("正在连接 RDP %s:%d", cfg.Host, cfg.Port),
+ })
+
+ sess, err := OpenSession(ctx, cfg)
+ if err != nil {
+ trace.Logf("engine=rdp open failed target=%s:%d err=%v", cfg.Host, cfg.Port, err)
+ _ = sink.SendFrame(ServerFrame{Type: "error", Message: "RDP 会话建立失败:" + err.Error()})
+ return err
+ }
+ defer func() {
+ if c, ok := sess.Conn.(io.Closer); ok {
+ _ = c.Close()
+ }
+ }()
+
+ _ = sink.SendFrame(ServerFrame{Type: "status", Message: "RDP 图形会话已建立,等待桌面画面..."})
+ trace.Logf("engine=rdp open ok target=%s:%d", cfg.Host, cfg.Port)
+ writeMu := &sync.Mutex{}
+ input := NewInputSenderWithLock(sess.Conn, writeMu)
+ receiver := NewFastPathReceiver()
+
+ readErr := make(chan error, 1)
+ go func() {
+ readErr <- e.readLoop(ctx, sess, receiver, sink, writeMu)
+ }()
+ go e.inputLoop(ctx, input, sink, events)
+
+ for {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case err := <-readErr:
+ if err != nil {
+ _ = sink.SendFrame(ServerFrame{Type: "error", Message: err.Error()})
+ }
+ return err
+ }
+ }
+}
+
+func (e *SessionEngine) inputLoop(ctx context.Context, input *InputSender, sink FrameSink, events <-chan ClientEvent) {
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case event, ok := <-events:
+ if !ok {
+ return
+ }
+ if event.Type != "mousemove" && event.Type != "mouse_move" {
+ trace.Logf("engine=rdp input type=%s x=%d y=%d button=%d code=%s down=%t", event.Type, event.X, event.Y, event.Button, event.Code, event.Down)
+ }
+ if err := e.handleEvent(input, sink, event); err != nil {
+ _ = sink.SendFrame(ServerFrame{Type: "status", Message: "输入事件失败:" + err.Error()})
+ }
+ }
+ }
+}
+
+func (e *SessionEngine) readLoop(ctx context.Context, sess *Session, receiver *FastPathReceiver, sink FrameSink, writeMu *sync.Mutex) error {
+ framebuffer := display.NewFramebuffer(sess.Width, sess.Height)
+ ticker := time.NewTicker(16 * time.Millisecond)
+ defer ticker.Stop()
+ refreshRequested := false
+ flushFrame := func() {
+ frame, ok := framebuffer.FlushRGBA()
+ if !ok {
+ return
+ }
+ _ = sink.SendFrame(ServerFrame{
+ Type: "bitmap",
+ X: frame.X,
+ Y: frame.Y,
+ Width: frame.Width,
+ Height: frame.Height,
+ RGBA: frame.RGBA,
+ })
+ }
+ sendBitmaps := func(rects []BitmapRect) {
+ for _, rect := range rects {
+ if rect.Width <= 0 || rect.Height <= 0 {
+ continue
+ }
+ if !refreshRequested && isBlackFullScreen(rect, sess.Width, sess.Height) {
+ refreshRequested = true
+ go func() {
+ time.Sleep(400 * time.Millisecond)
+ if err := e.requestRefresh(sess, writeMu); err != nil {
+ trace.Logf("engine=rdp refresh request failed err=%v", err)
+ }
+ }()
+ }
+ framebuffer.Draw(display.Bitmap{
+ X: rect.DestLeft,
+ Y: rect.DestTop,
+ Width: rect.Width,
+ Height: rect.Height,
+ BitsPerPixel: rect.BitsPerPixel,
+ Data: rect.Data,
+ })
+ }
+ }
+
+ type packetResult struct {
+ packet WirePacket
+ err error
+ }
+ packetCh := make(chan packetResult, 1)
+ go func() {
+ for {
+ pkt, err := ReadWirePacket(sess.Conn)
+ select {
+ case packetCh <- packetResult{packet: pkt, err: err}:
+ case <-ctx.Done():
+ return
+ }
+ if err != nil {
+ return
+ }
+ }
+ }()
+
+ for {
+ select {
+ case <-ctx.Done():
+ flushFrame()
+ return ctx.Err()
+ case <-ticker.C:
+ flushFrame()
+ case got := <-packetCh:
+ if got.err != nil {
+ flushFrame()
+ return got.err
+ }
+ var rects []BitmapRect
+ var err error
+ if got.packet.FastPath {
+ rects, err = receiver.Handle(got.packet.SecFlag, got.packet.Payload)
+ } else {
+ rects, err = receiver.HandleSlowPath(got.packet.Payload)
+ }
+ if err != nil {
+ continue
+ }
+ if err := e.ackSurfaceFrames(sess, receiver.TakeFrameIDs(), writeMu); err != nil {
+ trace.Logf("engine=rdp frame ack failed err=%v", err)
+ }
+ if len(rects) > 0 {
+ trace.Logf("engine=rdp bitmap rects=%d fastpath=%t", len(rects), got.packet.FastPath)
+ sendBitmaps(rects)
+ }
+ }
+ }
+}
+
+func isBlackFullScreen(rect BitmapRect, width, height int) bool {
+ if rect.DestLeft != 0 || rect.DestTop != 0 || rect.Width < width || rect.Height < height {
+ return false
+ }
+ limit := len(rect.Data)
+ if limit > 256*1024 {
+ limit = 256 * 1024
+ }
+ for i := 0; i < limit; i++ {
+ if rect.Data[i] > 4 {
+ return false
+ }
+ }
+ return limit > 0
+}
+
+func (e *SessionEngine) requestRefresh(sess *Session, writeMu *sync.Mutex) error {
+ body := buildRefreshRectBody(Config{Width: sess.Width, Height: sess.Height})
+ pdu := buildDataPDU(sess.UserID, sess.SharedID, pduType2RefreshRect, body)
+ trace.Logf("engine=rdp refresh rect %dx%d", sess.Width, sess.Height)
+ writeMu.Lock()
+ err := transport.WriteX224Data(sess.Conn, buildMCSSendDataRequest(sess.UserID, mcsGlobalChannelID, pdu))
+ writeMu.Unlock()
+ return err
+}
+
+func (e *SessionEngine) ackSurfaceFrames(sess *Session, frameIDs []uint32, writeMu *sync.Mutex) error {
+ for _, frameID := range frameIDs {
+ body := []byte{byte(frameID), byte(frameID >> 8), byte(frameID >> 16), byte(frameID >> 24)}
+ pdu := buildDataPDU(sess.UserID, sess.SharedID, pduType2FrameAcknowledge, body)
+ trace.Logf("engine=rdp frame ack frameID=%d", frameID)
+ writeMu.Lock()
+ err := transport.WriteX224Data(sess.Conn, buildMCSSendDataRequest(sess.UserID, mcsGlobalChannelID, pdu))
+ writeMu.Unlock()
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (e *SessionEngine) handleEvent(input *InputSender, sink FrameSink, event ClientEvent) error {
+ switch event.Type {
+ case "mousemove", "mouse_move":
+ return input.MouseMove(event.X, event.Y)
+ case "mousedown", "mouse_down":
+ return input.MouseButton(event.X, event.Y, event.Button, true)
+ case "mouseup", "mouse_up":
+ return input.MouseButton(event.X, event.Y, event.Button, false)
+ case "wheel":
+ return input.MouseWheel(event.X, event.Y, event.DeltaY)
+ case "key":
+ sc, ok := ScanCodeFromJS(event.Code)
+ if !ok {
+ return nil
+ }
+ return input.Key(sc, event.Down)
+ case "clipboard":
+ if event.Text != "" {
+ return input.TypeText(event.Text)
+ }
+ return nil
+ case "auto_exec":
+ if event.Text == "" {
+ return nil
+ }
+ _ = sink.SendFrame(ServerFrame{Type: "status", Message: "正在远端执行:" + event.Text})
+ return input.AutoExec(event.Text)
+ case "resize":
+ _ = sink.SendFrame(ServerFrame{Type: "status", Message: "分辨率变更请求已记录,重连后生效"})
+ return nil
+ default:
+ return nil
+ }
+}
+
+// ProbeSessionFrames 用于诊断:建立会话后读取若干秒位图帧数量 // Logan.Li
+func ProbeSessionFrames(ctx context.Context, cfg Config, seconds int) (int, error) {
+ sess, err := OpenSession(ctx, cfg)
+ if err != nil {
+ return 0, err
+ }
+ defer func() {
+ if c, ok := sess.Conn.(io.Closer); ok {
+ _ = c.Close()
+ }
+ }()
+ receiver := NewFastPathReceiver()
+ deadline := time.Now().Add(time.Duration(seconds) * time.Second)
+ count := 0
+ for time.Now().Before(deadline) {
+ if c, ok := sess.Conn.(interface{ SetReadDeadline(time.Time) error }); ok {
+ _ = c.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
+ }
+ pkt, err := ReadWirePacket(sess.Conn)
+ if err != nil {
+ if time.Now().Before(deadline) {
+ continue
+ }
+ return count, nil
+ }
+ var rects []BitmapRect
+ if pkt.FastPath {
+ rects, err = receiver.Handle(pkt.SecFlag, pkt.Payload)
+ } else {
+ rects, err = receiver.HandleSlowPath(pkt.Payload)
+ }
+ if err == nil {
+ count += len(rects)
+ }
+ }
+ if c, ok := sess.Conn.(interface{ SetReadDeadline(time.Time) error }); ok {
+ _ = c.SetReadDeadline(time.Time{})
+ }
+ return count, nil
+}
diff --git a/backend/pkg/rdp/trace/trace.go b/backend/pkg/rdp/trace/trace.go
new file mode 100644
index 0000000..9a6ef7c
--- /dev/null
+++ b/backend/pkg/rdp/trace/trace.go
@@ -0,0 +1,21 @@
+package trace
+
+import (
+ "log"
+ "os"
+ "strings"
+)
+
+// Enabled 判断是否开启 RDP 协议级调试日志,默认关闭避免高频帧刷屏 // Logan.Li
+func Enabled() bool {
+ v := strings.ToLower(strings.TrimSpace(os.Getenv("RDP_DEBUG")))
+ return v == "1" || v == "true" || v == "yes" || v == "debug"
+}
+
+// Logf 输出 RDP 调试日志,统一前缀便于 docker logs 过滤 // Logan.Li
+func Logf(format string, args ...interface{}) {
+ if !Enabled() {
+ return
+ }
+ log.Printf("[RDP:TRACE] "+format, args...)
+}
diff --git a/backend/pkg/rdp/transport/tls.go b/backend/pkg/rdp/transport/tls.go
new file mode 100644
index 0000000..29b854f
--- /dev/null
+++ b/backend/pkg/rdp/transport/tls.go
@@ -0,0 +1,39 @@
+package transport
+
+import (
+ "crypto/tls"
+ "fmt"
+ "net"
+ "time"
+)
+
+// UpgradeTLS 在 X.224 协商后升级为 TLS,RDP 自签证书由堡垒机侧 TOFU 处理 // Logan.Li
+func UpgradeTLS(conn net.Conn, serverName string, timeout time.Duration) (*tls.Conn, error) {
+ _ = conn.SetDeadline(time.Now().Add(timeout))
+ tlsConn := tls.Client(conn, &tls.Config{
+ ServerName: serverName,
+ InsecureSkipVerify: true, // RDP 常见自签证书,后续可落库校验指纹 // Logan.Li
+ MinVersion: tls.VersionTLS10,
+ MaxVersion: tls.VersionTLS12,
+ })
+ if err := tlsConn.Handshake(); err != nil {
+ return nil, err
+ }
+ _ = tlsConn.SetDeadline(time.Time{})
+ return tlsConn, nil
+}
+
+// TLSCipherSuiteName 返回 TLS cipher suite 名称,旧版本未命名时回退十六进制 // Logan.Li
+func TLSCipherSuiteName(id uint16) string {
+ for _, suite := range tls.CipherSuites() {
+ if suite.ID == id {
+ return suite.Name
+ }
+ }
+ for _, suite := range tls.InsecureCipherSuites() {
+ if suite.ID == id {
+ return suite.Name
+ }
+ }
+ return fmt.Sprintf("0x%04x", id)
+}
diff --git a/backend/pkg/rdp/transport/tpkt.go b/backend/pkg/rdp/transport/tpkt.go
new file mode 100644
index 0000000..29881b7
--- /dev/null
+++ b/backend/pkg/rdp/transport/tpkt.go
@@ -0,0 +1,54 @@
+package transport
+
+import (
+ "encoding/binary"
+ "fmt"
+ "io"
+)
+
+const (
+ tpktVersion = 3
+ tpktHeaderSize = 4
+ maxTPKTSize = 64 * 1024
+)
+
+// WriteTPKT 写入 TPKT 包头与负载,RDP 初始握手基于 TPKT 承载 // Logan.Li
+func WriteTPKT(w io.Writer, payload []byte) error {
+ total := len(payload) + tpktHeaderSize
+ if total > maxTPKTSize {
+ return fmt.Errorf("TPKT payload too large: %d", len(payload))
+ }
+ header := []byte{tpktVersion, 0, 0, 0}
+ binary.BigEndian.PutUint16(header[2:4], uint16(total))
+ if _, err := w.Write(header); err != nil {
+ return err
+ }
+ _, err := w.Write(payload)
+ return err
+}
+
+// ReadTPKT 读取一个完整 TPKT 包并返回负载 // Logan.Li
+func ReadTPKT(r io.Reader) ([]byte, error) {
+ header := make([]byte, tpktHeaderSize)
+ if _, err := io.ReadFull(r, header); err != nil {
+ return nil, err
+ }
+ if header[0] != tpktVersion {
+ return nil, fmt.Errorf("invalid TPKT version: %d", header[0])
+ }
+ total := int(binary.BigEndian.Uint16(header[2:4]))
+ if total < tpktHeaderSize || total > maxTPKTSize {
+ return nil, fmt.Errorf("invalid TPKT length: %d", total)
+ }
+ payload := make([]byte, total-tpktHeaderSize)
+ if _, err := io.ReadFull(r, payload); err != nil {
+ return nil, err
+ }
+ return payload, nil
+}
+
+// WriteRaw 直接写入已封装的 TPKT/FastPath 帧,供会话层发送输入事件 // Logan.Li
+func WriteRaw(w io.Writer, frame []byte) error {
+ _, err := w.Write(frame)
+ return err
+}
diff --git a/backend/pkg/rdp/transport/x224.go b/backend/pkg/rdp/transport/x224.go
new file mode 100644
index 0000000..147963f
--- /dev/null
+++ b/backend/pkg/rdp/transport/x224.go
@@ -0,0 +1,119 @@
+package transport
+
+import (
+ "encoding/binary"
+ "fmt"
+ "io"
+)
+
+const (
+ x224TypeConnectionRequest = 0xE0
+ x224TypeConnectionConfirm = 0xD0
+ x224TypeData = 0xF0
+
+ negTypeRequest = 0x01
+ negTypeResponse = 0x02
+ negTypeFailure = 0x03
+
+ protocolSSL = 0x00000001
+ protocolCredSSP = 0x00000002
+)
+
+const (
+ ProtocolStandard uint32 = 0
+ ProtocolTLS uint32 = protocolSSL
+ ProtocolCredSSP uint32 = protocolCredSSP
+)
+
+// NegotiationResult 是 X.224 阶段的 RDP 安全协议协商结果 // Logan.Li
+type NegotiationResult struct {
+ SelectedProtocol uint32
+ FailureCode uint32
+}
+
+func (r NegotiationResult) ProtocolName() string {
+ switch r.SelectedProtocol {
+ case ProtocolCredSSP:
+ return "CredSSP/NLA"
+ case ProtocolTLS:
+ return "TLS"
+ case ProtocolStandard:
+ return "Standard RDP"
+ default:
+ return fmt.Sprintf("unknown(0x%x)", r.SelectedProtocol)
+ }
+}
+
+// BuildConnectionRequest 构造 X.224 Connection Request,并请求 NLA/TLS 协商 // Logan.Li
+func BuildConnectionRequest() []byte {
+ return BuildConnectionRequestWithCookie("aiops")
+}
+
+// BuildConnectionRequestWithCookie 按 MS-RDPBCGR 2.2.1.1.1 构造包含 mstshash 的请求 // Logan.Li
+func BuildConnectionRequestWithCookie(username string) []byte {
+ if username == "" {
+ username = "aiops"
+ }
+ if len(username) > 9 {
+ username = username[:9]
+ }
+ cookie := []byte("Cookie: mstshash=" + username + "\r\n")
+ neg := make([]byte, 8)
+ neg[0] = negTypeRequest
+ neg[1] = 0
+ binary.LittleEndian.PutUint16(neg[2:4], uint16(len(neg)))
+ binary.LittleEndian.PutUint32(neg[4:8], protocolSSL|protocolCredSSP)
+
+ length := 7 + len(cookie) + len(neg)
+ pdu := make([]byte, 0, length)
+ pdu = append(pdu, byte(length-1))
+ pdu = append(pdu, x224TypeConnectionRequest)
+ pdu = append(pdu, 0x00, 0x00) // dst-ref // Logan.Li
+ pdu = append(pdu, 0x00, 0x00) // src-ref // Logan.Li
+ pdu = append(pdu, 0x00) // class/options // Logan.Li
+ pdu = append(pdu, cookie...)
+ pdu = append(pdu, neg...)
+ return pdu
+}
+
+// ParseConnectionConfirm 解析 X.224 Connection Confirm 与 RDP Negotiation Response // Logan.Li
+func ParseConnectionConfirm(payload []byte) (NegotiationResult, error) {
+ if len(payload) < 7 {
+ return NegotiationResult{}, fmt.Errorf("short X.224 confirm: %d", len(payload))
+ }
+ if payload[1] != x224TypeConnectionConfirm {
+ return NegotiationResult{}, fmt.Errorf("unexpected X.224 pdu type: 0x%x", payload[1])
+ }
+ for i := 7; i+8 <= len(payload); i++ {
+ switch payload[i] {
+ case negTypeResponse:
+ return NegotiationResult{SelectedProtocol: binary.LittleEndian.Uint32(payload[i+4 : i+8])}, nil
+ case negTypeFailure:
+ return NegotiationResult{FailureCode: binary.LittleEndian.Uint32(payload[i+4 : i+8])}, fmt.Errorf("RDP negotiation failure: 0x%x", binary.LittleEndian.Uint32(payload[i+4:i+8]))
+ }
+ }
+ return NegotiationResult{SelectedProtocol: 0}, nil
+}
+
+// WriteX224Data 写入 X.224 Data TPDU,NLA 后的 MCS/GCC 数据仍经由该层承载 // Logan.Li
+func WriteX224Data(w io.Writer, payload []byte) error {
+ data := make([]byte, 0, len(payload)+3)
+ data = append(data, 0x02, x224TypeData, 0x80)
+ data = append(data, payload...)
+ return WriteTPKT(w, data)
+}
+
+// ReadX224Data 读取并剥离 X.224 Data TPDU 头,返回上层 MCS 负载 // Logan.Li
+func ReadX224Data(r io.Reader) ([]byte, error) {
+ payload, err := ReadTPKT(r)
+ if err != nil {
+ return nil, err
+ }
+ if len(payload) < 3 {
+ return nil, fmt.Errorf("X.224 data too short: %d", len(payload))
+ }
+ if payload[1] != x224TypeData {
+ return nil, fmt.Errorf("unexpected X.224 type: 0x%x", payload[1])
+ }
+ return payload[3:], nil
+}
diff --git a/backend/pkg/rdpfilestage/stage.go b/backend/pkg/rdpfilestage/stage.go
new file mode 100644
index 0000000..c8fe85d
--- /dev/null
+++ b/backend/pkg/rdpfilestage/stage.go
@@ -0,0 +1,41 @@
+package rdpfilestage
+
+import (
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+// Item 暂存文件条目 // Logan.Li
+type Item struct {
+ Name string
+ Data []byte
+ Expire time.Time
+}
+
+var store sync.Map
+
+// Put 暂存文件并返回一次性 token(默认 30 分钟有效)// Logan.Li
+func Put(name string, data []byte, ttl time.Duration) string {
+ if ttl <= 0 {
+ ttl = 30 * time.Minute
+ }
+ token := uuid.NewString()
+ store.Store(token, Item{Name: name, Data: data, Expire: time.Now().Add(ttl)})
+ return token
+}
+
+// Get 读取暂存文件 // Logan.Li
+func Get(token string) (Item, bool) {
+ raw, ok := store.Load(token)
+ if !ok {
+ return Item{}, false
+ }
+ item := raw.(Item)
+ if time.Now().After(item.Expire) {
+ store.Delete(token)
+ return Item{}, false
+ }
+ return item, true
+}
\ No newline at end of file
diff --git a/backend/pkg/systask/registry.go b/backend/pkg/systask/registry.go
index 3a3434b..cb59042 100644
--- a/backend/pkg/systask/registry.go
+++ b/backend/pkg/systask/registry.go
@@ -8,6 +8,7 @@ import (
"ezops/model"
"ezops/pkg/auditcleaner"
+ "ezops/pkg/billingsync"
"ezops/pkg/database"
snmppkg "ezops/pkg/snmp"
@@ -66,6 +67,11 @@ var (
Description: "清理 7 天前的 SNMP 采集指标历史,防止表膨胀",
Schedule: "每 24 小时", Category: "cleanup", Runnable: true, Enabled: true,
},
+ {
+ Code: "cloud_billing_sync", Name: "云账单同步",
+ Description: "从云账号 AK 拉取昨日账单(按产品+地域),写入云账单管理;当前支持阿里云",
+ Schedule: "每天 10:30", Category: "finops", Runnable: true, Enabled: true,
+ },
}
)
@@ -195,6 +201,14 @@ func RunManual(gdb *gorm.DB, driver, code string) (Item, error) {
r := gdb.Exec(sql)
err = r.Error
msg = fmt.Sprintf("清理 %d 条 SNMP 指标(保留 %d 天)", r.RowsAffected, snmppkg.MetricsRetentionDays)
+ case "cloud_billing_sync":
+ result := billingsync.SyncDate(gdb, billingsync.Yesterday(), nil)
+ msg = billingsync.SummaryMessage(result)
+ if result.Accounts == 0 {
+ err = fmt.Errorf("没有可同步的活跃云账号")
+ } else if result.Created == 0 && result.Updated == 0 && len(result.Errors) > 0 {
+ err = fmt.Errorf("%s", result.Errors[0])
+ }
default:
return Item{}, fmt.Errorf("任务 %s 未实现手动执行", code)
}
diff --git a/backend/pkg/winsmb/client.go b/backend/pkg/winsmb/client.go
new file mode 100644
index 0000000..b23ff75
--- /dev/null
+++ b/backend/pkg/winsmb/client.go
@@ -0,0 +1,171 @@
+package winsmb
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net"
+ "os"
+ "path"
+ "strings"
+ "time"
+
+ "github.com/hirochachacha/go-smb2"
+)
+
+// 虚拟 Download 盘在 Windows 上的相对路径(挂载 C$ 共享)// Logan.Li
+const (
+ AdminShare = "C$"
+ DownloadRelDir = `Users\Public\AIOpsShare\Download`
+ DownloadFullPath = `C:\Users\Public\AIOpsShare\Download`
+)
+
+// Auth SMB 管理员凭据 // Logan.Li
+type Auth struct {
+ Host string
+ Domain string
+ Username string
+ Password string
+}
+
+// FileEntry 目录项 // Logan.Li
+type FileEntry struct {
+ Name string
+ Size int64
+ IsDir bool
+ ModTime time.Time
+}
+
+func sanitizeFileName(name string) string {
+ base := path.Base(strings.ReplaceAll(name, `\`, "/"))
+ base = strings.TrimSpace(base)
+ if base == "" || base == "." || base == ".." {
+ return "upload.bin"
+ }
+ return base
+}
+
+func (a Auth) dial() (*smb2.Share, func(), error) {
+ if strings.TrimSpace(a.Host) == "" {
+ return nil, nil, fmt.Errorf("SMB 主机为空")
+ }
+ d := &smb2.Dialer{
+ Initiator: &smb2.NTLMInitiator{
+ User: a.Username,
+ Password: a.Password,
+ Domain: a.Domain,
+ },
+ }
+ tcpConn, err := net.DialTimeout("tcp", net.JoinHostPort(a.Host, "445"), 15*time.Second)
+ if err != nil {
+ return nil, nil, fmt.Errorf("SMB 连接失败(请确认 445 端口可达且账号有管理员权限): %w", err)
+ }
+ session, err := d.DialContext(context.Background(), tcpConn)
+ if err != nil {
+ tcpConn.Close()
+ return nil, nil, fmt.Errorf("SMB 认证失败: %w", err)
+ }
+ share, err := session.Mount(AdminShare)
+ if err != nil {
+ _ = session.Logoff()
+ return nil, nil, fmt.Errorf("挂载 %s 失败: %w", AdminShare, err)
+ }
+ cleanup := func() {
+ _ = share.Umount()
+ _ = session.Logoff()
+ }
+ return share, cleanup, nil
+}
+
+// EnsureDownloadDir 创建虚拟 Download 目录 // Logan.Li
+func EnsureDownloadDir(auth Auth) error {
+ share, closeFn, err := auth.dial()
+ if err != nil {
+ return err
+ }
+ defer closeFn()
+ cur := ""
+ for _, part := range strings.Split(DownloadRelDir, `\`) {
+ if part == "" {
+ continue
+ }
+ if cur == "" {
+ cur = part
+ } else {
+ cur = cur + `\` + part
+ }
+ _ = share.Mkdir(cur, 0755)
+ }
+ return nil
+}
+
+func remoteFilePath(fileName string) string {
+ return DownloadRelDir + `\` + sanitizeFileName(fileName)
+}
+
+// Upload 上传文件到虚拟 Download 盘 // Logan.Li
+func Upload(auth Auth, fileName string, data []byte) error {
+ if err := EnsureDownloadDir(auth); err != nil {
+ return err
+ }
+ share, closeFn, err := auth.dial()
+ if err != nil {
+ return err
+ }
+ defer closeFn()
+ target := remoteFilePath(fileName)
+ f, err := share.Create(target)
+ if err != nil {
+ return fmt.Errorf("创建远端文件失败: %w", err)
+ }
+ defer f.Close()
+ if _, err := f.Write(data); err != nil {
+ return fmt.Errorf("写入远端文件失败: %w", err)
+ }
+ return nil
+}
+
+// Download 从虚拟 Download 盘读取文件 // Logan.Li
+func Download(auth Auth, fileName string) ([]byte, error) {
+ share, closeFn, err := auth.dial()
+ if err != nil {
+ return nil, err
+ }
+ defer closeFn()
+ target := remoteFilePath(fileName)
+ f, err := share.Open(target)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, fmt.Errorf("文件不存在: %s", sanitizeFileName(fileName))
+ }
+ return nil, fmt.Errorf("打开远端文件失败: %w", err)
+ }
+ defer f.Close()
+ return io.ReadAll(f)
+}
+
+// ListDownload 列出虚拟 Download 盘文件 // Logan.Li
+func ListDownload(auth Auth) ([]FileEntry, error) {
+ if err := EnsureDownloadDir(auth); err != nil {
+ return nil, err
+ }
+ share, closeFn, err := auth.dial()
+ if err != nil {
+ return nil, err
+ }
+ defer closeFn()
+ entries, err := share.ReadDir(DownloadRelDir)
+ if err != nil {
+ return nil, fmt.Errorf("读取目录失败: %w", err)
+ }
+ out := make([]FileEntry, 0, len(entries))
+ for _, ent := range entries {
+ out = append(out, FileEntry{
+ Name: ent.Name(),
+ Size: ent.Size(),
+ IsDir: ent.IsDir(),
+ ModTime: ent.ModTime(),
+ })
+ }
+ return out, nil
+}
\ No newline at end of file
diff --git a/backend/pkg/wshub/manager.go b/backend/pkg/wshub/manager.go
index 2a737c7..c8df5d5 100644
--- a/backend/pkg/wshub/manager.go
+++ b/backend/pkg/wshub/manager.go
@@ -23,12 +23,13 @@ import (
// Connection types — identifies which feature a WebSocket serves.
const (
- TypeTerminal = "terminal" // WebShell SSH terminal
- TypeKubectl = "kubectl" // kubectl command terminal
- TypePodExec = "pod_exec" // K8s pod exec terminal
- TypeAudit = "audit" // Audit real-time stream (live monitor)
- TypeMetrics = "metrics" // Dashboard metrics push
- TypePipeline = "pipeline" // Pipeline run log stream
+ TypeTerminal = "terminal" // WebShell SSH terminal
+ TypeRDP = "rdp" // RDP 图形会话 // Logan.Li
+ TypeKubectl = "kubectl" // kubectl command terminal
+ TypePodExec = "pod_exec" // K8s pod exec terminal
+ TypeAudit = "audit" // Audit real-time stream (live monitor)
+ TypeMetrics = "metrics" // Dashboard metrics push
+ TypePipeline = "pipeline" // Pipeline run log stream
)
// Config tunes the manager behavior.
@@ -88,8 +89,8 @@ type WSManager struct {
conns sync.Map // id → *ConnInfo
next uint64 // atomic counter for connection IDs
- mu sync.Mutex
- closed bool
+ mu sync.Mutex
+ closed bool
}
// New creates a WSManager with the given config.
diff --git a/backend/router/agent_static.go b/backend/router/agent_static.go
new file mode 100644
index 0000000..b367a0f
--- /dev/null
+++ b/backend/router/agent_static.go
@@ -0,0 +1,133 @@
+package router
+
+import (
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "go.uber.org/zap"
+)
+
+type agentStaticPaths struct {
+ binDir string
+ deployDir string
+}
+
+var agentBinaryFiles = map[string]string{
+ "linux-amd64": "aiops-agent-linux-amd64",
+ "linux-arm64": "aiops-agent-linux-arm64",
+ "darwin-arm64": "aiops-agent-darwin-arm64",
+}
+
+// registerAgentDownloads 注册 Agent 安装脚本和二进制下载路由,避免维护 backend/agent 重复目录。 // Logan.Li
+func registerAgentDownloads(r *gin.Engine, log *zap.Logger) {
+ paths := resolveAgentStaticPaths(log)
+
+ r.GET("/agent/arch", func(c *gin.Context) {
+ base := requestBaseURL(c)
+ downloads := availableAgentDownloads(paths.binDir)
+ c.JSON(http.StatusOK, gin.H{
+ "version": "1.0.0",
+ "downloads": downloads,
+ "install": fmt.Sprintf("curl -fsSL %s/agent/install.sh | sudo bash -s -- --server %s --token ", base, base),
+ })
+ })
+
+ r.GET("/agent/install.sh", func(c *gin.Context) {
+ serveAgentStaticFile(c, paths.deployDir, "install.sh")
+ })
+ for _, fileName := range agentBinaryFiles {
+ name := fileName
+ r.GET("/agent/"+name, func(c *gin.Context) {
+ serveAgentStaticFile(c, paths.binDir, name)
+ })
+ }
+ r.GET("/agent/bin/:file", func(c *gin.Context) {
+ serveAgentStaticFile(c, paths.binDir, c.Param("file"))
+ })
+ r.GET("/api/agent/bin/:file", func(c *gin.Context) {
+ serveAgentStaticFile(c, paths.binDir, c.Param("file"))
+ })
+}
+
+// resolveAgentStaticPaths 按部署形态寻找唯一 Agent 资源目录:容器优先,其次项目根目录。 // Logan.Li
+func resolveAgentStaticPaths(log *zap.Logger) agentStaticPaths {
+ paths := agentStaticPaths{
+ binDir: firstExistingDir([]string{
+ "/app/agent/bin",
+ "./agent/bin",
+ "../agent/bin",
+ }),
+ deployDir: firstExistingDir([]string{
+ "/app/agent/deploy",
+ "./agent/deploy",
+ "../agent/deploy",
+ }),
+ }
+ if log != nil {
+ log.Info("Agent 静态资源目录已解析",
+ zap.String("bin_dir", paths.binDir),
+ zap.String("deploy_dir", paths.deployDir),
+ )
+ }
+ return paths
+}
+
+func firstExistingDir(candidates []string) string {
+ for _, candidate := range candidates {
+ info, err := os.Stat(candidate)
+ if err == nil && info.IsDir() {
+ return candidate
+ }
+ }
+ return ""
+}
+
+func availableAgentDownloads(binDir string) gin.H {
+ downloads := gin.H{}
+ for platform, fileName := range agentBinaryFiles {
+ if binDir == "" {
+ continue
+ }
+ fullPath := filepath.Join(binDir, fileName)
+ if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
+ downloads[platform] = "/agent/" + fileName
+ }
+ }
+ return downloads
+}
+
+func requestBaseURL(c *gin.Context) string {
+ scheme := "http"
+ if c.Request.TLS != nil {
+ scheme = "https"
+ }
+ if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" {
+ scheme = proto
+ }
+ host := c.Request.Host
+ if host == "" {
+ host = "localhost:8080"
+ }
+ return fmt.Sprintf("%s://%s", scheme, host)
+}
+
+func serveAgentStaticFile(c *gin.Context, dir string, fileName string) {
+ if dir == "" {
+ c.JSON(http.StatusNotFound, gin.H{"error": "agent static directory not found"})
+ return
+ }
+ if strings.Contains(fileName, "/") || strings.Contains(fileName, "\\") || fileName == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent file"})
+ return
+ }
+ fullPath := filepath.Join(dir, fileName)
+ if info, err := os.Stat(fullPath); err != nil || info.IsDir() {
+ c.JSON(http.StatusNotFound, gin.H{"error": "agent file not found"})
+ return
+ }
+ c.File(fullPath)
+}
diff --git a/backend/router/routes.go b/backend/router/routes.go
index b8eeb51..20c19f4 100644
--- a/backend/router/routes.go
+++ b/backend/router/routes.go
@@ -1,17 +1,16 @@
package router
import (
+ "ezops/config"
"ezops/controller"
"ezops/middleware"
- "ezops/config"
"ezops/model"
- "ezops/service"
ezredis "ezops/pkg/redis"
"ezops/pkg/siteprobe"
"ezops/pkg/systask"
"ezops/pkg/toolinstaller"
"ezops/pkg/wshub"
- "fmt"
+ "ezops/service"
"strconv"
"github.com/gin-gonic/gin"
@@ -35,35 +34,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
})
// Agent 二进制下载 + 一键安装脚本(无需鉴权,方便 curl|bash)
- r.Static("/agent/bin", "./agent/bin")
- r.StaticFile("/agent/install.sh", "./agent/deploy/install.sh")
- r.StaticFile("/agent/aiops-agent-linux-amd64", "./agent/bin/aiops-agent-linux-amd64")
- r.StaticFile("/agent/aiops-agent-linux-arm64", "./agent/bin/aiops-agent-linux-arm64")
- r.StaticFile("/agent/aiops-agent-darwin-arm64", "./agent/bin/aiops-agent-darwin-arm64") // macOS Apple Silicon // Logan.Li
- r.GET("/agent/arch", func(c *gin.Context) {
- // 动态构建安装命令 URL(根据请求来源,兼容 nginx 反代)
- scheme := "http"
- if c.Request.TLS != nil {
- scheme = "https"
- }
- if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" {
- scheme = proto
- }
- host := c.Request.Host
- if host == "" {
- host = "localhost:8080"
- }
- base := fmt.Sprintf("%s://%s", scheme, host)
- c.JSON(200, gin.H{
- "version": "1.0.0",
- "downloads": gin.H{
- "linux-amd64": "/agent/aiops-agent-linux-amd64",
- "linux-arm64": "/agent/aiops-agent-linux-arm64",
- "darwin-arm64": "/agent/aiops-agent-darwin-arm64",
- },
- "install": fmt.Sprintf("curl -fsSL %s/agent/install.sh | sudo bash -s -- --server %s --token ", base, base),
- })
- })
+ registerAgentDownloads(r, log)
// === A3: 统一 WebSocket 连接管理器 === // Logan.Li
// 所有 WebSocket handler 共享同一个 WSManager(心跳、连接注册、监控、优雅关闭)
@@ -73,6 +44,9 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
auditHub := controller.NewAuditHub(wsMgr)
webShellHandler := controller.NewWebShellHandler(db, auditHub, wsMgr)
r.GET("/ws/terminal", middleware.JWTAuth(&cfg.JWT, db), webShellHandler.Terminal)
+ rdpHandler := controller.NewRDPHandler(db, cfg.JWT.Secret, wsMgr)
+ r.GET("/ws/rdp", middleware.JWTAuth(&cfg.JWT, db), rdpHandler.Serve)
+ r.GET("/ws/rdp-audit/:sessionId", middleware.JWTAuth(&cfg.JWT, db), rdpHandler.ServeLive)
// WebSocket 审计实时流 — 在线会话实时查看 // Logan.Li
r.GET("/ws/audit/:sessionId", middleware.JWTAuth(&cfg.JWT, db), func(c *gin.Context) {
@@ -151,6 +125,10 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
api.POST("/auth/login", authHandler.Login)
api.POST("/auth/register", authHandler.Register)
+ // RDP 暂存文件拉取(token 鉴权,供 Windows iwr 回退下载)// Logan.Li
+ rdpFilePublic := controller.NewRDPFileHandler(db, cfg.JWT.Secret)
+ api.GET("/rdp-staged/:token", rdpFilePublic.ServeStaged)
+
// Webhook 触发路由(无需 JWT,由 Webhook Token 签名验证)
pipelineHandler := controller.NewPipelineHandler(db, redisCache, ti)
webhookGroup := api.Group("/webhook")
@@ -272,18 +250,28 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
servers.POST("/batch-delete", serverHandlerV2.BatchDeleteServers)
servers.POST("/batch", serverHandlerV2.BatchCreateServers)
- // Agent 远程安装 // Logan.Li
- servers.POST("/:id/install-agent", agentInstallHandler.InstallAgent)
- servers.POST("/install-agent", agentInstallHandler.BatchInstallAgent)
+ // Agent 远程安装 // Logan.Li
+ servers.POST("/:id/install-agent", agentInstallHandler.InstallAgent)
+ servers.POST("/install-agent", agentInstallHandler.BatchInstallAgent)
- // SFTP 文件浏览 // Logan.Li
- sftpHandler := controller.NewSFTPHandler(db)
- servers.GET("/:id/files", sftpHandler.ListFiles)
- servers.GET("/:id/files/download", sftpHandler.DownloadFile)
- servers.POST("/:id/files/upload", sftpHandler.UploadFile)
- servers.DELETE("/:id/files", sftpHandler.DeleteFile)
- servers.POST("/:id/files/mkdir", sftpHandler.Mkdir)
- }
+ // SFTP 文件浏览 // Logan.Li
+ sftpHandler := controller.NewSFTPHandler(db)
+ servers.GET("/:id/files", sftpHandler.ListFiles)
+ servers.GET("/:id/files/download", sftpHandler.DownloadFile)
+ servers.POST("/:id/files/upload", sftpHandler.UploadFile)
+ servers.DELETE("/:id/files", sftpHandler.DeleteFile)
+ servers.POST("/:id/files/mkdir", sftpHandler.Mkdir)
+
+ // RDP 虚拟 Download 盘(SMB 管理共享,一键上传下载)// Logan.Li
+ rdpFileHandler := controller.NewRDPFileHandler(db, cfg.JWT.Secret)
+ servers.GET("/:id/rdp-files", rdpFileHandler.ListFiles)
+ servers.POST("/:id/rdp-files/upload", rdpFileHandler.UploadFile)
+ servers.GET("/:id/rdp-files/download", rdpFileHandler.DownloadFile)
+ servers.POST("/:id/rdp-files/stage", rdpFileHandler.StageUpload)
+ }
+
+ // 云账单处理器(云账号同步账单路由共用) // Logan.Li
+ billingHandler := controller.NewBillingHandler(db)
// 云账号路由
cloudAccountHandler := controller.NewCloudAccountHandler(db)
@@ -295,11 +283,12 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
cloudAccounts.DELETE("/:id", cloudAccountHandler.DeleteCloudAccount)
// 云同步路由 — 区域同步 / 主机发现 / 导入主机 // Logan.Li
- syncHandler := controller.NewCloudSyncHandler(db)
+ syncHandler := controller.NewCloudSyncHandler(db, cfg.JWT.Secret)
cloudAccounts.POST("/:id/sync-regions", syncHandler.SyncRegions)
cloudAccounts.GET("/:id/regions", syncHandler.GetAccountRegions)
cloudAccounts.POST("/:id/discover-instances", syncHandler.DiscoverInstances)
cloudAccounts.POST("/:id/import-instances", syncHandler.ImportInstances)
+ cloudAccounts.POST("/:id/sync-billing", billingHandler.SyncAccountBilling)
}
// 域名路由
@@ -334,7 +323,6 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
}
// 云账单路由
- billingHandler := controller.NewBillingHandler(db)
billings := authorized.Group("/cloud-billings")
{
billings.GET("", billingHandler.ListBillings)
@@ -343,6 +331,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
billings.DELETE("/:id", billingHandler.DeleteBilling)
billings.GET("/stats", billingHandler.GetBillingStats)
billings.GET("/providers", billingHandler.GetProviderOptions)
+ billings.POST("/sync", billingHandler.SyncYesterdayBillings)
}
billingAlerts := authorized.Group("/billing-alerts")
{
@@ -376,15 +365,15 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
clusters.POST("", k8sHandler.CreateCluster)
clusters.PUT("/:id", k8sHandler.UpdateCluster)
clusters.DELETE("/:id", k8sHandler.DeleteCluster)
- clusters.POST("/:id/sync", k8sHandler.SyncCluster) // 同步集群资源 // Logan.Li
- clusters.GET("/:id/nodes", k8sHandler.ListNodes) // Deprecated: use GET /kubernetes/:clusterCode/nodes
- clusters.GET("/:id/nodes/:name", k8sHandler.GetNodeDetail) // M10 节点详情 // Logan.Li
- clusters.GET("/:id/pods", k8sHandler.ListPods) // Deprecated: use GET /kubernetes/:clusterCode/pods
- clusters.GET("/:id/deployments", k8sHandler.ListDeployments) // Deprecated: use GET /kubernetes/:clusterCode/deployments
- clusters.GET("/:id/services", k8sHandler.ListServices) // Deprecated: use GET /kubernetes/:clusterCode/services
- clusters.GET("/:id/ingresses", k8sHandler.ListIngresses) // Deprecated: use GET /kubernetes/:clusterCode/ingresses
- clusters.GET("/:id/topology", k8sHandler.GetTopology) // 获取拓扑布局 // Logan.Li
- clusters.PUT("/:id/topology", k8sHandler.SaveTopology) // 保存拓扑布局 // Logan.Li
+ clusters.POST("/:id/sync", k8sHandler.SyncCluster) // 同步集群资源 // Logan.Li
+ clusters.GET("/:id/nodes", k8sHandler.ListNodes) // Deprecated: use GET /kubernetes/:clusterCode/nodes
+ clusters.GET("/:id/nodes/:name", k8sHandler.GetNodeDetail) // M10 节点详情 // Logan.Li
+ clusters.GET("/:id/pods", k8sHandler.ListPods) // Deprecated: use GET /kubernetes/:clusterCode/pods
+ clusters.GET("/:id/deployments", k8sHandler.ListDeployments) // Deprecated: use GET /kubernetes/:clusterCode/deployments
+ clusters.GET("/:id/services", k8sHandler.ListServices) // Deprecated: use GET /kubernetes/:clusterCode/services
+ clusters.GET("/:id/ingresses", k8sHandler.ListIngresses) // Deprecated: use GET /kubernetes/:clusterCode/ingresses
+ clusters.GET("/:id/topology", k8sHandler.GetTopology) // 获取拓扑布局 // Logan.Li
+ clusters.PUT("/:id/topology", k8sHandler.SaveTopology) // 保存拓扑布局 // Logan.Li
// Pod 日志 // Logan.Li
clusters.GET("/:id/namespaces/:namespace/pods/:podName/logs", k8sHandler.GetPodLogs)
@@ -681,7 +670,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
releases.GET("", pipelineHandler.ListReleases)
releases.POST("", pipelineHandler.CreateRelease)
releases.POST("/:id/approve", pipelineHandler.ApproveRelease)
- releases.POST("/:id/reject", pipelineHandler.RejectRelease) // 驳回发布 // Logan.Li
+ releases.POST("/:id/reject", pipelineHandler.RejectRelease) // 驳回发布 // Logan.Li
releases.POST("/:id/deploy", pipelineHandler.DeployRelease)
releases.POST("/:id/rollback", pipelineHandler.RollbackRelease)
}
@@ -870,6 +859,18 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
ts.GET("/:id/stream", terminalSessionHandler.StreamSession)
}
+ // Windows RDP 图形会话审计路由(只做录屏帧回放,不做命令审计) // Logan.Li
+ rdpSessionHandler := controller.NewRDPSessionHandler(db, rdpHandler.KillSession)
+ rs := authorized.Group("/rdp-sessions")
+ {
+ rs.GET("", rdpSessionHandler.ListRDPSessions)
+ rs.GET("/:id", rdpSessionHandler.GetRDPSession)
+ rs.GET("/:id/record", rdpSessionHandler.GetRDPRecord)
+ rs.POST("/:id/kill", rdpSessionHandler.KillRDPSession)
+ rs.GET("/:id/download", rdpSessionHandler.DownloadRDPRecord)
+ rs.POST("/:id/replay-view", rdpSessionHandler.LogRDPReplayView)
+ }
+
// 会话命令审计路由(JumpServer 式命令审计) // Logan.Li
scHandler := controller.NewSessionCommandHandler(db)
sc := authorized.Group("/session-commands")
@@ -938,9 +939,6 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
agentQuery.PUT("/port-policies/:hostname", agentHandler.SavePortPolicy)
}
- // Agent 二进制下载(无需鉴权,Agent 升级时使用)
- r.Static("/api/agent/bin", "/app/agent/bin")
-
// AI 智能助手 - LLM 配置 + 对话代理(OpenAI 兼容) // Logan.Li
llmHandler := controller.NewLLMHandler(db)
aiopsGroup := authorized.Group("/aiops")
diff --git a/backend/seed/seed.go b/backend/seed/seed.go
index 12bbdac..b69f4cd 100644
--- a/backend/seed/seed.go
+++ b/backend/seed/seed.go
@@ -84,8 +84,8 @@ func SeedData(db *gorm.DB) {
}`,
FlowConfig: `[{"name":"直接主管","approver_role":"team_lead","step_type":"audit"},{"name":"运维负责人","approver_role":"ops_manager","step_type":"audit"},{"name":"运维执行","approver_role":"ops_manager","step_type":"execute"}]`,
Enabled: true,
- },
- {
+ },
+ {
Name: "应用部署申请",
Code: "app_deploy",
Description: "申请新应用部署,包含 Git 地址、端口、Nacos 等配置信息",
@@ -109,7 +109,7 @@ func SeedData(db *gorm.DB) {
FlowConfig: `[{"name":"技术负责人","approver_role":"tech_lead","step_type":"audit"},{"name":"运维负责人","approver_role":"ops_manager","step_type":"audit"},{"name":"部署执行","approver_role":"ops_manager","step_type":"execute"}]`,
Enabled: true,
},
- {
+ {
Name: "权限申请",
Code: "permission_request",
Description: "申请堡垒机、生产VPN、日志查询、数据查询等运维权限",
@@ -127,7 +127,7 @@ func SeedData(db *gorm.DB) {
}`,
FlowConfig: `[{"name":"直接主管","approver_role":"team_lead","step_type":"audit"},{"name":"安全负责人","approver_role":"security_lead","step_type":"audit"},{"name":"权限开通","approver_role":"ops_manager","step_type":"execute"}]`,
Enabled: true,
- },
+ },
}
if err := db.Create(&templates).Error; err != nil {
zap.L().Warn("failed to seed approval templates", zap.Error(err))
@@ -171,7 +171,7 @@ func SeedData(db *gorm.DB) {
// AIOps
{Name: "智能助手", Code: "aiops:assistant", Type: "menu", Sort: 60, Status: 1},
{Name: "告警分析", Code: "aiops:analysis", Type: "menu", Sort: 61, Status: 1},
- {Name: "容量预测", Code: "aiops:forecast", Type: "menu", Sort: 62, Status: 1},
+ {Name: "预测分析", Code: "aiops:forecast", Type: "menu", Sort: 62, Status: 1},
{Name: "Agent CLI", Code: "aiops:agent-cli", Type: "menu", Sort: 63, Status: 1},
{Name: "大模型设置", Code: "aiops:llm-config", Type: "menu", Sort: 64, Status: 1},
// 资源申请
@@ -193,6 +193,8 @@ func SeedData(db *gorm.DB) {
db.Create(&perms)
zap.L().Info("seeded default permissions")
}
+ // 兼容旧库菜单命名,将容量预测统一更名为预测分析。 // Logan.Li
+ db.Model(&model.Permission{}).Where("code = ?", "aiops:forecast").Update("name", "预测分析")
// 5. 创建默认角色(如果不存在)
var roleCount int64
diff --git a/backend/server b/backend/server
deleted file mode 100755
index c7afeac..0000000
Binary files a/backend/server and /dev/null differ
diff --git a/backend/service/service.go b/backend/service/service.go
index d77c53c..f749eea 100644
--- a/backend/service/service.go
+++ b/backend/service/service.go
@@ -9,9 +9,9 @@ import (
"ezops/dto"
"ezops/model"
- "ezops/repository"
"ezops/pkg/crypto"
ezredis "ezops/pkg/redis"
+ "ezops/repository"
"gorm.io/gorm"
)
@@ -60,6 +60,9 @@ func (s *ServerService) CreateServer(req *dto.CreateServerRequest) (*model.Serve
Port: req.Port,
Username: req.Username,
AuthType: req.AuthType,
+ OSType: req.OSType,
+ Protocol: req.Protocol,
+ RDPPort: req.RDPPort,
OS: req.OS,
OSVersion: req.OSVersion,
Arch: req.Arch,
@@ -80,6 +83,18 @@ func (s *ServerService) CreateServer(req *dto.CreateServerRequest) (*model.Serve
if req.AuthType == "" {
srv.AuthType = "password"
}
+ if srv.OSType == "" {
+ srv.OSType = "linux"
+ }
+ if srv.Protocol == "" {
+ srv.Protocol = "ssh"
+ }
+ if srv.Protocol == "rdp" {
+ srv.OSType = "windows"
+ if srv.RDPPort == 0 {
+ srv.RDPPort = 3389
+ }
+ }
// 加密密码和 SSH 密钥
if req.Password != "" {
@@ -96,6 +111,11 @@ func (s *ServerService) CreateServer(req *dto.CreateServerRequest) (*model.Serve
}
srv.SSHKey = enc
}
+ if req.CredentialID != nil && *req.CredentialID > 0 {
+ if err := s.applyCredentialToServer(*req.CredentialID, srv); err != nil {
+ return nil, err
+ }
+ }
if err := s.repo.Create(srv); err != nil {
return nil, err
@@ -125,6 +145,15 @@ func (s *ServerService) UpdateServer(id uint, req *dto.UpdateServerRequest) erro
if req.AuthType != nil {
updates["auth_type"] = *req.AuthType
}
+ if req.OSType != nil {
+ updates["os_type"] = *req.OSType
+ }
+ if req.Protocol != nil {
+ updates["protocol"] = *req.Protocol
+ }
+ if req.RDPPort != nil {
+ updates["rdp_port"] = *req.RDPPort
+ }
if req.OS != nil {
updates["os"] = *req.OS
}
@@ -174,6 +203,23 @@ func (s *ServerService) UpdateServer(id uint, req *dto.UpdateServerRequest) erro
}
updates["ssh_key"] = enc
}
+ if req.CredentialID != nil && *req.CredentialID > 0 {
+ srv, err := s.repo.GetByID(id)
+ if err != nil {
+ return err
+ }
+ if err := s.applyCredentialToServer(*req.CredentialID, srv); err != nil {
+ return err
+ }
+ updates["username"] = srv.Username
+ updates["password"] = srv.Password
+ updates["ssh_key"] = srv.SSHKey
+ updates["auth_type"] = srv.AuthType
+ updates["port"] = srv.Port
+ updates["rdp_port"] = srv.RDPPort
+ updates["protocol"] = srv.Protocol
+ updates["os_type"] = srv.OSType
+ }
if len(updates) == 0 {
return nil
@@ -181,6 +227,67 @@ func (s *ServerService) UpdateServer(id uint, req *dto.UpdateServerRequest) erro
return s.repo.UpdateFields(id, updates)
}
+func (s *ServerService) applyCredentialToServer(credentialID uint, srv *model.Server) error {
+ cred, err := s.credentialByID(credentialID)
+ if err != nil {
+ return err
+ }
+ plain, err := s.crypt.Decrypt(cred.Content)
+ if err != nil {
+ return fmt.Errorf("凭据解密失败: %w", err)
+ }
+ switch cred.SecretType {
+ case "ssh":
+ var sshc model.SSHSecretContent
+ if json.Unmarshal([]byte(plain), &sshc) == nil {
+ if sshc.SSHUser != "" {
+ srv.Username = sshc.SSHUser
+ }
+ if sshc.SSHPort > 0 {
+ srv.Port = sshc.SSHPort
+ }
+ if sshc.PrivateKey != "" {
+ srv.SSHKey = sshc.PrivateKey
+ srv.Password = ""
+ srv.AuthType = "key"
+ } else if sshc.Password != "" {
+ srv.Password = sshc.Password
+ srv.SSHKey = ""
+ srv.AuthType = "password"
+ }
+ }
+ srv.Protocol = "ssh"
+ if srv.OSType == "" {
+ srv.OSType = "linux"
+ }
+ case "rdp":
+ var rdpc model.RDPSecretContent
+ if err := json.Unmarshal([]byte(plain), &rdpc); err != nil {
+ return fmt.Errorf("RDP 凭据格式错误: %w", err)
+ }
+ srv.Username = rdpc.Username
+ srv.Password = plain
+ srv.SSHKey = ""
+ srv.AuthType = "password"
+ srv.Protocol = "rdp"
+ srv.OSType = "windows"
+ if srv.RDPPort == 0 {
+ srv.RDPPort = 3389
+ }
+ default:
+ return fmt.Errorf("服务器仅支持关联 ssh/rdp 凭据")
+ }
+ return nil
+}
+
+func (s *ServerService) credentialByID(id uint) (*model.Credential, error) {
+ var cred model.Credential
+ if err := s.repo.DB().First(&cred, id).Error; err != nil {
+ return nil, err
+ }
+ return &cred, nil
+}
+
// DeleteServer 删除服务器
func (s *ServerService) DeleteServer(id uint) error {
return s.repo.Delete(id)
@@ -237,6 +344,19 @@ func (s *CredentialService) buildCredentialContent(req *dto.CreateCredentialRequ
}
b, _ := json.Marshal(data)
return string(b), fmt.Sprintf("SSH %s@%d", req.SSHUser, req.SSHPort), nil
+ case "rdp":
+ security := req.RDPSecurity
+ if security == "" {
+ security = "nla"
+ }
+ data := map[string]string{
+ "username": req.RDPUsername,
+ "password": req.RDPPassword,
+ "domain": req.RDPDomain,
+ "security": security,
+ }
+ b, _ := json.Marshal(data)
+ return string(b), fmt.Sprintf("RDP %s", req.RDPUsername), nil
case "docker":
data := map[string]string{
"server": req.DockerServer,
@@ -309,6 +429,25 @@ func (s *CredentialService) buildUpdateContent(req *dto.UpdateCredentialRequest,
}
b, _ := json.Marshal(data)
return string(b), "", nil
+ case "rdp":
+ data := map[string]string{}
+ if req.RDPUsername != nil {
+ data["username"] = *req.RDPUsername
+ }
+ if req.RDPPassword != nil {
+ data["password"] = *req.RDPPassword
+ }
+ if req.RDPDomain != nil {
+ data["domain"] = *req.RDPDomain
+ }
+ if req.RDPSecurity != nil {
+ data["security"] = *req.RDPSecurity
+ }
+ if len(data) == 0 {
+ return "", "", nil
+ }
+ b, _ := json.Marshal(data)
+ return string(b), "", nil
case "docker":
data := map[string]string{}
if req.DockerServer != nil {
@@ -523,9 +662,9 @@ func toJSONString(v interface{}) string {
// ━━━ Config Helper — 管理所有 Service 的依赖注入 ━━━━
type Services struct {
- Server *ServerService
- Credential *CredentialService
- AlertContact *AlertContactService
+ Server *ServerService
+ Credential *CredentialService
+ AlertContact *AlertContactService
AlertContactGroup *AlertContactGroupService
}
@@ -534,9 +673,9 @@ func NewServices(db *gorm.DB, cache ezredis.Cache, masterKey string) *Services {
crypt := crypto.NewSecretBox(masterKey)
return &Services{
- Server: NewServerService(repository.NewServerRepository(db), cache, crypt),
- Credential: NewCredentialService(repository.NewCredentialRepository(db), crypt),
- AlertContact: NewAlertContactService(repository.NewAlertContactRepository(db)),
+ Server: NewServerService(repository.NewServerRepository(db), cache, crypt),
+ Credential: NewCredentialService(repository.NewCredentialRepository(db), crypt),
+ AlertContact: NewAlertContactService(repository.NewAlertContactRepository(db)),
AlertContactGroup: NewAlertContactGroupService(repository.NewAlertContactGroupRepository(db)),
}
}
diff --git a/docker-compose.yml b/docker-compose.yml
index 3d7f19b..697278f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -85,6 +85,9 @@ services:
REDIS_HOST: redis
REDIS_PORT: 6379
JWT_SECRET: ${JWT_SECRET:-aiops-jwt-secret-change-me}
+ # RDP 使用自研协议栈,第三方 grdp 已移除;该变量保留兼容但后端不再读取。 // Logan.Li
+ RDP_ENGINE: ${RDP_ENGINE:-rdp}
+ RDP_DEBUG: ${RDP_DEBUG:-1}
volumes:
- aiops_uploads:/app/uploads
- /var/run/docker.sock:/var/run/docker.sock
@@ -109,4 +112,4 @@ volumes:
networks:
aiops-network:
- driver: bridge
\ No newline at end of file
+ driver: bridge
diff --git a/docs/README.md b/docs/README.md
index 2d08e6a..5b0f53f 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -13,6 +13,7 @@
| [API 接口](./api-reference.md) | 后端 API 接口文档 |
| [数据库设计](./database-schema.md) | 数据模型和表结构 |
| [开发日志](./dev-log.md) | 详细开发日志(按日期记录) |
+| [Web RDP 远程桌面](./rdp.md) | 自研 RDP 协议栈、WebSocket 帧协议(归档:`/opt/rdp/`) |
| [Redis 缓存策略](./redis-caching-strategy.md) | Redis 缓存使用现状与优化方案 |
## 设计文档
@@ -45,6 +46,7 @@
| 日志告警 | AlertEngine 支持 `type=log` 规则,查询 Loki / ES 触发/恢复 |
| 远程执行 | 新增远程执行页(执行/上传),移除 ansible 命名 |
| 大模型双协议 | OpenAI 兼容 + Anthropic Messages 协议接入 |
+| Web RDP 自研引擎 | 移除 grdp,SessionEngine 完整图形会话,见 [rdp.md](./rdp.md) |
## 部署速查
diff --git a/docs/design-system.md b/docs/design-system.md
new file mode 100644
index 0000000..fca7ad1
--- /dev/null
+++ b/docs/design-system.md
@@ -0,0 +1,51 @@
+# 前端视觉与页面规范
+
+本文档约定 AIOps 控制台的页面结构、视觉基础和复用组件。新增页面优先使用这些规范,已有页面逐步迁移。
+
+## 设计目标
+
+- 页面打开即用,避免营销式大标题和多余说明。
+- 信息密度适中,优先支持扫描、筛选、对比和批量操作。
+- 主题感知,颜色优先使用 Tailwind 语义色和 `var(--dg-*)` 变量。
+- 同类页面保持一致:列表、详情、弹窗、空态、加载态和错误态不重复造轮子。
+
+## 页面结构
+
+标准业务页使用 `PageShell`:
+
+1. `PageHeader`:标题、简短描述、主操作按钮。
+2. `FilterBar`:搜索、筛选、刷新、批量操作。
+3. 主内容区:表格、图表、详情或编辑器。
+4. `PageSection`:页面内分组区域。
+
+示例:
+
+```tsx
+
+
+
+ {table}
+
+```
+
+## 状态与数据展示
+
+- 状态统一使用 `StatusBadge`,不要在页面中临时拼颜色。
+- 指标卡片使用 `MetricCard`,用于概览数值、趋势和健康状态。
+- 空态优先使用 `EmptyState`,需要明确下一步动作时传入 `action`。
+
+常用状态:`success`、`warning`、`danger`、`info`、`neutral`、`running`、`offline`。
+
+## 交互规范
+
+- 删除、重置、停用等高危操作必须使用确认弹窗,不使用 `window.confirm()`。
+- Dialog 必须包含 `DialogDescription`。
+- 列表页操作按钮文案保持 2-4 字,图标按钮需要 tooltip。
+- 加载中显示 skeleton 或 spinner,不能让页面无反馈。
+
+## 代码约定
+
+- 通用页面组件放在 `frontend/src/components/common/`。
+- 基础原子组件继续放在 `frontend/src/components/ui/`。
+- 页面组件使用 `PascalCase.tsx`,工具函数使用 `camelCase.ts`。
+- 新增颜色进入主题变量,不在业务组件硬编码十六进制颜色。
diff --git a/docs/dev-log.md b/docs/dev-log.md
index 1b2630f..8795996 100644
--- a/docs/dev-log.md
+++ b/docs/dev-log.md
@@ -1,5 +1,43 @@
# AIOps 开发日志
+## 2026-07-03 Web RDP 自研图形引擎(移除 grdp)
+
+### 完成项
+
+| 项目 | 状态 | 说明 |
+|------|------|------|
+| 自研 SessionEngine | ✅ | `NewEngine()` → `SessionEngine`,完整 MCS + 位图 + 键鼠 |
+| 移除 nakagami/grdp | ✅ | `go.mod` 无 grdp;`Dockerfile` 去掉 `third_party/` |
+| 位图解压 | ✅ | `rle/` RLE + `mppc.go` MPPC + `bitmap_decode.go` PNG 编码 |
+| FastPath 收发 | ✅ | `packet_io.go`、`fastpath_recv.go`、`input_fastpath.go` |
+| 会话激活 | ✅ | FontMap 后 `SuppressOutput(ALLOW_DISPLAY_UPDATES)` |
+| 文件传输 | ✅ | SMB 直传 + `rdpfilestage` 暂存 + `auto_exec` 回退 |
+| 第三方备份 | ✅ | `/opt/rdp/third-party/`(grdp v0.8.7 + 参考实现) |
+| 文档 | ✅ | `docs/rdp.md` + `/opt/rdp/CHANGELOG.md` |
+
+### 新增 / 变更文件(`backend/pkg/rdp/`)
+
+- `session_engine.go`、`session_connect.go`、`packet_io.go`
+- `fastpath_recv.go`、`bitmap_decode.go`、`input_fastpath.go`、`mppc.go`
+- `rle/`(`util.go`、`bpp1~4.go`、`entry.go`)
+- `engine.go`:`NewEngine()` 指向 `SessionEngine`
+- `transport/tpkt.go`:新增 `WriteRaw`
+
+### 编译与部署
+
+```bash
+cd backend && CGO_ENABLED=1 go build .
+./run.sh docker up # 已验证镜像构建通过
+```
+
+### 实测备注
+
+- CredSSP/NLA 对 `192.168.14.172:3389` 稳定通过(~7ms)
+- MCS 握手在频繁探测时偶发 `connection reset`(目标机限流/策略)
+- 详细协议说明见 [docs/rdp.md](./rdp.md)
+
+---
+
## 2026-07-01 文档同步 + PageShell 设计系统
### 完成项
diff --git a/docs/development-guide.md b/docs/development-guide.md
index 0d44cd4..45005f2 100644
--- a/docs/development-guide.md
+++ b/docs/development-guide.md
@@ -226,7 +226,7 @@ const { list, total } = await getServers({ page: 1 });
```bash
cd backend
go vet ./...
-go build -o aiops-server .
+go build -o aiops .
```
### 前端构建测试
@@ -262,4 +262,4 @@ python3 tests/integration_test.py --host localhost --port 8080
```go
log.Info("server started", zap.String("port", cfg.Port))
log.Error("database error", zap.Error(err))
-```
\ No newline at end of file
+```
diff --git a/docs/rdp.md b/docs/rdp.md
new file mode 100644
index 0000000..fa7197c
--- /dev/null
+++ b/docs/rdp.md
@@ -0,0 +1,234 @@
+# AIOps Web RDP 远程桌面
+
+> 作者:Logan.Li
+> 更新:2026-07-03
+> 归档副本:`/opt/rdp/docs/rdp.md` · 变更记录:`/opt/rdp/CHANGELOG.md`
+
+---
+
+## 概述
+
+AIOps 提供浏览器内 Windows 远程桌面(Canvas 渲染 + WebSocket)。当前已移除第三方 `grdp` 兜底实现,后端固定使用 AIOps 自研 RDP 协议栈。// Logan.Li
+
+| 环境变量 | 行为 |
+|----------|------|
+| `RDP_ENGINE` | 已不再参与引擎选择,保留环境变量不会改变行为 |
+| `RDP_DEBUG=1` | 打开 `[RDP:TRACE]` 协议级日志 |
+
+| 层级 | 状态 | 路径 |
+|------|------|------|
+| X.224 / TPKT / TLS | ✅ 自研灰度 | `backend/pkg/rdp/transport/` |
+| CredSSP / NTLMv2 (NLA) | ✅ 自研灰度 | `backend/pkg/rdp/credssp/` |
+| MCS / GCC / 许可证 / 激活 | ✅ 自研灰度 | `backend/pkg/rdp/mcs_probe.go`、`session_connect.go` |
+| FastPath 位图 / MPPC / RLE | ✅ 自研灰度 | `bitmap_decode.go`、`fastpath_recv.go`、`mppc.go`、`rle/` |
+| 键鼠 / 剪贴板 / auto_exec | ✅ 自研灰度 | `input_fastpath.go` |
+| 文件传输 (SMB + 暂存回退) | ✅ 自研 | `pkg/winsmb/`、`pkg/rdpfilestage/` |
+| 前端 Canvas + 工具面板 | ✅ 自研 | `RdpDialog.tsx`、`RdpToolsPanel.tsx` |
+
+自研协议栈不依赖第三方 RDP 实现,也不直接依赖控制器、数据库或 WebSocket。已删除的第三方目录备份在 `/opt/rdp/third-party/grdp-aiops-backup-20260703`。// Logan.Li
+
+---
+
+## 2026-07-03 变更记录
+
+### 自研 RDP 引擎
+
+| 变更项 | 说明 |
+|--------|------|
+| 引擎入口 | `NewEngine()` 固定返回 `SessionEngine` |
+| 兜底依赖 | 已移除 `github.com/nakagami/grdp` 和 `backend/third_party` |
+| Dockerfile | 不再复制 third_party 模块 |
+| 新增模块 | `session_engine.go`、`session_connect.go`、`packet_io.go`、`fastpath_recv.go`、`bitmap_decode.go`、`input_fastpath.go`、`mppc.go`、`rle/` |
+| 自研会话激活 | `ConfirmActive` 后可选读 FontMap,发送 `SuppressOutput(ALLOW_DISPLAY_UPDATES)` 触发服务端推帧 |
+| 自研位图输出 | FastPath / SlowPath Bitmap + SurfaceCmd → PNG base64 → WebSocket `type:bitmap` |
+| 自研输入通道 | Fast-Path Input:扫描码键鼠、Unicode 剪贴板、`auto_exec`(Win+R 粘贴命令) |
+| 协议日志 | `RDP_DEBUG=1` 输出 `[RDP:TRACE]`,定位连接、bitmap、input 阶段 |
+
+### 仍保留的探测 API(诊断用)
+
+| 函数 | 用途 |
+|------|------|
+| `ProbeHandshake` | X.224 安全协议协商 |
+| `ProbeCredSSP` | NLA 凭据验证 |
+| `ProbeMCSConnect` | MCS/GCC 握手探测(读完即关连接) |
+| `ProbeSessionFrames` | 建立会话后统计位图矩形数量 |
+
+### 已知限制 / 待完善
+
+- `mcs_probe.go` 超 700 行红线,后续需按 MCS/GCC/PDU 拆文件
+- 未实现 cliprdr 虚拟通道双向同步、RDPGFX/H.264、动态虚拟通道
+- 部分 Windows 主机在频繁探测后可能 `connection reset`(目标机策略/限流,非 NLA 失败)
+
+---
+
+## 架构与调用链
+
+```
+RdpDialog.tsx
+ │ WebSocket /ws/rdp?id=&width=&height=&token=
+ ▼
+controller/rdp.go
+ │ loadRDPCredential() → engine.Run()
+ ▼
+pkg/rdp/engine.go → SessionEngine
+ ├── session_engine.go readLoop + handleEvent
+ ├── session_connect.go OpenSession (CredSSP + MCS + SuppressOutput)
+ ├── packet_io.go TPKT / FastPath 读写
+ ├── fastpath_recv.go 服务端更新解析
+ ├── bitmap_decode.go RLE/翻转 → PNG
+ ├── input_fastpath.go 键鼠 / 剪贴板 / auto_exec
+ ├── mppc.go MPPC 解压
+ └── rle/ 位图 RLE 解压算法
+```
+
+---
+
+## 后端文件清单
+
+### 协议栈 `backend/pkg/rdp/`
+
+| 文件 | 说明 |
+|------|------|
+| `engine.go` | `Engine` 接口、`RDP_ENGINE` 引擎选择 |
+| `client.go` | `ProbeHandshake` / `ProbeTLSHandshake` / `ProbeCredSSP` |
+| `session_engine.go` | 图形会话主循环、帧推送、输入分发 |
+| `session_connect.go` | `OpenSession`、SuppressOutput 激活 |
+| `mcs_probe.go` | MCS Connect、GCC、ClientInfo、ConfirmActive 构建与探测 |
+| `packet_io.go` | `ReadWirePacket`、`WriteFastPathInput` |
+| `fastpath_recv.go` | FastPath 更新、MPPC、SurfaceCmd 解析 |
+| `bitmap_decode.go` | 位图解码与 PNG 编码 |
+| `input_fastpath.go` | Fast-Path Input 事件编码 |
+| `mppc.go` | MPPC-64K 解压器 |
+| `keymap.go` | 浏览器 `KeyboardEvent.code` → RDP 扫描码 |
+| `transport/` | TPKT、X.224、TLS |
+| `credssp/` | CredSSP + NTLMv2 + ASN.1 |
+| `rle/` | RLE 位图解压(`DecompressInto`) |
+
+### 控制器 / 模型
+
+| 文件 | 说明 |
+|------|------|
+| `controller/rdp.go` | WebSocket 会话、审计录制 |
+| `controller/rdp_session.go` | 会话列表 / 回放 / 终止 API |
+| `controller/rdp_file.go` | 文件 list/upload/download/stage |
+| `controller/rdp_cred.go` | 凭据加载辅助 |
+| `model/rdp_session.go` | `RDPSession` 表 |
+
+### 文件传输
+
+| 文件 | 说明 |
+|------|------|
+| `pkg/winsmb/` | SMB 管理共享直传 `C:\Users\Public\AIOpsShare\Download` |
+| `pkg/rdpfilestage/` | 服务端暂存 + 一次性 token |
+| `frontend/.../RdpToolsPanel.tsx` | 工具面板(通用命令 / 文件 Tab) |
+| `frontend/.../rdpConstants.ts` | 虚拟 Download 盘路径常量 |
+
+---
+
+## 前端
+
+| 文件 | 说明 |
+|------|------|
+| `RdpDialog.tsx` | Canvas 渲染、WebSocket、键鼠捕获、工具栏 |
+| `RdpToolsPanel.tsx` | 剪贴板同步、文件上传下载、auto_exec 回退 |
+| `api/server/rdp-files.ts` | 文件传输 REST |
+| `api/audit/rdp.ts` | 会话审计 API |
+
+---
+
+## WebSocket 帧协议
+
+### 服务端 → 浏览器
+
+```json
+{ "type": "session", "message": "123", "width": 1280, "height": 800 }
+{ "type": "status", "message": "正在连接 RDP ..." }
+{ "type": "error", "message": "..." }
+{ "type": "bitmap", "x": 0, "y": 0, "width": 64, "height": 64, "image": "data:image/png;base64,..." }
+{ "type": "clipboard","text": "远端剪贴板内容" }
+```
+
+### 浏览器 → 服务端
+
+```json
+{ "type": "mouse_move", "x": 100, "y": 200 }
+{ "type": "mouse_down", "x": 100, "y": 200, "button": 0 }
+{ "type": "mouse_up", "x": 100, "y": 200, "button": 0 }
+{ "type": "wheel", "x": 100, "y": 200, "delta_y": -120 }
+{ "type": "key", "code": "KeyA", "key": "a", "down": true }
+{ "type": "clipboard", "text": "本地剪贴板" }
+{ "type": "auto_exec", "text": "C:\\Users\\Public\\AIOpsShare\\Download\\setup.exe" }
+```
+
+---
+
+## CredSSP 多策略重试
+
+每条策略使用**新建 TCP 连接**(`client.go`):
+
+1. `v6 + binding`(默认)
+2. `v6 + binding + UseCBT`
+3. `v6 + binding + ForceMIC`
+4. `v2 + raw`(兼容旧服务端)
+
+关键修复:`credssp.subjectPublicKey()` 使用证书 `SubjectPublicKey` 原始 ASN.1,而非 PKIX SPKI。
+
+---
+
+## 外部项目借鉴边界
+
+| 项目 | 可借鉴点 | 不直接采用原因 |
+|------|----------|----------------|
+| FreeRDP | 协议完整性、通道、编解码、客户端能力矩阵 | C 生态和体量过大,不适合直接嵌入 |
+| Apache Guacamole | Web 网关、协议代理、录制与前端桥接分层 | 依赖 guacd/C,不符合单 Go 后端目标 |
+| IronRDP | 现代模块化、状态机测试、安全边界 | Rust 生态,适合作为测试和分层参考 |
+| Teleport | 身份、RBAC、审计、Desktop Gateway 架构 | 产品目标偏堡垒机,不是轻量 RDP client |
+| tomatome/grdp / nakagami/grdp | Go 协议状态机、能力集、bitmap/input 参考 | 第三方实现已移除,仅保留 `/opt` 备份用于协议对照 |
+
+## 第三方代码归档与文档
+
+归档与变更记录目录:
+
+```
+/opt/rdp/
+├── CHANGELOG.md # 变更记录
+├── README.md # 索引
+├── docs/rdp.md # 完整说明(与本文档同步)
+└── third-party/
+ ├── nakagami-grdp-v0.8.7/
+ ├── rdp-web-refs/
+ └── grdpprobe/
+```
+
+> 第三方源码只能作为协议行为和工程分层参考;涉及 GPL 的项目不得直接搬运实现。// Logan.Li
+
+---
+
+## 验证命令
+
+```bash
+# 编译
+cd backend && CGO_ENABLED=1 go build -o aiops .
+
+# NLA 探测
+go run /tmp/rdp_cred_probe.go # ProbeCredSSP
+
+# 会话位图统计(需可达的 Windows:3389)
+go run /tmp/rdp_session_probe_main.go
+
+# Docker 端到端
+./run.sh docker up
+# 浏览器:服务器管理 → Windows 主机 → RDP 连接
+```
+
+---
+
+## 相关路由
+
+| 路径 | 说明 |
+|------|------|
+| `GET /ws/rdp` | WebSocket 图形会话 |
+| `GET /api/v1/rdp-sessions` | 会话审计列表 |
+| `GET /api/v1/servers/:id/rdp-files` | 远端文件列表 |
+| `POST /api/v1/servers/:id/rdp-files/upload` | 上传 |
+| `GET /api/v1/servers/:id/rdp-files/download` | 下载 |
diff --git a/docs/terminal-architecture.md b/docs/terminal-architecture.md
new file mode 100644
index 0000000..e92fcb7
--- /dev/null
+++ b/docs/terminal-architecture.md
@@ -0,0 +1,524 @@
+# AIOps 终端与远程桌面设计说明
+
+> 更新:2026-07-03
+> 范围:SSH WebShell、Windows RDP、Kubernetes Pod exec、kubectl 集群终端、流水线 Workspace 终端、审计实时查看与历史回放。
+> 本文用于记录当前实现、设计取舍与后续重构方向。// Logan.Li
+
+---
+
+## 1. 总体目标
+
+AIOps 的终端体系不是单一功能,而是一组共享基础能力:
+
+1. **实时交互**:浏览器通过 WebSocket 与后端建立交互式会话。
+2. **尺寸自适应**:前端终端容器变化后执行 `fit()`,并同步 `cols/rows` 到远端 PTY。
+3. **审计留痕**:会话起止、用户、资产、输入动作、命令、输出大小、录制数据可追溯。
+4. **历史回放**:SSH 终端使用文本流回放,RDP 使用 Canvas 图形帧回放。
+5. **统一体验**:SSH、Pod、kubectl、Workspace 终端共享 xterm.js 适配策略;RDP 使用 Canvas 独立渲染策略。
+
+核心原则:
+
+- 终端输入只走实时链路,审计只记录有价值的离散事件。
+- `mousemove` 等高频低价值事件不持久化。
+- RDP 历史回放采用节流图形帧,不保存每个实时帧,避免存储爆炸。
+- 前端终端窗口不手写分散 resize 逻辑,统一走 `frontend/src/lib/xterm-utils.ts`。// Logan.Li
+
+---
+
+## 2. 统一链路
+
+### 2.1 SSH / Pod / kubectl / Workspace
+
+```
+Browser xterm.js
+ ├─ onData → WebSocket JSON {status:0,data}
+ ├─ ResizeObserver + FitAddon
+ └─ resize → WebSocket JSON {status:1,cols,rows}
+ │
+ ▼
+Backend WebSocket Handler
+ ├─ SSH: ssh.Session + PTY
+ ├─ Pod exec: Kubernetes channel.k8s.io WebSocket
+ ├─ kubectl: local kubectl process + PTY
+ └─ Workspace: pipeline workspace shell + PTY
+ │
+ ▼
+Audit / Recording
+ ├─ terminal session metadata
+ ├─ asciicast text stream
+ └─ command records
+```
+
+### 2.2 Windows RDP
+
+```
+Browser Canvas
+ ├─ mouse/key/clipboard input → WebSocket JSON
+ └─ bitmap frames ← WebSocket binary RDPR
+ │
+ ▼
+controller/rdp.go
+ ├─ load RDP credential
+ ├─ create RDPSession
+ ├─ start Engine.Run()
+ ├─ broadcast live audit frames
+ └─ throttle snapshot frames for replay
+ │
+ ▼
+pkg/rdp Engine
+ └─ AIOps RDP protocol engine
+```
+
+RDP 与 xterm 终端不同:它没有 PTY 的 `cols/rows` 概念,主要优化点是输入事件合并、图形帧编码、Canvas 绘制和录屏抽样。// Logan.Li
+
+---
+
+## 3. 前端终端窗口适配
+
+公共工具:`frontend/src/lib/xterm-utils.ts`
+
+| 方法 | 作用 |
+|------|------|
+| `fitTerminal()` | 调用 FitAddon,并在 WebSocket 打开时发送 `{status:1,cols,rows}` |
+| `mountXterm()` | 等容器渲染完成后挂载 xterm,延迟多次 fit,减少右侧黑边 |
+| `attachTerminalResize()` | 通过 ResizeObserver 监听容器尺寸,节流 fit 和 resize 同步 |
+
+已接入或应接入的组件:
+
+| 组件 | 类型 | 说明 |
+|------|------|------|
+| `TerminalDialog.tsx` | SSH 单会话终端 | 资产列表打开 SSH |
+| `WebShell.tsx` | SSH 多标签终端 | WebShell 页面 |
+| `PodExecTerminal.tsx` | Pod exec | K8s Pod 容器终端 |
+| `KubectlTerminal.tsx` | kubectl | 集群级 kubectl shell |
+| `WorkspaceTermDialog.tsx` | Workspace shell | 流水线构建目录终端 |
+| `LiveMonitorDialog.tsx` | SSH 实时审计 | 只读 xterm |
+| `ReplayDialog.tsx` | SSH 历史回放 | asciicast 回放 |
+
+终端容器统一使用:
+
+```tsx
+
+```
+
+全局样式位置:`frontend/src/index.css`
+
+```css
+.xterm-terminal-host {
+ width: 100%;
+ overflow: hidden;
+ background: #0a0e14;
+}
+.xterm-terminal-host .xterm {
+ height: 100%;
+}
+```
+
+注意事项:
+
+- Dialog 动画会影响 xterm 首次测量,因此终端弹框禁用过渡。
+- `fit()` 后必须同步后端 PTY 尺寸,否则只是前端铺满,远端程序仍按旧尺寸输出。
+- `term.open()` 只能对同一个 xterm 实例执行一次,复用场景需要检查 `term.element`。// Logan.Li
+
+---
+
+## 4. SSH 终端实现
+
+主要文件:
+
+| 文件 | 说明 |
+|------|------|
+| `backend/controller/webshell.go` | SSH WebSocket、PTY、录制、命令审计 |
+| `backend/model/terminal_session.go` | SSH 会话模型 |
+| `backend/model/session_command.go` | 命令审计模型 |
+| `frontend/src/components/server/TerminalDialog.tsx` | 单资产 SSH 终端 |
+| `frontend/src/components/server/WebShell.tsx` | 多标签 SSH 终端 |
+
+协议:
+
+```json
+{ "status": 0, "data": "ls -la\n" }
+{ "status": 1, "cols": 120, "rows": 34 }
+```
+
+审计策略:
+
+- 会话记录:开始时间、结束时间、用户、资产、状态、录制大小。
+- 录制数据:asciicast 文本流。
+- 命令记录:按回车提交后提取命令,不记录每个按键。
+- 输出统计:下一条命令前累计 stdout 字节数写入上一条命令。
+
+优化方向:
+
+- 命令列表按会话聚合展示,减少碎片行。
+- 对连续输入、粘贴、多行脚本增加摘要视图。
+- 高危命令识别只用于展示和告警,不阻塞正常录制。// Logan.Li
+
+---
+
+## 5. Kubernetes Pod Exec
+
+主要文件:
+
+| 文件 | 说明 |
+|------|------|
+| `backend/controller/k8s_pod.go` | Pod exec WebSocket 代理 |
+| `frontend/src/components/kubernetes/PodExecTerminal.tsx` | Pod 终端 UI |
+
+链路:
+
+```
+xterm.js
+ └─ WebSocket JSON
+ └─ backend
+ └─ Kubernetes API WebSocket channel.k8s.io
+```
+
+K8s channel 映射:
+
+| Channel | 方向 | 说明 |
+|---------|------|------|
+| 0 | client → pod | stdin |
+| 1 | pod → client | stdout |
+| 2 | pod → client | stderr |
+| 3 | pod → client | error/status |
+| 4 | client → pod | resize |
+
+前端 resize JSON:
+
+```json
+{ "status": 1, "cols": 120, "rows": 34 }
+```
+
+后端转换为 K8s resize frame:
+
+```json
+{ "Width": 120, "Height": 34 }
+```
+
+注意事项:
+
+- Pod exec 不应进入 SSH 命令审计表,除非未来设计 K8s 独立审计模型。
+- 容器切换、shell 切换会重连 WebSocket。
+- ESC 必须传给终端,Dialog 不能用 ESC 自动关闭。// Logan.Li
+
+---
+
+## 6. kubectl 集群终端
+
+主要文件:
+
+| 文件 | 说明 |
+|------|------|
+| `backend/controller/kubectl_shell.go` | kubectl shell WebSocket |
+| `frontend/src/components/kubernetes/KubectlTerminal.tsx` | kubectl 终端 UI |
+
+设计目标:
+
+- 给运维提供集群级 kubectl shell。
+- 使用与 SSH 相同的 xterm 自适应策略。
+- 后端负责 kubeconfig/集群上下文隔离。
+
+风险点:
+
+- kubectl shell 权限大,必须依赖 RBAC、审计和操作权限控制。
+- 后续应对危险命令做二次确认或审计标记。
+- 多集群切换时不能复用旧进程上下文。// Logan.Li
+
+---
+
+## 7. Workspace 终端
+
+主要文件:
+
+| 文件 | 说明 |
+|------|------|
+| `backend/controller/workspace_files.go` | 流水线工作区文件/终端入口 |
+| `frontend/src/components/deploy/WorkspaceTermDialog.tsx` | 工作区终端 UI |
+
+用途:
+
+- 进入流水线构建目录查看文件。
+- 辅助排查构建产物、日志、临时文件。
+
+设计约束:
+
+- 默认目录必须限制在 pipeline workspace 内。
+- 后端不能允许用户通过路径逃逸进入任意系统目录。
+- 终端尺寸适配与 SSH 共用 `xterm-utils`。// Logan.Li
+
+---
+
+## 8. RDP 实现
+
+主要文件:
+
+| 文件 | 说明 |
+|------|------|
+| `backend/controller/rdp.go` | RDP WebSocket、会话、审计、实时帧推送 |
+| `backend/controller/rdp_live.go` | RDP 实时审计广播 |
+| `backend/controller/rdp_session.go` | RDP 会话列表、录制、终止 API |
+| `backend/pkg/rdp/engine.go` | 引擎选择 |
+| `backend/pkg/rdp/session_engine.go` | 自研 RDP 引擎主循环 |
+| `frontend/src/components/server/RdpDialog.tsx` | RDP 实时操作窗口 |
+| `frontend/src/components/system/RdpAuditViewer.tsx` | RDP 实时审计与历史回放 |
+
+### 8.1 自研 RDP 引擎策略
+
+当前后端固定使用自研 `SessionEngine`,不再通过 `RDP_ENGINE` 切换第三方兜底。已删除的 grdp 目录备份到:
+
+```text
+/opt/rdp/third-party/grdp-aiops-backup-20260703
+```
+
+仓内 `backend/third_party` 已清理,Dockerfile 不再复制本地 replace 模块。// Logan.Li
+
+### 8.2 WebSocket 帧协议
+
+服务端到浏览器:
+
+```json
+{ "type": "session", "message": "123", "width": 1024, "height": 800 }
+{ "type": "status", "message": "正在连接 RDP ..." }
+{ "type": "error", "message": "..." }
+```
+
+图像帧优先走二进制:
+
+```text
+RDPR + x:uint32 + y:uint32 + width:uint32 + height:uint32 + rgba:[]
+```
+
+历史回放帧走节流 PNG:
+
+```json
+{ "type": "bitmap", "x": 0, "y": 0, "width": 300, "height": 200, "image": "data:image/png;base64,..." }
+```
+
+浏览器到服务端:
+
+```json
+{ "type": "mouse_move", "x": 100, "y": 200 }
+{ "type": "mouse_down", "x": 100, "y": 200, "button": 0 }
+{ "type": "mouse_up", "x": 100, "y": 200, "button": 0 }
+{ "type": "wheel", "x": 100, "y": 200, "delta_y": -120 }
+{ "type": "key", "code": "KeyA", "key": "a", "down": true }
+{ "type": "clipboard", "text": "..." }
+```
+
+### 8.3 输入优化
+
+- 前端 `mousemove` 16ms 合并,只保留最新坐标。
+- `mouse_down`、`mouse_up` 前先 flush 最新坐标,保证拖拽起止准确。
+- 后端输入队列满时丢弃移动事件,关键事件优先。
+- RDP 引擎处理输入时不打印每个 `mousemove`,避免日志拖慢实时操作。
+
+### 8.4 图形优化
+
+- 实时图形帧使用 RGBA 二进制直传,历史回放使用节流 PNG 快照。
+- 前端 Canvas 使用 `putImageData()` 绘制二进制帧。
+- 右下角帧统计节流刷新,避免每帧 React 重渲染。
+- RDP 录屏只保存节流快照,不保存每个实时帧。
+
+### 8.5 审计策略
+
+持久化:
+
+- 会话元数据:用户、资产、开始/结束时间、状态、录制大小。
+- 离散操作:点击、滚轮、剪贴板、文件上传下载、输入摘要。
+- 历史回放:节流图形快照。
+
+不持久化:
+
+- 高频 `mousemove`。
+- 每个实时 RGBA 帧。
+- 无业务含义的连续坐标变化。
+
+拖拽建议记录:
+
+```text
+[RDP拖拽] button=left start=(x1,y1) end=(x2,y2) duration=850ms
+```
+
+普通点击建议记录:
+
+```text
+[RDP点击] button=left x=100 y=200
+```
+
+键盘输入建议合并:
+
+```text
+[RDP输入] abc*** # 敏感字段后续可脱敏
+```
+
+---
+
+## 9. 审计页设计
+
+当前菜单:`/audit/terminal`
+
+建议子页签:
+
+| 页签 | 内容 |
+|------|------|
+| 在线会话 | SSH 在线会话 + RDP 在线会话 |
+| 历史会话 | SSH 历史 asciicast 回放 + RDP Canvas 回放 |
+| 会话命令 | SSH 命令 + RDP 操作摘要,按会话聚合展示 |
+
+### 9.1 在线会话
+
+SSH:
+
+- 使用 `LiveMonitorDialog`
+- 后端 `/ws/audit/:sessionId`
+- 只读 xterm
+
+RDP:
+
+- 使用 `RdpAuditViewer mode="live"`
+- 后端 `/ws/rdp-audit/:sessionId`
+- 只读 Canvas
+
+### 9.2 历史会话
+
+SSH:
+
+- 使用 `ReplayDialog`
+- 数据源:`terminalSessionApi.getRecord()`
+- 格式:asciicast 文本
+
+RDP:
+
+- 使用 `RdpAuditViewer mode="replay"`
+- 数据源:`rdpSessionApi.getRecord()`
+- 格式:节流 PNG bitmap JSON
+
+### 9.3 会话命令 / 操作摘要
+
+问题:
+
+- SSH 命令记录适合逐条展示。
+- RDP 如果按键、点击、剪贴板都逐条展示,会出现大量低价值行。
+
+建议:
+
+1. 后端写入时尽量合并:
+ - 连续键盘输入合并为 `[RDP输入] ...`
+ - 拖拽只记录开始/结束。
+ - 点击和滚轮按短时间窗口合并。
+2. 前端展示时按会话聚合:
+ - 默认展示会话摘要。
+ - 展开后看详细事件。
+ - 高危动作、文件传输、剪贴板单独标记。
+
+---
+
+## 10. 当前架构质量评估
+
+### 10.1 做得好的地方
+
+- `backend/pkg/rdp/domain` 定义了引擎接口,控制器不直接依赖协议细节。
+- `transport/`、`credssp/`、`rle/`、`display/` 已经有基础分层。
+- RDP 依赖已收敛到自研协议栈,构建不再需要 `backend/third_party`。
+- xterm 终端已抽出公共适配工具,后续维护成本低。
+- RDP 实时帧和历史回放帧开始分离,避免审计存储被实时帧撑爆。
+
+### 10.2 需要优化的地方
+
+| 文件 | 问题 | 建议 |
+|------|------|------|
+| `backend/pkg/rdp/mcs_probe.go` | 1664 行,严重超过红线 | 拆成 `mcs_connect.go`、`gcc_caps.go`、`confirm_active.go`、`client_info.go`、`probe.go` |
+| `backend/controller/rdp.go` | 约 460 行,承担会话、审计、帧编码、快照 | 拆出 `rdp_sink.go`、`rdp_audit.go`、`rdp_input_audit.go` |
+| `RdpDialog.tsx` | 实时渲染、输入、工具面板入口集中 | 后续拆出 `useRdpSocket()`、`useRdpCanvas()`、`useRdpInput()` |
+| `PodExecTerminal.tsx` | 连接、UI、resize 混合 | 可抽 `useXtermWebSocket()` |
+| `KubectlTerminal.tsx` | 与 Pod exec 重复 | 与 Pod/Workspace 共用 hook |
+
+### 10.3 建议目标结构
+
+后端 RDP:
+
+```text
+backend/pkg/rdp/
+├── domain/
+├── engine.go
+├── session_engine.go
+├── input/
+│ ├── fastpath.go
+│ └── keymap.go
+├── graphics/
+│ ├── framebuffer.go
+│ ├── codec.go
+│ └── rle/
+├── mcs/
+│ ├── connect.go
+│ ├── gcc.go
+│ ├── caps.go
+│ └── active.go
+├── credssp/
+└── transport/
+```
+
+控制器:
+
+```text
+backend/controller/
+├── rdp.go # 路由入口和会话编排
+├── rdp_sink.go # WebSocket frame sink
+├── rdp_live.go # 实时审计广播
+├── rdp_input_audit.go # 点击/拖拽/输入摘要
+├── rdp_session.go # 会话 REST API
+├── rdp_file.go
+└── rdp_cred.go
+```
+
+前端:
+
+```text
+frontend/src/components/terminal/
+├── useXtermSocket.ts
+├── XtermDialogShell.tsx
+└── terminalTheme.ts
+
+frontend/src/components/rdp/
+├── useRdpSocket.ts
+├── useRdpCanvas.ts
+├── useRdpInput.ts
+├── RdpDialog.tsx
+└── RdpAuditViewer.tsx
+```
+
+---
+
+## 11. 验证清单
+
+终端类修改后建议验证:
+
+```bash
+cd frontend
+npx tsc --noEmit
+npm run build
+```
+
+后端修改后建议验证:
+
+```bash
+cd backend
+go build -o aiops .
+```
+
+端到端:
+
+```bash
+./run.sh docker up
+python3 tests/integration_test.py --host localhost --port 8080
+```
+
+手动检查:
+
+- SSH 终端打开、缩放、全屏、输入、回放。
+- Pod exec 打开、切 shell、缩放、全屏。
+- kubectl 终端打开、缩放、全屏。
+- Workspace 终端打开、缩放、全屏。
+- RDP 打开、拖动窗口、文件夹拖拽、实时审计、历史回放。
+- 会话命令页筛选、导出、从命令跳转回放。// Logan.Li
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
index bc3df9a..c852688 100644
--- a/frontend/eslint.config.js
+++ b/frontend/eslint.config.js
@@ -35,6 +35,14 @@ export default defineConfig([
'error',
{ fixToUnknown: false, ignoreRestArgs: true },
],
+ // React Compiler 迁移型规则先作为告警推进,避免历史页面一次性阻断本地门禁。// Logan.Li
+ 'react-hooks/static-components': 'warn',
+ 'react-hooks/set-state-in-effect': 'warn',
+ 'react-hooks/purity': 'warn',
+ 'react-hooks/immutability': 'warn',
+ 'react-hooks/preserve-manual-memoization': 'warn',
+ // 工具函数和组件同文件导出在历史代码中较多,先降级为告警后续逐步拆分。// Logan.Li
+ 'react-refresh/only-export-components': 'warn',
},
},
])
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index d1e3851..dc8d20d 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
- "name": "my-app",
- "version": "0.0.0",
+ "name": "aiops-frontend",
+ "version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "my-app",
- "version": "0.0.0",
+ "name": "aiops-frontend",
+ "version": "0.1.0",
"dependencies": {
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-yaml": "^6.1.3",
diff --git a/frontend/package.json b/frontend/package.json
index 3c4a085..9e4e986 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,11 +1,14 @@
{
- "name": "my-app",
+ "name": "aiops-frontend",
"private": true,
- "version": "0.0.0",
+ "version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
+ "typecheck": "tsc --noEmit",
+ "check": "npm run typecheck",
+ "check:strict": "npm run lint && npm run typecheck",
"lint": "eslint .",
"preview": "vite preview"
},
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 6f5afc2..c392693 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -16,8 +16,7 @@ const Dashboard = lazy(() => import('@/components/dashboard/Dashboard').then(m =
const ServerManager = lazy(() => import('@/components/server/ServerManager').then(m => ({ default: m.ServerManager })));
const CloudAccountManager = lazy(() => import('@/components/cloud/CloudAccountManager').then(m => ({ default: m.CloudAccountManager })));
const DomainManager = lazy(() => import('@/components/cloud/DomainManager').then(m => ({ default: m.DomainManager })));
-const RegionManager = lazy(() => import('@/components/cloud/RegionManager').then(m => ({ default: m.RegionManager })));
-const PlatformManager = lazy(() => import('@/components/cloud/PlatformManager').then(m => ({ default: m.PlatformManager })));
+
const CloudBilling = lazy(() => import('@/components/cloud/CloudBilling').then(m => ({ default: m.CloudBilling })));
const KubernetesClusters = lazy(() => import('@/components/kubernetes/KubernetesClusters').then(m => ({ default: m.KubernetesClusters })));
const ClusterOverviewPage = lazy(() => import('@/components/kubernetes/ClusterOverviewPage').then(m => ({ default: m.default })));
@@ -123,8 +122,8 @@ function App() {
} />
} />
} />
- } />
- } />
+ } />
+ } />
} />
{/* Kubernetes Routes */}
diff --git a/frontend/src/api/aiops/agent-cli.ts b/frontend/src/api/aiops/agent-cli.ts
index bbcbcbd..a78503c 100644
--- a/frontend/src/api/aiops/agent-cli.ts
+++ b/frontend/src/api/aiops/agent-cli.ts
@@ -6,9 +6,15 @@ import { useUserStore } from '@/store';
export interface AgentCliSession {
id: number;
user_id: number;
+ mode: string; // host | kubernetes
server_id: number;
server_name: string;
server_host: string;
+ cluster_id: number;
+ cluster_name: string;
+ namespace: string;
+ pod_name: string;
+ container: string;
title: string;
status: string; // running/done/aborted/error
provider: string;
@@ -38,25 +44,56 @@ export interface AgentCliMessage {
created_at: string;
}
+export type AgentCliMode = 'host' | 'kubernetes';
+
+export type AgentCliWsParams =
+ | { mode?: 'host'; serverId: number | string }
+ | { mode: 'kubernetes'; cluster: string; namespace: string; pod?: string; container?: string };
+
export const agentCliApi = {
- // 会话列表(按当前用户隔离)
- getSessions: async (params?: { server_id?: number; page?: number; page_size?: number }) => {
+ getSessions: async (params?: {
+ mode?: AgentCliMode;
+ server_id?: number;
+ cluster_id?: number;
+ namespace?: string;
+ page?: number;
+ page_size?: number;
+ }) => {
return http.get<{ list: AgentCliSession[]; total: number }>('/aiops/agent-cli/sessions', params);
},
- // 会话详情(含完整推理消息,供回放)
getSession: async (id: number | string) => {
return http.get<{ session: AgentCliSession; messages: AgentCliMessage[] }>(`/aiops/agent-cli/sessions/${id}`);
},
- // 删除会话
deleteSession: async (id: number | string) => {
return http.delete(`/aiops/agent-cli/sessions/${id}`);
},
};
-// buildAgentCliWsUrl 构造 Agent CLI WebSocket 地址(浏览器无法设 header,用 ?token= 回退) // Logan.Li
-export function buildAgentCliWsUrl(serverId: number | string): string {
- const proto = typeof location !== 'undefined' && location.protocol === 'https:' ? 'wss' : 'ws';
- // Token 双保险:store 优先,localStorage 兜底 // Logan.Li
- const token = useUserStore.getState().token || localStorage.getItem('token') || '';
- return `${proto}://${location.host}/ws/agent-cli?id=${serverId}&token=${encodeURIComponent(token)}`;
+function wsToken() {
+ return useUserStore.getState().token || localStorage.getItem('token') || '';
}
+
+// buildAgentCliWsUrl 构造 Agent CLI WebSocket 地址 // Logan.Li
+export function buildAgentCliWsUrl(params: AgentCliWsParams | number | string): string {
+ const proto = typeof location !== 'undefined' && location.protocol === 'https:' ? 'wss' : 'ws';
+ const token = encodeURIComponent(wsToken());
+ const base = `${proto}://${location.host}/ws/agent-cli`;
+
+ if (typeof params === 'number' || typeof params === 'string') {
+ return `${base}?mode=host&id=${params}&token=${token}`;
+ }
+
+ if (params.mode === 'kubernetes') {
+ const qs = new URLSearchParams({
+ mode: 'kubernetes',
+ cluster: params.cluster,
+ namespace: params.namespace,
+ token: wsToken(),
+ });
+ if (params.pod) qs.set('pod', params.pod);
+ if (params.container) qs.set('container', params.container);
+ return `${base}?${qs.toString()}`;
+ }
+
+ return `${base}?mode=host&id=${params.serverId}&token=${token}`;
+}
\ No newline at end of file
diff --git a/frontend/src/api/audit/index.ts b/frontend/src/api/audit/index.ts
index aa9f2ad..cafab78 100644
--- a/frontend/src/api/audit/index.ts
+++ b/frontend/src/api/audit/index.ts
@@ -1,4 +1,5 @@
// 安全审计 API 聚合出口 // Logan.Li
export * from './audit';
+export * from './rdp';
export * from './terminal';
diff --git a/frontend/src/api/audit/rdp.ts b/frontend/src/api/audit/rdp.ts
new file mode 100644
index 0000000..0b23340
--- /dev/null
+++ b/frontend/src/api/audit/rdp.ts
@@ -0,0 +1,22 @@
+// Windows RDP 图形会话审计 API // Logan.Li
+
+import { http, type ListResponse } from '@/utils/request';
+
+export const rdpSessionApi = {
+ list: async (params?: {
+ page?: number;
+ page_size?: number;
+ server_id?: number;
+ username?: string;
+ status?: string;
+ keyword?: string;
+ start_date?: string;
+ end_date?: string;
+ has_record?: string | boolean;
+ }) => http.get('/rdp-sessions', params as Record),
+ get: async (id: number) => http.get(`/rdp-sessions/${id}`),
+ getRecord: async (id: number) => http.get(`/rdp-sessions/${id}/record`),
+ kill: async (id: number) => http.post(`/rdp-sessions/${id}/kill`),
+ download: async (id: number) => http.download(`/rdp-sessions/${id}/download`, `rdp-session-${id}.json`),
+ logReplayView: async (id: number) => http.post(`/rdp-sessions/${id}/replay-view`),
+};
diff --git a/frontend/src/api/cloud/account.ts b/frontend/src/api/cloud/account.ts
index f962544..ebfa3a6 100644
--- a/frontend/src/api/cloud/account.ts
+++ b/frontend/src/api/cloud/account.ts
@@ -24,9 +24,17 @@ export const cloudApi = {
discoverInstances: async (id: number | string, regions: string[]) => {
return http.post<{ list: Record[]; total: number; errors: string[] }>(`/cloud-accounts/${id}/discover-instances`, { regions });
},
- importInstances: async (id: number | string, instances: Record[], opts?: { default_port?: number; credential_id?: number }) => {
+ importInstances: async (
+ id: number | string,
+ instances: Record[],
+ opts?: { default_port?: number; credential_id?: number; ssh_credential_id?: number; rdp_credential_id?: number },
+ ) => {
return http.post<{ imported: number; skipped: number }>(`/cloud-accounts/${id}/import-instances`, { instances, ...opts });
},
+ /** 同步指定云账号账单(默认昨日) // Logan.Li */
+ syncBilling: async (id: number | string, params?: { date?: string }) => {
+ return http.post(`/cloud-accounts/${id}/sync-billing`, params || {});
+ },
getDomains: async (params?: { page?: number; page_size?: number; keyword?: string }) => {
return http.get('/domains', params);
},
@@ -39,4 +47,4 @@ export const cloudApi = {
deleteDomain: async (id: number | string) => {
return http.delete(`/domains/${id}`);
},
-};
\ No newline at end of file
+};
diff --git a/frontend/src/api/cloud/billing.ts b/frontend/src/api/cloud/billing.ts
index 70eb9f4..fb1d612 100644
--- a/frontend/src/api/cloud/billing.ts
+++ b/frontend/src/api/cloud/billing.ts
@@ -6,7 +6,7 @@ export const billingApi = {
getStats: async () => {
return http.get('/cloud-billings/stats');
},
- list: async (params?: { provider?: string; start_date?: string; end_date?: string; page?: number; page_size?: number }) => {
+ list: async (params?: { provider?: string; cloud_account_id?: number; start_date?: string; end_date?: string; page?: number; page_size?: number }) => {
return http.get('/cloud-billings', params);
},
create: async (data: Record) => {
@@ -21,6 +21,17 @@ export const billingApi = {
getProviders: async () => {
return http.get('/cloud-billings/providers');
},
+ /** 从云账号 AK 同步账单(默认昨日) // Logan.Li */
+ sync: async (params?: { account_id?: number; date?: string }) => {
+ return http.post<{
+ billing_date: string;
+ accounts: number;
+ created: number;
+ updated: number;
+ skipped: number;
+ errors: string[];
+ }>('/cloud-billings/sync', params || {});
+ },
listAlertRules: async () => {
return http.get('/billing-alerts');
},
diff --git a/frontend/src/api/monitor/grafana.ts b/frontend/src/api/monitor/grafana.ts
deleted file mode 100644
index 5e7a5a8..0000000
--- a/frontend/src/api/monitor/grafana.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-// 自定义监控仪表盘 API // Logan.Li
-
-import { http } from '@/utils/request';
-import type { GrafanaDashboard } from '@/types/monitor';
-
-export const dashboardApi = {
- getDashboards: async (): Promise<{ data: GrafanaDashboard[]; total: number }> => {
- const res = await http.get('/custom-dashboards');
- // http 自动解包 .data,后端返回 {list, total}
- const body = (res as Record) ?? {};
- const list = (body.list as GrafanaDashboard[]) ?? [];
- const total = (body.total as number) ?? list.length;
- return { data: list, total };
- },
- getDatasources: async (): Promise<{ data: Record[]; total: number }> => {
- // 复用 Prometheus 数据源列表
- const res = await http.get('/prometheus-datasources');
- const arr = (res as Record[]) ?? [];
- return { data: arr, total: arr.length };
- },
- createDashboard: async (data: Partial): Promise<{ success: boolean }> => {
- // 将 panels 数组序列化为 JSON 字符串
- const payload: Record = {
- name: data.name ?? '',
- description: data.description ?? '',
- datasource: data.datasource ?? 'prometheus',
- datasource_url: data.datasourceUrl ?? '',
- panels: JSON.stringify(data.panels ?? []),
- is_default: data.isDefault ?? false,
- };
- await http.post('/custom-dashboards', payload);
- return { success: true };
- },
- updateDashboard: async (id: number | string, data: Partial): Promise<{ success: boolean }> => {
- const payload: Record = {};
- if (data.name !== undefined) payload.name = data.name;
- if (data.description !== undefined) payload.description = data.description;
- if (data.datasource !== undefined) payload.datasource = data.datasource;
- if (data.datasourceUrl !== undefined) payload.datasource_url = data.datasourceUrl;
- if (data.panels !== undefined) payload.panels = JSON.stringify(data.panels);
- if (data.isDefault !== undefined) payload.is_default = data.isDefault;
- await http.put(`/custom-dashboards/${id}`, payload);
- return { success: true };
- },
- deleteDashboard: async (id: number | string): Promise<{ success: boolean }> => {
- await http.delete(`/custom-dashboards/${id}`);
- return { success: true };
- },
- setDefault: async (id: number | string): Promise<{ success: boolean }> => {
- await http.post(`/custom-dashboards/${id}/default`);
- return { success: true };
- },
- createDatasource: async (_data: Record): Promise<{ success: boolean }> => {
- return { success: false };
- },
-};
diff --git a/frontend/src/api/monitor/index.ts b/frontend/src/api/monitor/index.ts
index 6ad17df..b02fe5b 100644
--- a/frontend/src/api/monitor/index.ts
+++ b/frontend/src/api/monitor/index.ts
@@ -7,6 +7,5 @@ export { alertMetricsApi } from './alert-metrics';
export type { MetricCatalogItem, MetricCatalogCategory, UnifiedAlertRule } from './alert-metrics';
export { traceApi } from './trace';
export { traceDatasourceApi, type TraceDataSource } from './trace-datasource';
-export { dashboardApi } from './grafana';
export { promDatasourceApi, zabbixDatasourceApi } from './datasource';
-export { metricsApi, snmpApi, prometheusProxyApi, zabbixProxyApi, middlewareMonitorApi } from './metrics';
\ No newline at end of file
+export { metricsApi, snmpApi, prometheusProxyApi, zabbixProxyApi, middlewareMonitorApi } from './metrics';
diff --git a/frontend/src/api/server/rdp-files.ts b/frontend/src/api/server/rdp-files.ts
new file mode 100644
index 0000000..f99af77
--- /dev/null
+++ b/frontend/src/api/server/rdp-files.ts
@@ -0,0 +1,38 @@
+import http from '@/utils/request';
+
+export interface RdpFileItem {
+ name: string;
+ size: number;
+ is_dir: boolean;
+ mod_time: string;
+}
+
+export interface RdpFileListData {
+ root: string;
+ list: RdpFileItem[];
+ total: number;
+}
+
+export interface RdpStageResult {
+ token: string;
+ download_url: string;
+ remote_path: string;
+ deploy_command: string;
+ mode: string;
+}
+
+export const rdpFilesApi = {
+ list: async (serverId: number) => http.get(`/servers/${serverId}/rdp-files`),
+ upload: async (serverId: number, file: File) => {
+ const fd = new FormData();
+ fd.append('file', file);
+ return http.upload<{ name: string; path: string; size: number }>(`/servers/${serverId}/rdp-files/upload`, fd);
+ },
+ stage: async (serverId: number, file: File) => {
+ const fd = new FormData();
+ fd.append('file', file);
+ return http.upload(`/servers/${serverId}/rdp-files/stage`, fd);
+ },
+ download: async (serverId: number, name: string) =>
+ http.download(`/servers/${serverId}/rdp-files/download?name=${encodeURIComponent(name)}`, name),
+};
\ No newline at end of file
diff --git a/frontend/src/api/server/server.ts b/frontend/src/api/server/server.ts
index 41a3bde..5d3c9fe 100644
--- a/frontend/src/api/server/server.ts
+++ b/frontend/src/api/server/server.ts
@@ -24,7 +24,15 @@ export const serverApi = {
batchDelete: async (ids: number[]) => {
return http.post('/servers/batch-delete', { ids });
},
- batchCreate: async (data: { servers: Record[]; region_id?: string; auth_type?: string; credential_id?: number; default_port?: number }) => {
+ batchCreate: async (data: {
+ servers: Record[];
+ region_id?: string;
+ auth_type?: string;
+ credential_id?: number;
+ ssh_credential_id?: number;
+ rdp_credential_id?: number;
+ default_port?: number;
+ }) => {
return http.post('/servers/batch', data);
},
getMetrics: async (_id: number | string) => {
@@ -57,4 +65,4 @@ export const serverApi = {
mkdir: async (serverId: number, dirPath: string) => {
return http.post(`/servers/${serverId}/files/mkdir`, { path: dirPath });
},
-};
\ No newline at end of file
+};
diff --git a/frontend/src/components/aiops/AIAlertAnalyzer.tsx b/frontend/src/components/aiops/AIAlertAnalyzer.tsx
index 520a7e9..53f919a 100644
--- a/frontend/src/components/aiops/AIAlertAnalyzer.tsx
+++ b/frontend/src/components/aiops/AIAlertAnalyzer.tsx
@@ -34,7 +34,7 @@ import { useTranslation } from 'react-i18next';
function parseAIResponse(raw: string, fallback: AIAnalysis): AIAnalysis {
try {
// Strip markdown code fences if present
- let json = raw.replace(/```(?:json)?\s*\n?/g, '').replace(/```$/, '').trim();
+ const json = raw.replace(/```(?:json)?\s*\n?/g, '').replace(/```$/, '').trim();
const parsed = JSON.parse(json);
return {
rootCause: parsed.root_cause || parsed.rootCause || fallback.rootCause,
diff --git a/frontend/src/components/aiops/AIPredictiveAnalytics.tsx b/frontend/src/components/aiops/AIPredictiveAnalytics.tsx
index 4fa58ba..e8211b0 100644
--- a/frontend/src/components/aiops/AIPredictiveAnalytics.tsx
+++ b/frontend/src/components/aiops/AIPredictiveAnalytics.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { StatStrip } from '@/components/ui/stat-strip';
import { Button } from '@/components/ui/button';
@@ -15,7 +15,6 @@ import {
Brain,
TrendingUp,
TrendingDown,
- AlertTriangle,
HardDrive,
Cpu,
MemoryStick,
@@ -23,36 +22,36 @@ import {
Activity,
Calendar,
Sparkles,
- Zap,
Minus,
Loader2,
} from 'lucide-react';
-import { dashboardStatsApi, serverApi, alertApi } from '@/api';
+import { dashboardStatsApi, serverApi, billingApi } from '@/api';
import { PredictiveCapacityTab } from './PredictiveCapacityTab';
+import { PredictiveCostTab } from './PredictiveCostTab';
import {
computePredictions,
+ computeCostPredictions,
trendColorClass,
type CapacityForecast,
} from './predictUtils';
import { PageShell } from '@/components/layout/PageShell';
+import { formatCurrency } from '@/components/cloud/CloudBillingHelpers';
import type { JsonData } from '@/types/common';
-import { useTranslation } from 'react-i18next';
// 重新导出 CapacityForecast,兼容已有 import 路径 './AIPredictiveAnalytics'
export type { CapacityForecast };
export function AIPredictiveAnalytics() {
- const { t } = useTranslation();
const [selectedTimeRange, setSelectedTimeRange] = useState('24h');
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState('metrics');
- // 原始数据
const [rawMetrics, setRawMetrics] = useState([]);
const [serverStats, setServerStats] = useState({});
- const [alertEvents, setAlertEvents] = useState([]);
- const [alertRules, setAlertRules] = useState([]);
+ const [billingStats, setBillingStats] = useState(null);
+ const [billingRecords, setBillingRecords] = useState([]);
+ const [billingAlertRules, setBillingAlertRules] = useState([]);
useEffect(() => {
void loadData();
@@ -61,18 +60,23 @@ export function AIPredictiveAnalytics() {
const loadData = async () => {
setLoading(true);
try {
- const [metricsData, statsResp, alertsResp, rulesResp] = await Promise.all([
+ const start = new Date();
+ start.setDate(start.getDate() - 89);
+ const startDate = start.toISOString().slice(0, 10);
+
+ const [metricsData, statsResp, billStats, billList, billRules] = await Promise.all([
dashboardStatsApi.getMetrics(72),
serverApi.getServerStats(),
- alertApi.getAlerts({ page: 1, page_size: 100 }),
- alertApi.getRules(),
+ billingApi.getStats().catch(() => null),
+ billingApi.list({ start_date: startDate, page_size: 500 }).catch(() => ({ list: [] })),
+ billingApi.listAlertRules().catch(() => ({ list: [] })),
]);
+
setRawMetrics(Array.isArray(metricsData) ? metricsData : []);
setServerStats(statsResp || {});
- const alertsRaw = (alertsResp as JsonData)?.list || (alertsResp as JsonData) || [];
- setAlertEvents(Array.isArray(alertsRaw) ? alertsRaw : []);
- const rulesRaw = (rulesResp as JsonData)?.list || (rulesResp as JsonData) || [];
- setAlertRules(Array.isArray(rulesRaw) ? rulesRaw : []);
+ setBillingStats(billStats);
+ setBillingRecords((billList as JsonData)?.list || []);
+ setBillingAlertRules((billRules as JsonData)?.list || []);
} catch {
// 保持空数据,组件显示空状态
} finally {
@@ -86,15 +90,21 @@ export function AIPredictiveAnalytics() {
setIsAnalyzing(false);
};
- // ============ 综合预测推算(线性回归 + 告警规则阈值 + 事件频率) ============
- const {
- predictions,
- capacityForecasts,
- dedupedAnomalies,
- accuracy,
- predictedAlertCount,
- capacityWarningCount,
- } = computePredictions({ rawMetrics, serverStats, alertEvents, alertRules, selectedTimeRange });
+ const { predictions, capacityForecasts, accuracy, capacityWarningCount } = computePredictions({
+ rawMetrics,
+ serverStats,
+ selectedTimeRange,
+ });
+
+ const costData = useMemo(
+ () =>
+ computeCostPredictions({
+ billingStats,
+ billingRecords,
+ billingAlertRules,
+ }),
+ [billingStats, billingRecords, billingAlertRules],
+ );
const getTrendIcon = (trend: string) => {
switch (trend) {
@@ -108,16 +118,16 @@ export function AIPredictiveAnalytics() {
};
const pageTabs = [
- { id: 'metrics', label: t('aiops.metricPredict') },
- { id: 'capacity', label: t('aiops.predictiveCapacity') },
- { id: 'anomaly', label: t('aiops.alertAnalysisPredict') },
+ { id: 'metrics', label: '指标预测' },
+ { id: 'capacity', label: '容量规划' },
+ { id: 'cost', label: '成本预测' },
];
return (
}
tabs={pageTabs}
activeTab={activeTab}
@@ -125,10 +135,16 @@ export function AIPredictiveAnalytics() {
metrics={(
0
+ ? formatCurrency(costData.summary.predictedMonthEnd)
+ : '—',
+ color: 'purple',
+ },
]}
/>
)}
@@ -140,10 +156,10 @@ export function AIPredictiveAnalytics() {
- {t('monitor.traceMgr.hour1')}
- {t('monitor.traceMgr.hour24')}
- {t('monitor.traceMgr.day7')}
- {t('monitor.siteProtocol.ssl.ref30days')}
+ 1小时
+ 24小时
+ 7天
+ 30天