feat: 完善堡垒机与 Agent 智能运维能力
- 支持 Windows Agent 构建、安装脚本和统一上报协议 - 增强 RDP 剪贴板、虚拟盘和文件传输能力 - 新增 AIOps 上下文工具、告警 RCA trace 落库和回放接口 - 重构 docs 模块文档并补充终端、堡垒机、监控、AI Agent 设计
This commit is contained in:
1 parent
a9513c2056
commit
e0d652d211
169 files changed
+12192
-9116
No files matched your search
@@ -22,6 +22,7 @@ import (
|
||||
"ezops/pkg/agentcli/llm"
|
||||
"ezops/pkg/agentcli/safety"
|
||||
"ezops/pkg/agentcli/tools"
|
||||
"ezops/pkg/aiopscontext"
|
||||
"ezops/pkg/remoteexec"
|
||||
)
|
||||
|
||||
@@ -54,11 +55,11 @@ type wsEvent struct {
|
||||
|
||||
// wsControl 前端→后端控制消息 // Logan.Li
|
||||
type wsControl struct {
|
||||
Type string `json:"type"` // start/continue/resume/confirm/deny/abort
|
||||
Query string `json:"query,omitempty"` // start/continue 时任务描述
|
||||
SessionID uint `json:"session_id,omitempty"` // resume 时恢复的历史会话 ID
|
||||
SafetyMode string `json:"safety_mode,omitempty"` // strict/balanced/permissive
|
||||
ConfirmID string `json:"confirm_id,omitempty"` // confirm/deny 时
|
||||
Type string `json:"type"` // start/continue/resume/confirm/deny/abort
|
||||
Query string `json:"query,omitempty"` // start/continue 时任务描述
|
||||
SessionID uint `json:"session_id,omitempty"` // resume 时恢复的历史会话 ID
|
||||
SafetyMode string `json:"safety_mode,omitempty"` // strict/balanced/permissive
|
||||
ConfirmID string `json:"confirm_id,omitempty"` // confirm/deny 时
|
||||
}
|
||||
|
||||
// writeWSCloseError 在 agentConn 建立前,直接通过 conn 推送一条 error 事件再关闭连接 // Logan.Li
|
||||
@@ -75,24 +76,24 @@ func writeWSCloseError(conn *websocket.Conn, msg string) {
|
||||
|
||||
// agentConn 单个 WS 连接的运行态 // Logan.Li
|
||||
type agentConn struct {
|
||||
conn *websocket.Conn
|
||||
db *gorm.DB
|
||||
userID uint
|
||||
mode string
|
||||
server model.Server
|
||||
sshCfg remoteexec.SSHConfig
|
||||
conn *websocket.Conn
|
||||
db *gorm.DB
|
||||
userID uint
|
||||
mode string
|
||||
server model.Server
|
||||
sshCfg remoteexec.SSHConfig
|
||||
kubernetesCtx *kubernetesAgentContext
|
||||
session *model.AgentCLISession
|
||||
taskCtx context.Context
|
||||
cancel context.CancelFunc
|
||||
totalTokens int
|
||||
tokenMu sync.Mutex
|
||||
writeMu sync.Mutex
|
||||
confirms map[string]chan bool
|
||||
cfMu sync.Mutex
|
||||
convHistory []llm.Message
|
||||
running bool
|
||||
runningMu sync.Mutex
|
||||
session *model.AgentCLISession
|
||||
taskCtx context.Context
|
||||
cancel context.CancelFunc
|
||||
totalTokens int
|
||||
tokenMu sync.Mutex
|
||||
writeMu sync.Mutex
|
||||
confirms map[string]chan bool
|
||||
cfMu sync.Mutex
|
||||
convHistory []llm.Message
|
||||
running bool
|
||||
runningMu sync.Mutex
|
||||
}
|
||||
|
||||
// Run WebSocket 入口 // Logan.Li
|
||||
@@ -362,6 +363,7 @@ func (ac *agentConn) runTask(sess *model.AgentCLISession, query, safetyMode stri
|
||||
SafetyMode: parseSafetyMode(safetyMode),
|
||||
MemoryDir: fmt.Sprintf("data/agentcli/user_%d", ac.userID),
|
||||
Core: coreCfg,
|
||||
ExtraTools: aiopscontext.NewAgentTools(aiopscontext.NewService(ac.db)),
|
||||
}
|
||||
if ac.mode == agentcli.RunModeKubernetes {
|
||||
if ac.kubernetesCtx == nil {
|
||||
@@ -604,4 +606,4 @@ func (ac *agentConn) persistMsg(sid uint, round int, typ, role, content, toolNam
|
||||
// 持久化失败不阻断主流程
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ezops/middleware"
|
||||
"ezops/model"
|
||||
"ezops/pkg/agentcli/llm"
|
||||
"ezops/pkg/aiopscontext"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AIOpsAlertAnalysisHandler 基于上下文包执行告警 RCA。 // Logan.Li
|
||||
type AIOpsAlertAnalysisHandler struct {
|
||||
db *gorm.DB
|
||||
context *aiopscontext.Service
|
||||
}
|
||||
|
||||
// NewAIOpsAlertAnalysisHandler 创建告警分析处理器。 // Logan.Li
|
||||
func NewAIOpsAlertAnalysisHandler(db *gorm.DB) *AIOpsAlertAnalysisHandler {
|
||||
return &AIOpsAlertAnalysisHandler{db: db, context: aiopscontext.NewService(db)}
|
||||
}
|
||||
|
||||
// AlertAnalysisRequest 告警分析请求,前端传告警 ID 和少量兜底字段。 // Logan.Li
|
||||
type AlertAnalysisRequest struct {
|
||||
AlertID uint `json:"alert_id"`
|
||||
Name string `json:"name"`
|
||||
Severity string `json:"severity"`
|
||||
Target string `json:"target"`
|
||||
Metric string `json:"metric"`
|
||||
CurrentValue string `json:"current_value"`
|
||||
Threshold string `json:"threshold"`
|
||||
Duration string `json:"duration"`
|
||||
Description string `json:"description"`
|
||||
TimeRange string `json:"time_range"`
|
||||
}
|
||||
|
||||
// AlertAnalysisResponse 告警 RCA 结构化响应。 // Logan.Li
|
||||
type AlertAnalysisResponse struct {
|
||||
TraceID string `json:"trace_id"`
|
||||
SpanID string `json:"span_id"`
|
||||
RootCause string `json:"root_cause"`
|
||||
Impact string `json:"impact"`
|
||||
Recommendations []string `json:"recommendations"`
|
||||
AutoFixScript string `json:"auto_fix_script,omitempty"`
|
||||
SimilarCases int `json:"similar_cases"`
|
||||
Confidence int `json:"confidence"`
|
||||
Evidence []aiopscontext.EvidenceItem `json:"evidence"`
|
||||
ContextPack *aiopscontext.ContextPack `json:"context_pack,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Source string `json:"source"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
}
|
||||
|
||||
// Analyze POST /api/v1/aiops/alert-analysis。 // Logan.Li
|
||||
func (h *AIOpsAlertAnalysisHandler) Analyze(c *gin.Context) {
|
||||
start := time.Now()
|
||||
traceID := uuid.NewString()
|
||||
spanID := uuid.NewString()
|
||||
userID := middleware.GetUserID(c)
|
||||
|
||||
var req AlertAnalysisRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
timeRange := req.TimeRange
|
||||
if strings.TrimSpace(timeRange) == "" {
|
||||
timeRange = "24h"
|
||||
}
|
||||
metrics := metricsForAlert(req.Metric)
|
||||
pack, err := h.context.BuildPack(c.Request.Context(), aiopscontext.ContextPackRequest{
|
||||
AlertID: req.AlertID, Target: req.Target, Metrics: metrics, TimeRange: timeRange, Limit: 12,
|
||||
})
|
||||
if err != nil {
|
||||
h.saveAnalysisTrace(c.Request.Context(), analysisTraceInput{
|
||||
TraceID: traceID, SpanID: spanID, UserID: userID, Feature: "alert_analysis",
|
||||
Target: req.Target, AlertID: req.AlertID, Status: "error", Source: "context-pack",
|
||||
Duration: time.Since(start).Milliseconds(), Error: err.Error(),
|
||||
Result: gin.H{"error": "构建上下文失败: " + err.Error()},
|
||||
})
|
||||
c.JSON(http.StatusOK, model.Response{Code: 500, Message: "构建上下文失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
fallback := fallbackAlertAnalysis(req, pack)
|
||||
attachTrace(fallback, traceID, spanID)
|
||||
var cfg model.LLMConfig
|
||||
if err := h.db.First(&cfg).Error; err != nil || !cfg.Enabled {
|
||||
h.saveAnalysisTrace(c.Request.Context(), analysisTraceInput{
|
||||
TraceID: traceID, SpanID: spanID, UserID: userID, Feature: "alert_analysis",
|
||||
Target: firstText(req.Target, pack.Scope), AlertID: req.AlertID, Provider: cfg.Provider,
|
||||
Model: fallback.Model, Source: fallback.Source, Status: "fallback", Pack: pack, Result: fallback,
|
||||
Duration: time.Since(start).Milliseconds(),
|
||||
})
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: fallback})
|
||||
return
|
||||
}
|
||||
|
||||
result, usage, err := h.callAlertRCA(c.Request.Context(), cfg, req, pack, fallback)
|
||||
if err != nil {
|
||||
fallback.Source = "rule-fallback: " + err.Error()
|
||||
attachTrace(fallback, traceID, spanID)
|
||||
h.saveAnalysisTrace(c.Request.Context(), analysisTraceInput{
|
||||
TraceID: traceID, SpanID: spanID, UserID: userID, Feature: "alert_analysis",
|
||||
Target: firstText(req.Target, pack.Scope), AlertID: req.AlertID, Provider: cfg.Provider,
|
||||
Model: cfg.Model, Source: "rule-fallback", Status: "fallback", Pack: pack, Result: fallback,
|
||||
Duration: time.Since(start).Milliseconds(), Error: err.Error(),
|
||||
})
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: fallback})
|
||||
return
|
||||
}
|
||||
attachTrace(result, traceID, spanID)
|
||||
h.saveAnalysisTrace(c.Request.Context(), analysisTraceInput{
|
||||
TraceID: traceID, SpanID: spanID, UserID: userID, Feature: "alert_analysis",
|
||||
Target: firstText(req.Target, pack.Scope), AlertID: req.AlertID, Provider: cfg.Provider,
|
||||
Model: result.Model, Source: result.Source, Status: "success", Pack: pack, Result: result,
|
||||
PromptTokens: usage.PromptTokens, CompletionTokens: usage.CompletionTokens, TotalTokens: usage.TotalTokens,
|
||||
Duration: time.Since(start).Milliseconds(),
|
||||
})
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: result})
|
||||
}
|
||||
|
||||
func (h *AIOpsAlertAnalysisHandler) callAlertRCA(ctx context.Context, cfg model.LLMConfig, req AlertAnalysisRequest, pack *aiopscontext.ContextPack, fallback *AlertAnalysisResponse) (*AlertAnalysisResponse, llm.Usage, error) {
|
||||
packJSON, _ := json.MarshalIndent(pack, "", " ")
|
||||
prompt := fmt.Sprintf(`你是 AIOps 平台的企业级告警 RCA 分析器。请只基于【告警基本信息】和【AIOps Context Pack】做判断,不要编造不存在的数据。
|
||||
|
||||
【告警基本信息】
|
||||
- ID: %d
|
||||
- 名称: %s
|
||||
- 级别: %s
|
||||
- 目标: %s
|
||||
- 指标: %s
|
||||
- 当前值: %s
|
||||
- 阈值: %s
|
||||
- 持续: %s
|
||||
- 描述: %s
|
||||
|
||||
【AIOps Context Pack】
|
||||
%s
|
||||
|
||||
请返回严格 JSON,不要 markdown,不要代码块,字段如下:
|
||||
{
|
||||
"root_cause": "根因分析,2-4句,必须引用证据",
|
||||
"impact": "影响范围,1-2句",
|
||||
"recommendations": ["建议1", "建议2", "建议3"],
|
||||
"auto_fix_script": "仅限只读诊断脚本;没有则为空字符串",
|
||||
"similar_cases": 0,
|
||||
"confidence": 0-100
|
||||
}`, req.AlertID, req.Name, req.Severity, req.Target, req.Metric, req.CurrentValue, req.Threshold, req.Duration, req.Description, string(packJSON))
|
||||
|
||||
resp, err := buildLLMClientFromConfig(cfg).Complete(ctx, &llm.CompletionRequest{
|
||||
Messages: []llm.Message{
|
||||
{Role: "system", Content: "你是 AIOps 平台的告警根因分析专家,输出必须是可解析 JSON。"},
|
||||
{Role: "user", Content: prompt},
|
||||
},
|
||||
Temperature: 0.2,
|
||||
MaxTokens: cfg.MaxTokens,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, llm.Usage{}, err
|
||||
}
|
||||
parsed := *fallback
|
||||
if err := parseAlertAnalysisJSON(resp.Message.Content, &parsed); err != nil {
|
||||
parsed.RootCause = strings.TrimSpace(resp.Message.Content)
|
||||
}
|
||||
parsed.ContextPack = pack
|
||||
parsed.Evidence = pack.Evidence
|
||||
parsed.Model = cfg.Model
|
||||
parsed.Source = "llm"
|
||||
parsed.GeneratedAt = time.Now()
|
||||
return &parsed, resp.Usage, nil
|
||||
}
|
||||
|
||||
// ListAnalysisTraces 查询 AI 分析审计记录,普通用户只看自己的记录。 // Logan.Li
|
||||
func (h *AIOpsAlertAnalysisHandler) ListAnalysisTraces(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if pageSize <= 0 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
q := h.db.Model(&model.AIAnalysisTrace{})
|
||||
if !middleware.GetIsAdmin(c) {
|
||||
q = q.Where("user_id = ?", middleware.GetUserID(c))
|
||||
}
|
||||
if feature := strings.TrimSpace(c.Query("feature")); feature != "" {
|
||||
q = q.Where("feature = ?", feature)
|
||||
}
|
||||
if target := strings.TrimSpace(c.Query("target")); target != "" {
|
||||
q = q.Where("target LIKE ?", "%"+target+"%")
|
||||
}
|
||||
if status := strings.TrimSpace(c.Query("status")); status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
var rows []model.AIAnalysisTrace
|
||||
if err := q.Select("id, trace_id, span_id, parent_span_id, user_id, feature, target, alert_id, provider, model, source, status, evidence_count, prompt_tokens, completion_tokens, total_tokens, duration, error, created_at, updated_at").
|
||||
Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&rows).Error; err != nil {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 500, Message: "查询失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"list": rows, "total": total}})
|
||||
}
|
||||
|
||||
// GetAnalysisTrace 按 trace_id 回放某次 AI 分析看到的上下文包和输出结论。 // Logan.Li
|
||||
func (h *AIOpsAlertAnalysisHandler) GetAnalysisTrace(c *gin.Context) {
|
||||
traceID := strings.TrimSpace(c.Param("trace_id"))
|
||||
q := h.db.Where("trace_id = ?", traceID)
|
||||
if !middleware.GetIsAdmin(c) {
|
||||
q = q.Where("user_id = ?", middleware.GetUserID(c))
|
||||
}
|
||||
var row model.AIAnalysisTrace
|
||||
if err := q.Order("id ASC").First(&row).Error; err != nil {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 404, Message: "分析记录不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{
|
||||
"trace": row,
|
||||
"context_pack": decodeJSONField(row.ContextPack),
|
||||
"result": decodeJSONField(row.Result),
|
||||
}})
|
||||
}
|
||||
|
||||
func parseAlertAnalysisJSON(raw string, out *AlertAnalysisResponse) error {
|
||||
clean := strings.TrimSpace(raw)
|
||||
clean = strings.TrimPrefix(clean, "```json")
|
||||
clean = strings.TrimPrefix(clean, "```")
|
||||
clean = strings.TrimSuffix(clean, "```")
|
||||
clean = strings.TrimSpace(clean)
|
||||
var tmp struct {
|
||||
RootCause string `json:"root_cause"`
|
||||
Impact string `json:"impact"`
|
||||
Recommendations []string `json:"recommendations"`
|
||||
AutoFixScript string `json:"auto_fix_script"`
|
||||
SimilarCases int `json:"similar_cases"`
|
||||
Confidence int `json:"confidence"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(clean), &tmp); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(tmp.RootCause) != "" {
|
||||
out.RootCause = tmp.RootCause
|
||||
}
|
||||
if strings.TrimSpace(tmp.Impact) != "" {
|
||||
out.Impact = tmp.Impact
|
||||
}
|
||||
if len(tmp.Recommendations) > 0 {
|
||||
out.Recommendations = tmp.Recommendations
|
||||
}
|
||||
out.AutoFixScript = tmp.AutoFixScript
|
||||
out.SimilarCases = tmp.SimilarCases
|
||||
if tmp.Confidence > 0 {
|
||||
if tmp.Confidence > 100 {
|
||||
tmp.Confidence = 100
|
||||
}
|
||||
out.Confidence = tmp.Confidence
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fallbackAlertAnalysis(req AlertAnalysisRequest, pack *aiopscontext.ContextPack) *AlertAnalysisResponse {
|
||||
evidenceText := "暂无足够平台证据"
|
||||
if len(pack.Evidence) > 0 {
|
||||
evidenceText = pack.Evidence[0].Summary
|
||||
}
|
||||
target := firstText(req.Target, pack.Scope, "未知目标")
|
||||
metric := firstText(req.Metric, "unknown")
|
||||
recs := []string{
|
||||
fmt.Sprintf("先查看 %s 的近期指标趋势和告警时间线,确认是否持续异常", target),
|
||||
"结合变更、发布、定时任务和日志错误进一步缩小根因范围",
|
||||
"修复动作前先执行只读诊断,涉及重启、清理、扩容等动作需走确认或审批",
|
||||
}
|
||||
return &AlertAnalysisResponse{
|
||||
RootCause: fmt.Sprintf("告警 %s 在目标 %s 上触发,指标 %s 当前值 %s,阈值 %s。基于当前上下文,最直接证据是:%s。",
|
||||
firstText(req.Name, "未命名告警"), target, metric, req.CurrentValue, req.Threshold, evidenceText),
|
||||
Impact: fmt.Sprintf("该告警级别为 %s,可能影响目标 %s 的稳定性,需要结合业务等级判断优先级。", firstText(req.Severity, "unknown"), target),
|
||||
Recommendations: recs,
|
||||
SimilarCases: 0,
|
||||
Confidence: fallbackConfidence(pack),
|
||||
Evidence: pack.Evidence,
|
||||
ContextPack: pack,
|
||||
Model: "rule-fallback",
|
||||
Source: "rule-fallback",
|
||||
GeneratedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func firstText(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func metricsForAlert(metric string) []string {
|
||||
m := strings.ToLower(metric)
|
||||
switch {
|
||||
case strings.Contains(m, "mem"):
|
||||
return []string{"mem_usage", "load1"}
|
||||
case strings.Contains(m, "load"):
|
||||
return []string{"load1", "load5", "cpu_usage"}
|
||||
case strings.Contains(m, "io"):
|
||||
return []string{"cpu_iowait", "load1"}
|
||||
default:
|
||||
return []string{"cpu_usage", "mem_usage", "load1"}
|
||||
}
|
||||
}
|
||||
|
||||
func fallbackConfidence(pack *aiopscontext.ContextPack) int {
|
||||
score := 45
|
||||
if len(pack.Alerts) > 0 {
|
||||
score += 10
|
||||
}
|
||||
if len(pack.Metrics) > 0 {
|
||||
score += 15
|
||||
}
|
||||
if len(pack.Assets) > 0 {
|
||||
score += 5
|
||||
}
|
||||
if score > 80 {
|
||||
score = 80
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
type analysisTraceInput struct {
|
||||
TraceID string
|
||||
SpanID string
|
||||
ParentSpanID string
|
||||
UserID uint
|
||||
Feature string
|
||||
Target string
|
||||
AlertID uint
|
||||
Provider string
|
||||
Model string
|
||||
Source string
|
||||
Status string
|
||||
Pack *aiopscontext.ContextPack
|
||||
Result interface{}
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
TotalTokens int
|
||||
Duration int64
|
||||
Error string
|
||||
}
|
||||
|
||||
func attachTrace(resp *AlertAnalysisResponse, traceID, spanID string) {
|
||||
if resp == nil {
|
||||
return
|
||||
}
|
||||
resp.TraceID = traceID
|
||||
resp.SpanID = spanID
|
||||
}
|
||||
|
||||
func (h *AIOpsAlertAnalysisHandler) saveAnalysisTrace(ctx context.Context, in analysisTraceInput) {
|
||||
packJSON := marshalTraceJSON(in.Pack)
|
||||
resultJSON := marshalTraceJSON(traceResultForStorage(in.Result))
|
||||
evidenceCount := 0
|
||||
if in.Pack != nil {
|
||||
evidenceCount = len(in.Pack.Evidence)
|
||||
}
|
||||
row := model.AIAnalysisTrace{
|
||||
TraceID: in.TraceID,
|
||||
SpanID: in.SpanID,
|
||||
ParentSpanID: in.ParentSpanID,
|
||||
UserID: in.UserID,
|
||||
Feature: firstText(in.Feature, "unknown"),
|
||||
Target: in.Target,
|
||||
AlertID: in.AlertID,
|
||||
Provider: in.Provider,
|
||||
Model: in.Model,
|
||||
Source: trimTraceText(in.Source, 64),
|
||||
Status: firstText(in.Status, "unknown"),
|
||||
ContextPack: packJSON,
|
||||
Result: resultJSON,
|
||||
EvidenceCount: evidenceCount,
|
||||
PromptTokens: in.PromptTokens,
|
||||
CompletionTokens: in.CompletionTokens,
|
||||
TotalTokens: in.TotalTokens,
|
||||
Duration: in.Duration,
|
||||
Error: in.Error,
|
||||
}
|
||||
_ = h.db.WithContext(ctx).Create(&row).Error
|
||||
}
|
||||
|
||||
func marshalTraceJSON(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func traceResultForStorage(v interface{}) interface{} {
|
||||
switch r := v.(type) {
|
||||
case *AlertAnalysisResponse:
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *r
|
||||
cp.ContextPack = nil
|
||||
return cp
|
||||
case AlertAnalysisResponse:
|
||||
r.ContextPack = nil
|
||||
return r
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func decodeJSONField(raw string) interface{} {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
var out interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||
return raw
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func trimTraceText(s string, max int) string {
|
||||
if max <= 0 {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
if len(runes) <= max {
|
||||
return s
|
||||
}
|
||||
return string(runes[:max])
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"ezops/model"
|
||||
"ezops/pkg/aiopscontext"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AIOpsContextHandler 提供 AI 上下文包接口,供智能助手、告警分析、预测分析和后续 MCP 复用。 // Logan.Li
|
||||
type AIOpsContextHandler struct {
|
||||
service *aiopscontext.Service
|
||||
}
|
||||
|
||||
// NewAIOpsContextHandler 创建 AI 上下文处理器。 // Logan.Li
|
||||
func NewAIOpsContextHandler(db *gorm.DB) *AIOpsContextHandler {
|
||||
return &AIOpsContextHandler{service: aiopscontext.NewService(db)}
|
||||
}
|
||||
|
||||
// Summary GET /api/v1/aiops/context/summary // Logan.Li
|
||||
func (h *AIOpsContextHandler) Summary(c *gin.Context) {
|
||||
data, err := h.service.Summary(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 500, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: data})
|
||||
}
|
||||
|
||||
// BuildPack POST /api/v1/aiops/context/pack // Logan.Li
|
||||
func (h *AIOpsContextHandler) BuildPack(c *gin.Context) {
|
||||
var req aiopscontext.ContextPackRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
data, err := h.service.BuildPack(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 500, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: data})
|
||||
}
|
||||
@@ -30,10 +30,35 @@ func (h *AuditSettingsHandler) getOrCreate() (model.AuditSettings, error) {
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return s, err
|
||||
}
|
||||
s = model.AuditSettings{RetentionDays: 180, RecordAll: true}
|
||||
s = model.AuditSettings{
|
||||
RetentionDays: 180,
|
||||
RecordAll: true,
|
||||
SessionWatermarkEnabled: true,
|
||||
SessionRecordEnabled: true,
|
||||
ClipboardEnabled: true,
|
||||
FileUploadEnabled: true,
|
||||
FileDownloadEnabled: true,
|
||||
}
|
||||
return s, h.db.Create(&s).Error
|
||||
}
|
||||
|
||||
func auditSettingsPayload(s model.AuditSettings) gin.H {
|
||||
return gin.H{
|
||||
"retention_days": s.RetentionDays,
|
||||
"record_all": s.RecordAll,
|
||||
"session_watermark_enabled": s.SessionWatermarkEnabled,
|
||||
"session_record_enabled": s.SessionRecordEnabled,
|
||||
"clipboard_enabled": s.ClipboardEnabled,
|
||||
"file_upload_enabled": s.FileUploadEnabled,
|
||||
"file_download_enabled": s.FileDownloadEnabled,
|
||||
"command_policy_enabled": s.CommandPolicyEnabled,
|
||||
"approval_required": s.ApprovalRequired,
|
||||
"last_cleanup_at": s.LastCleanupAt,
|
||||
"last_cleanup_summary": s.LastCleanupSummary,
|
||||
"allowed_retention_days": model.AllowedAuditRetentionDays,
|
||||
}
|
||||
}
|
||||
|
||||
// GetAuditSettings 获取审计策略 // Logan.Li
|
||||
// GET /api/v1/admin/audit-settings
|
||||
func (h *AuditSettingsHandler) GetAuditSettings(c *gin.Context) {
|
||||
@@ -45,13 +70,7 @@ func (h *AuditSettingsHandler) GetAuditSettings(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
Code: 200,
|
||||
Message: "success",
|
||||
Data: gin.H{
|
||||
"retention_days": s.RetentionDays,
|
||||
"record_all": s.RecordAll,
|
||||
"last_cleanup_at": s.LastCleanupAt,
|
||||
"last_cleanup_summary": s.LastCleanupSummary,
|
||||
"allowed_retention_days": model.AllowedAuditRetentionDays,
|
||||
},
|
||||
Data: auditSettingsPayload(s),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -59,8 +78,15 @@ func (h *AuditSettingsHandler) GetAuditSettings(c *gin.Context) {
|
||||
// PUT /api/v1/admin/audit-settings
|
||||
func (h *AuditSettingsHandler) ApplyAuditSettings(c *gin.Context) {
|
||||
var req struct {
|
||||
RetentionDays int `json:"retention_days" binding:"required"`
|
||||
RecordAll bool `json:"record_all"`
|
||||
RetentionDays int `json:"retention_days" binding:"required"`
|
||||
RecordAll bool `json:"record_all"`
|
||||
SessionWatermarkEnabled bool `json:"session_watermark_enabled"`
|
||||
SessionRecordEnabled bool `json:"session_record_enabled"`
|
||||
ClipboardEnabled bool `json:"clipboard_enabled"`
|
||||
FileUploadEnabled bool `json:"file_upload_enabled"`
|
||||
FileDownloadEnabled bool `json:"file_download_enabled"`
|
||||
CommandPolicyEnabled bool `json:"command_policy_enabled"`
|
||||
ApprovalRequired bool `json:"approval_required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误"})
|
||||
@@ -82,6 +108,13 @@ func (h *AuditSettingsHandler) ApplyAuditSettings(c *gin.Context) {
|
||||
|
||||
s.RetentionDays = req.RetentionDays
|
||||
s.RecordAll = req.RecordAll
|
||||
s.SessionWatermarkEnabled = req.SessionWatermarkEnabled
|
||||
s.SessionRecordEnabled = req.SessionRecordEnabled
|
||||
s.ClipboardEnabled = req.ClipboardEnabled
|
||||
s.FileUploadEnabled = req.FileUploadEnabled
|
||||
s.FileDownloadEnabled = req.FileDownloadEnabled
|
||||
s.CommandPolicyEnabled = req.CommandPolicyEnabled
|
||||
s.ApprovalRequired = req.ApprovalRequired
|
||||
|
||||
cleanResult, cleanErr := auditcleaner.Run(h.db, req.RetentionDays)
|
||||
now := time.Now()
|
||||
@@ -111,11 +144,18 @@ func (h *AuditSettingsHandler) ApplyAuditSettings(c *gin.Context) {
|
||||
Code: 200,
|
||||
Message: msg,
|
||||
Data: gin.H{
|
||||
"retention_days": s.RetentionDays,
|
||||
"record_all": s.RecordAll,
|
||||
"last_cleanup_at": s.LastCleanupAt,
|
||||
"last_cleanup_summary": s.LastCleanupSummary,
|
||||
"cleanup": cleanResult,
|
||||
"retention_days": s.RetentionDays,
|
||||
"record_all": s.RecordAll,
|
||||
"session_watermark_enabled": s.SessionWatermarkEnabled,
|
||||
"session_record_enabled": s.SessionRecordEnabled,
|
||||
"clipboard_enabled": s.ClipboardEnabled,
|
||||
"file_upload_enabled": s.FileUploadEnabled,
|
||||
"file_download_enabled": s.FileDownloadEnabled,
|
||||
"command_policy_enabled": s.CommandPolicyEnabled,
|
||||
"approval_required": s.ApprovalRequired,
|
||||
"last_cleanup_at": s.LastCleanupAt,
|
||||
"last_cleanup_summary": s.LastCleanupSummary,
|
||||
"cleanup": cleanResult,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -291,7 +291,7 @@ func (h *AutomationHandler) ToggleCronJob(c *gin.Context) {
|
||||
// @Router /cron-jobs/{id}/execute [post]
|
||||
func (h *AutomationHandler) ExecuteCronJob(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
execution, err := RunCronJobByID(h.db, uint(id))
|
||||
execution, err := QueueCronJobByID(h.db, uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, model.Response{
|
||||
Code: 404,
|
||||
@@ -299,13 +299,9 @@ func (h *AutomationHandler) ExecuteCronJob(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
msg := "执行成功"
|
||||
if execution.Status == "failed" {
|
||||
msg = "执行失败"
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
Code: 200,
|
||||
Message: msg,
|
||||
Message: "已提交后台执行",
|
||||
Data: execution,
|
||||
})
|
||||
}
|
||||
@@ -299,45 +299,41 @@ func (h *AutomationHandler) ExecuteScript(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
go h.finishScriptExecution(script, execution.ID)
|
||||
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
Code: 200,
|
||||
Message: "已提交后台执行",
|
||||
Data: execution,
|
||||
})
|
||||
}
|
||||
|
||||
// finishScriptExecution 在后台完成脚本执行,HTTP 请求只负责提交任务 // Logan.Li
|
||||
func (h *AutomationHandler) finishScriptExecution(script model.Script, executionID uint) {
|
||||
rt := scriptrunner.DetectRuntime()
|
||||
if ok, reason := scriptrunner.CanRun(rt, script.Type); !ok {
|
||||
c.JSON(http.StatusBadRequest, model.Response{
|
||||
Code: 400,
|
||||
Message: reason,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 在 AIOps 本机执行(embed 单二进制 / Docker 容器内)
|
||||
output, exitCode, duration := runLocal(script.Type, script.Content, script.Timeout)
|
||||
finishedAt := time.Now()
|
||||
status := "success"
|
||||
if exitCode != 0 {
|
||||
output := ""
|
||||
exitCode := 0
|
||||
duration := 0
|
||||
if ok, reason := scriptrunner.CanRun(rt, script.Type); !ok {
|
||||
status = "failed"
|
||||
exitCode = -1
|
||||
output = reason + "\n"
|
||||
} else {
|
||||
output, exitCode, duration = runLocal(script.Type, script.Content, script.Timeout)
|
||||
if exitCode != 0 {
|
||||
status = "failed"
|
||||
}
|
||||
}
|
||||
|
||||
h.db.Model(&execution).Updates(map[string]interface{}{
|
||||
finishedAt := time.Now()
|
||||
h.db.Model(&model.TaskExecution{}).Where("id = ?", executionID).Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
"output": output,
|
||||
"duration": duration,
|
||||
"finished_at": finishedAt,
|
||||
})
|
||||
h.db.Model(&script).UpdateColumn("run_count", script.RunCount+1)
|
||||
|
||||
execution.Output = output
|
||||
execution.Status = status
|
||||
execution.Duration = duration
|
||||
execution.FinishedAt = &finishedAt
|
||||
|
||||
msg := "执行成功"
|
||||
if status == "failed" {
|
||||
msg = "执行失败"
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
Code: 200,
|
||||
Message: msg,
|
||||
Data: execution,
|
||||
})
|
||||
h.db.Model(&model.Script{}).Where("id = ?", script.ID).UpdateColumn("run_count", script.RunCount+1)
|
||||
_ = exitCode
|
||||
}
|
||||
|
||||
// ListExecutions 获取任务执行记录列表
|
||||
|
||||
@@ -21,7 +21,49 @@ func RunCronJobByID(db *gorm.DB, id uint) (*model.TaskExecution, error) {
|
||||
return runCronJob(db, job)
|
||||
}
|
||||
|
||||
// QueueCronJobByID 提交一次手动执行,立即返回执行记录,后台继续执行 // Logan.Li
|
||||
func QueueCronJobByID(db *gorm.DB, id uint) (*model.TaskExecution, error) {
|
||||
var job model.CronJob
|
||||
if err := db.First(&job, id).Error; err != nil {
|
||||
return nil, fmt.Errorf("定时任务不存在")
|
||||
}
|
||||
execution, startedAt, err := createCronExecution(db, job)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
go finishCronExecution(db, job, execution.ID, startedAt)
|
||||
return execution, nil
|
||||
}
|
||||
|
||||
func runCronJob(db *gorm.DB, job model.CronJob) (*model.TaskExecution, error) {
|
||||
execution, startedAt, err := createCronExecution(db, job)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
finishCronExecution(db, job, execution.ID, startedAt)
|
||||
_ = db.First(execution, execution.ID).Error
|
||||
return execution, nil
|
||||
}
|
||||
|
||||
func createCronExecution(db *gorm.DB, job model.CronJob) (*model.TaskExecution, time.Time, error) {
|
||||
startedAt := time.Now()
|
||||
execution := &model.TaskExecution{
|
||||
TaskType: "cron",
|
||||
TaskID: job.ID,
|
||||
TaskName: job.Name,
|
||||
Status: "running",
|
||||
TargetType: job.TargetType,
|
||||
Targets: job.Targets,
|
||||
StartedAt: &startedAt,
|
||||
}
|
||||
if err := db.Create(execution).Error; err != nil {
|
||||
return nil, startedAt, fmt.Errorf("创建执行记录失败")
|
||||
}
|
||||
return execution, startedAt, nil
|
||||
}
|
||||
|
||||
// finishCronExecution 执行定时任务主体并写回执行结果 // Logan.Li
|
||||
func finishCronExecution(db *gorm.DB, job model.CronJob, executionID uint, startedAt time.Time) {
|
||||
content := job.Content
|
||||
scriptType := job.Type
|
||||
if scriptType == "command" {
|
||||
@@ -36,20 +78,6 @@ func runCronJob(db *gorm.DB, job model.CronJob) (*model.TaskExecution, error) {
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
execution := model.TaskExecution{
|
||||
TaskType: "cron",
|
||||
TaskID: job.ID,
|
||||
TaskName: job.Name,
|
||||
Status: "running",
|
||||
TargetType: job.TargetType,
|
||||
Targets: job.Targets,
|
||||
StartedAt: &now,
|
||||
}
|
||||
if err := db.Create(&execution).Error; err != nil {
|
||||
return nil, fmt.Errorf("创建执行记录失败")
|
||||
}
|
||||
|
||||
var output string
|
||||
var exitCode int
|
||||
var duration int
|
||||
@@ -57,7 +85,7 @@ func runCronJob(db *gorm.DB, job model.CronJob) (*model.TaskExecution, error) {
|
||||
|
||||
if job.TargetType != "" && job.TargetType != "local" && job.Targets != "" {
|
||||
remoteResults, remoteErr := ExecuteCronOnTargets(db, job, scriptType, content, job.Timeout)
|
||||
duration = int(time.Since(now).Milliseconds())
|
||||
duration = int(time.Since(startedAt).Milliseconds())
|
||||
if remoteErr != nil {
|
||||
output = fmt.Sprintf("远程执行调度失败: %v", remoteErr)
|
||||
exitCode = -1
|
||||
@@ -77,7 +105,7 @@ func runCronJob(db *gorm.DB, job model.CronJob) (*model.TaskExecution, error) {
|
||||
if ok, reason := scriptrunner.CanRun(rt, scriptType); !ok {
|
||||
output = reason + "\n"
|
||||
exitCode = -1
|
||||
duration = int(time.Since(now).Milliseconds())
|
||||
duration = int(time.Since(startedAt).Milliseconds())
|
||||
status = "failed"
|
||||
} else {
|
||||
output, exitCode, duration = runLocal(scriptType, content, job.Timeout)
|
||||
@@ -88,7 +116,7 @@ func runCronJob(db *gorm.DB, job model.CronJob) (*model.TaskExecution, error) {
|
||||
}
|
||||
|
||||
finishedAt := time.Now()
|
||||
db.Model(&execution).Updates(map[string]interface{}{
|
||||
db.Model(&model.TaskExecution{}).Where("id = ?", executionID).Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
"output": output,
|
||||
"duration": duration,
|
||||
@@ -96,7 +124,7 @@ func runCronJob(db *gorm.DB, job model.CronJob) (*model.TaskExecution, error) {
|
||||
})
|
||||
|
||||
statsUpdates := map[string]interface{}{
|
||||
"last_run_at": now,
|
||||
"last_run_at": startedAt,
|
||||
"last_status": status,
|
||||
"run_count": job.RunCount + 1,
|
||||
}
|
||||
@@ -106,11 +134,5 @@ func runCronJob(db *gorm.DB, job model.CronJob) (*model.TaskExecution, error) {
|
||||
statsUpdates["fail_count"] = job.FailCount + 1
|
||||
}
|
||||
db.Model(&job).Updates(statsUpdates)
|
||||
|
||||
execution.Output = output
|
||||
execution.Status = status
|
||||
execution.Duration = duration
|
||||
execution.FinishedAt = &finishedAt
|
||||
_ = exitCode
|
||||
return &execution, nil
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// K8s 集群列表统计增强 — 实时拉取资源数量,修复 Ingress 等与详情页不一致 // Logan.Li
|
||||
// K8s 集群列表统计增强 — 列表页只读本地快照,避免不可达集群阻塞全站 // Logan.Li
|
||||
package controller
|
||||
|
||||
import (
|
||||
@@ -7,28 +7,12 @@ import (
|
||||
"ezops/model"
|
||||
)
|
||||
|
||||
// enrichClustersForList 列表返回前刷新资源计数与节点均值使用率 // Logan.Li
|
||||
// enrichClustersForList 列表返回前补齐本地资源计数与节点均值使用率 // Logan.Li
|
||||
func (h *KubernetesHandler) enrichClustersForList(clusters []model.KubernetesCluster) {
|
||||
for i := range clusters {
|
||||
c := &clusters[i]
|
||||
h.fillClusterCountsFromDB(c)
|
||||
h.refreshClusterCountsLive(c)
|
||||
h.fillClusterUsageFromNodes(c)
|
||||
// 异步落库,避免列表阻塞过久
|
||||
snap := *c
|
||||
go func(cluster model.KubernetesCluster) {
|
||||
if err := h.db.Model(&model.KubernetesCluster{}).Where("id = ?", cluster.ID).Updates(map[string]interface{}{
|
||||
"node_count": cluster.NodeCount,
|
||||
"pod_count": cluster.PodCount,
|
||||
"service_count": cluster.ServiceCount,
|
||||
"deployment_count": cluster.DeploymentCount,
|
||||
"ingress_count": cluster.IngressCount,
|
||||
"cpu_usage": cluster.CPUUsage,
|
||||
"memory_usage": cluster.MemoryUsage,
|
||||
}).Error; err != nil {
|
||||
log.Printf("[K8s] enrich cluster %d stats persist error: %v", cluster.ID, err)
|
||||
}
|
||||
}(snap)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,4 +90,4 @@ func (h *KubernetesHandler) updateClusterUsageAfterSync(clusterID uint) {
|
||||
"memory_usage": cluster.MemoryUsage,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
+199
-11
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -37,8 +38,52 @@ type RDPHandler struct {
|
||||
}
|
||||
|
||||
const rdpAuditMaxText = 500
|
||||
const rdpMaxWidth = 1024
|
||||
const rdpMaxHeight = 800
|
||||
|
||||
type sessionSecurityPolicy struct {
|
||||
ClipboardEnabled bool
|
||||
FileUploadEnabled bool
|
||||
FileDownloadEnabled bool
|
||||
SessionRecordEnabled bool
|
||||
}
|
||||
|
||||
func loadSessionSecurityPolicy(db *gorm.DB) sessionSecurityPolicy {
|
||||
policy := sessionSecurityPolicy{
|
||||
ClipboardEnabled: true,
|
||||
FileUploadEnabled: true,
|
||||
FileDownloadEnabled: true,
|
||||
SessionRecordEnabled: true,
|
||||
}
|
||||
var settings model.AuditSettings
|
||||
if err := db.First(&settings).Error; err != nil {
|
||||
return policy
|
||||
}
|
||||
policy.ClipboardEnabled = settings.ClipboardEnabled
|
||||
policy.FileUploadEnabled = settings.FileUploadEnabled
|
||||
policy.FileDownloadEnabled = settings.FileDownloadEnabled
|
||||
policy.SessionRecordEnabled = settings.SessionRecordEnabled
|
||||
return policy
|
||||
}
|
||||
|
||||
func rdpEventBlockedByPolicy(event rdpengine.ClientEvent, policy sessionSecurityPolicy) (bool, string) {
|
||||
switch event.Type {
|
||||
case "clipboard", "remote_copy":
|
||||
if !policy.ClipboardEnabled {
|
||||
return true, "会话策略已禁用剪贴板"
|
||||
}
|
||||
case "file_upload", "rdp_file_upload":
|
||||
if !policy.FileUploadEnabled {
|
||||
return true, "会话策略已禁用文件上传"
|
||||
}
|
||||
case "file_download":
|
||||
if !policy.FileDownloadEnabled {
|
||||
return true, "会话策略已禁用文件下载"
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
const rdpMaxWidth = 1920
|
||||
const rdpMaxHeight = 1200
|
||||
|
||||
func NewRDPHandler(db *gorm.DB, masterKey string, wsMgr *wshub.WSManager) *RDPHandler {
|
||||
return &RDPHandler{
|
||||
@@ -138,6 +183,7 @@ func (h *RDPHandler) Serve(c *gin.Context) {
|
||||
|
||||
userID := middleware.GetUserID(c)
|
||||
username := middleware.GetUsername(c)
|
||||
policy := loadSessionSecurityPolicy(h.db)
|
||||
conn, info, err := h.wsMgr.Upgrade(c, "rdp", userID, username)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -183,7 +229,7 @@ func (h *RDPHandler) Serve(c *gin.Context) {
|
||||
}()
|
||||
|
||||
events := make(chan rdpengine.ClientEvent, 128)
|
||||
sink := &wsFrameSink{conn: conn, mgr: h.wsMgr, recorder: rdpengine.NewMemoryRecorder(500), live: liveHub, sessionID: session.ID}
|
||||
sink := &wsFrameSink{conn: conn, mgr: h.wsMgr, recorder: rdpengine.NewMemoryRecorder(500), live: liveHub, sessionID: session.ID, recordW: width, recordH: height}
|
||||
_ = sink.SendFrame(rdpengine.ServerFrame{Type: "session", Message: strconv.Itoa(int(session.ID)), Width: width, Height: height})
|
||||
engineDone := make(chan error, 1)
|
||||
go func() {
|
||||
@@ -206,7 +252,10 @@ func (h *RDPHandler) Serve(c *gin.Context) {
|
||||
cancel()
|
||||
end := time.Now()
|
||||
duration := int(end.Sub(session.StartTime).Seconds())
|
||||
recordData := sink.recorder.JSON()
|
||||
recordData := ""
|
||||
if policy.SessionRecordEnabled {
|
||||
recordData = sink.recorder.JSON()
|
||||
}
|
||||
updates := map[string]interface{}{
|
||||
"status": status,
|
||||
"duration": duration,
|
||||
@@ -224,6 +273,7 @@ func (h *RDPHandler) Serve(c *gin.Context) {
|
||||
defer audit.Flush()
|
||||
|
||||
readDone := make(chan struct{})
|
||||
modifiers := &rdpModifierState{}
|
||||
go func() {
|
||||
defer close(readDone)
|
||||
for {
|
||||
@@ -231,6 +281,11 @@ func (h *RDPHandler) Serve(c *gin.Context) {
|
||||
if err := conn.ReadJSON(&event); err != nil {
|
||||
return
|
||||
}
|
||||
if blocked, message := rdpEventBlockedByPolicy(event, policy); blocked {
|
||||
_ = sink.SendFrame(rdpengine.ServerFrame{Type: "status", Message: message})
|
||||
continue
|
||||
}
|
||||
modifiers.Apply(&event)
|
||||
audit.Record(event)
|
||||
enqueueRDPEvent(events, event)
|
||||
}
|
||||
@@ -286,6 +341,38 @@ func enqueueRDPEvent(events chan rdpengine.ClientEvent, event rdpengine.ClientEv
|
||||
}
|
||||
}
|
||||
|
||||
type rdpModifierState struct {
|
||||
ctrl bool
|
||||
alt bool
|
||||
shift bool
|
||||
meta bool
|
||||
}
|
||||
|
||||
// Apply 在服务端补齐修饰键状态,兼容旧前端未传 ctrlKey/altKey 的 RDP 按键事件。 // Logan.Li
|
||||
func (s *rdpModifierState) Apply(event *rdpengine.ClientEvent) {
|
||||
if event.Type != "key" {
|
||||
return
|
||||
}
|
||||
event.Ctrl = event.Ctrl || s.ctrl
|
||||
event.Alt = event.Alt || s.alt
|
||||
event.Shift = event.Shift || s.shift
|
||||
event.Meta = event.Meta || s.meta
|
||||
if event.Meta {
|
||||
event.Ctrl = true
|
||||
event.Meta = false
|
||||
}
|
||||
switch event.Code {
|
||||
case "ControlLeft", "ControlRight":
|
||||
s.ctrl = event.Down
|
||||
case "AltLeft", "AltRight":
|
||||
s.alt = event.Down
|
||||
case "ShiftLeft", "ShiftRight":
|
||||
s.shift = event.Down
|
||||
case "MetaLeft", "MetaRight":
|
||||
s.meta = event.Down
|
||||
}
|
||||
}
|
||||
|
||||
type rdpOperationRecorder struct {
|
||||
db *gorm.DB
|
||||
session model.RDPSession
|
||||
@@ -312,9 +399,15 @@ func (r *rdpOperationRecorder) Record(event rdpengine.ClientEvent) {
|
||||
case "clipboard":
|
||||
r.Flush()
|
||||
r.create("[RDP粘贴] " + truncateAuditText(event.Text, rdpAuditMaxText))
|
||||
case "remote_copy":
|
||||
r.Flush()
|
||||
r.create("[RDP复制] Ctrl+C")
|
||||
case "file_upload":
|
||||
r.Flush()
|
||||
r.create("[RDP上传] " + truncateAuditText(event.Text, rdpAuditMaxText))
|
||||
case "rdp_file_upload":
|
||||
r.Flush()
|
||||
r.create("[RDP上传到虚拟盘] " + truncateAuditText(event.Name, rdpAuditMaxText))
|
||||
case "file_download":
|
||||
r.Flush()
|
||||
r.create("[RDP下载] " + truncateAuditText(event.Text, rdpAuditMaxText))
|
||||
@@ -328,6 +421,15 @@ func (r *rdpOperationRecorder) recordKey(event rdpengine.ClientEvent, now time.T
|
||||
if event.Key == "Control" || event.Key == "Shift" || event.Key == "Alt" || event.Key == "Meta" {
|
||||
return
|
||||
}
|
||||
if event.Ctrl || event.Alt || event.Meta {
|
||||
r.Flush()
|
||||
if label, ok := rdpShortcutAuditLabel(event); ok {
|
||||
r.create(label)
|
||||
return
|
||||
}
|
||||
r.create("[RDP按键] " + rdpModifierLabel(event) + strings.ToUpper(event.Key))
|
||||
return
|
||||
}
|
||||
if event.Code == "Enter" || event.Code == "NumpadEnter" {
|
||||
if strings.TrimSpace(r.inputBuffer) != "" {
|
||||
r.Flush()
|
||||
@@ -370,6 +472,42 @@ func isAuditableRDPKey(key string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func rdpModifierLabel(event rdpengine.ClientEvent) string {
|
||||
parts := make([]string, 0, 3)
|
||||
if event.Ctrl {
|
||||
parts = append(parts, "Ctrl")
|
||||
}
|
||||
if event.Alt {
|
||||
parts = append(parts, "Alt")
|
||||
}
|
||||
if event.Meta {
|
||||
parts = append(parts, "Meta")
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(parts, "+") + "+"
|
||||
}
|
||||
|
||||
func rdpShortcutAuditLabel(event rdpengine.ClientEvent) (string, bool) {
|
||||
if !event.Ctrl || event.Alt || event.Meta {
|
||||
return "", false
|
||||
}
|
||||
key := strings.ToUpper(event.Key)
|
||||
switch key {
|
||||
case "A":
|
||||
return "[RDP全选] Ctrl+A", true
|
||||
case "C":
|
||||
return "[RDP复制] Ctrl+C", true
|
||||
case "V":
|
||||
return "[RDP粘贴快捷键] Ctrl+V", true
|
||||
case "X":
|
||||
return "[RDP剪切] Ctrl+X", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func (r *rdpOperationRecorder) Flush() {
|
||||
if strings.TrimSpace(r.inputBuffer) == "" {
|
||||
r.inputBuffer = ""
|
||||
@@ -423,10 +561,16 @@ type wsFrameSink struct {
|
||||
bitmaps int
|
||||
lastLog time.Time
|
||||
lastRecord time.Time
|
||||
recordFB *image.RGBA
|
||||
recordW int
|
||||
recordH int
|
||||
seenScreen bool
|
||||
}
|
||||
|
||||
func (s *wsFrameSink) SendFrame(frame rdpengine.ServerFrame) error {
|
||||
s.recorder.Append(frame)
|
||||
if frame.Type != "bitmap" {
|
||||
s.recorder.Append(frame)
|
||||
}
|
||||
if s.live != nil {
|
||||
s.live.publish(frame)
|
||||
}
|
||||
@@ -451,20 +595,64 @@ func (s *wsFrameSink) snapshotFrame(frame rdpengine.ServerFrame) (rdpengine.Serv
|
||||
if frame.Type != "bitmap" || len(frame.RGBA) == 0 || frame.Width <= 0 || frame.Height <= 0 {
|
||||
return rdpengine.ServerFrame{}, false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.recordW <= 0 || s.recordH <= 0 {
|
||||
s.recordW = frame.Width + frame.X
|
||||
s.recordH = frame.Height + frame.Y
|
||||
if s.recordW < 1 || s.recordH < 1 {
|
||||
return rdpengine.ServerFrame{}, false
|
||||
}
|
||||
}
|
||||
if s.recordFB == nil {
|
||||
s.recordFB = image.NewRGBA(image.Rect(0, 0, s.recordW, s.recordH))
|
||||
}
|
||||
rect := image.Rect(frame.X, frame.Y, frame.X+frame.Width, frame.Y+frame.Height).Intersect(s.recordFB.Bounds())
|
||||
if rect.Empty() {
|
||||
return rdpengine.ServerFrame{}, false
|
||||
}
|
||||
src := image.NewRGBA(image.Rect(0, 0, frame.Width, frame.Height))
|
||||
copy(src.Pix, frame.RGBA)
|
||||
draw.Draw(s.recordFB, rect, src, image.Point{X: rect.Min.X - frame.X, Y: rect.Min.Y - frame.Y}, draw.Src)
|
||||
if !s.seenScreen {
|
||||
if isMostlyBlackRGBA(s.recordFB, 0.985) {
|
||||
return rdpengine.ServerFrame{}, false
|
||||
}
|
||||
s.seenScreen = true
|
||||
}
|
||||
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 {
|
||||
if err := png.Encode(&buf, s.recordFB); err != nil {
|
||||
return rdpengine.ServerFrame{}, false
|
||||
}
|
||||
frame.RGBA = nil
|
||||
frame.Image = "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||
return frame, true
|
||||
return rdpengine.ServerFrame{
|
||||
Type: "bitmap",
|
||||
X: 0,
|
||||
Y: 0,
|
||||
Width: s.recordW,
|
||||
Height: s.recordH,
|
||||
Image: "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()),
|
||||
}, true
|
||||
}
|
||||
|
||||
// isMostlyBlackRGBA 判断录屏缓存是否仍是启动期黑屏,避免历史回放堆满无效黑帧。 // Logan.Li
|
||||
func isMostlyBlackRGBA(img *image.RGBA, threshold float64) bool {
|
||||
if img == nil || len(img.Pix) == 0 {
|
||||
return true
|
||||
}
|
||||
total := 0
|
||||
black := 0
|
||||
for i := 0; i+3 < len(img.Pix); i += 4 {
|
||||
total++
|
||||
if img.Pix[i] <= 4 && img.Pix[i+1] <= 4 && img.Pix[i+2] <= 4 {
|
||||
black++
|
||||
}
|
||||
}
|
||||
return total == 0 || float64(black)/float64(total) >= threshold
|
||||
}
|
||||
|
||||
func writeRDPFrame(mgr *wshub.WSManager, conn *websocket.Conn, frame rdpengine.ServerFrame) error {
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ezops/model"
|
||||
"ezops/pkg/crypto"
|
||||
"ezops/pkg/rdpfilestage"
|
||||
"ezops/pkg/winsmb"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RDPFileHandler 通过 SMB 管理共享实现 RDP 虚拟 Download 盘文件传输 // Logan.Li
|
||||
type RDPFileHandler struct {
|
||||
db *gorm.DB
|
||||
crypt *crypto.SecretBox
|
||||
}
|
||||
|
||||
func NewRDPFileHandler(db *gorm.DB, masterKey string) *RDPFileHandler {
|
||||
return &RDPFileHandler{db: db, crypt: crypto.NewSecretBox(masterKey)}
|
||||
}
|
||||
|
||||
func (h *RDPFileHandler) loadAuth(serverID uint) (winsmb.Auth, error) {
|
||||
var server model.Server
|
||||
if err := h.db.First(&server, serverID).Error; err != nil {
|
||||
return winsmb.Auth{}, err
|
||||
}
|
||||
normalizeServerRemoteAccess(&server)
|
||||
if !isRDPServer(server) {
|
||||
return winsmb.Auth{}, errors.New("该资产未配置为 RDP/Windows")
|
||||
}
|
||||
cred, err := loadRDPCredential(h.crypt, server)
|
||||
if err != nil {
|
||||
return winsmb.Auth{}, err
|
||||
}
|
||||
return winsmb.Auth{
|
||||
Host: server.IP,
|
||||
Domain: cred.Domain,
|
||||
Username: cred.Username,
|
||||
Password: cred.Password,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *RDPFileHandler) ListFiles(c *gin.Context) {
|
||||
serverID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的服务器ID"})
|
||||
return
|
||||
}
|
||||
auth, err := h.loadAuth(uint(serverID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
entries, err := winsmb.ListDownload(auth)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, model.Response{Code: 502, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
type item struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
ModTime string `json:"mod_time"`
|
||||
}
|
||||
list := make([]item, 0, len(entries))
|
||||
for _, ent := range entries {
|
||||
if ent.IsDir {
|
||||
continue
|
||||
}
|
||||
list = append(list, item{
|
||||
Name: ent.Name,
|
||||
Size: ent.Size,
|
||||
IsDir: ent.IsDir,
|
||||
ModTime: ent.ModTime.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
Code: 200,
|
||||
Data: gin.H{
|
||||
"root": winsmb.DownloadFullPath,
|
||||
"list": list,
|
||||
"total": len(list),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *RDPFileHandler) UploadFile(c *gin.Context) {
|
||||
serverID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的服务器ID"})
|
||||
return
|
||||
}
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请选择文件"})
|
||||
return
|
||||
}
|
||||
if fileHeader.Size > 100<<20 {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "单文件不能超过 100MB"})
|
||||
return
|
||||
}
|
||||
f, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "读取上传文件失败"})
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "读取上传文件失败"})
|
||||
return
|
||||
}
|
||||
auth, err := h.loadAuth(uint(serverID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := winsmb.Upload(auth, fileHeader.Filename, data); err != nil {
|
||||
c.JSON(http.StatusBadGateway, model.Response{Code: 502, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
Code: 200,
|
||||
Message: "上传成功",
|
||||
Data: gin.H{
|
||||
"name": fileHeader.Filename,
|
||||
"path": winsmb.DownloadFullPath + `\` + fileHeader.Filename,
|
||||
"size": fileHeader.Size,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *RDPFileHandler) DownloadFile(c *gin.Context) {
|
||||
serverID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的服务器ID"})
|
||||
return
|
||||
}
|
||||
name := c.Query("name")
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "缺少文件名"})
|
||||
return
|
||||
}
|
||||
auth, err := h.loadAuth(uint(serverID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
data, err := winsmb.Download(auth, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, model.Response{Code: 502, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Header("Content-Disposition", "attachment; filename=\""+name+"\"")
|
||||
c.Data(http.StatusOK, "application/octet-stream", data)
|
||||
}
|
||||
|
||||
// StageUpload SMB 不可用时:文件暂存 AIOps,返回可由 Windows 拉取的一次性 URL // Logan.Li
|
||||
func (h *RDPFileHandler) StageUpload(c *gin.Context) {
|
||||
serverID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的服务器ID"})
|
||||
return
|
||||
}
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请选择文件"})
|
||||
return
|
||||
}
|
||||
if fileHeader.Size > 50<<20 {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "回退模式单文件不能超过 50MB"})
|
||||
return
|
||||
}
|
||||
f, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "读取上传文件失败"})
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "读取上传文件失败"})
|
||||
return
|
||||
}
|
||||
if _, err := h.loadAuth(uint(serverID)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
token := rdpfilestage.Put(fileHeader.Filename, data, 30*time.Minute)
|
||||
base := strings.TrimRight(publicHTTPBase(c), "/")
|
||||
downloadURL := fmt.Sprintf("%s/api/v1/rdp-staged/%s", base, token)
|
||||
remotePath := winsmb.DownloadFullPath + `\` + fileHeader.Filename
|
||||
deployCmd := fmt.Sprintf(
|
||||
`powershell -NoProfile -WindowStyle Hidden -Command "$d='%s';if(!(Test-Path $d)){ni -ItemType Directory -Path $d -Force|Out-Null};iwr -UseBasicParsing -Uri '%s' -OutFile (Join-Path $d '%s')"`,
|
||||
strings.ReplaceAll(winsmb.DownloadFullPath, `'`, `''`),
|
||||
downloadURL,
|
||||
strings.ReplaceAll(fileHeader.Filename, `'`, `''`),
|
||||
)
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
Code: 200,
|
||||
Data: gin.H{
|
||||
"token": token,
|
||||
"download_url": downloadURL,
|
||||
"remote_path": remotePath,
|
||||
"deploy_command": deployCmd,
|
||||
"mode": "stage",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ServeStaged 供 Windows 主机拉取暂存文件(token 即密钥)// Logan.Li
|
||||
func (h *RDPFileHandler) ServeStaged(c *gin.Context) {
|
||||
token := c.Param("token")
|
||||
item, ok := rdpfilestage.Get(token)
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "链接已过期或不存在"})
|
||||
return
|
||||
}
|
||||
c.Header("Content-Disposition", "attachment; filename=\""+item.Name+"\"")
|
||||
c.Data(http.StatusOK, "application/octet-stream", item.Data)
|
||||
}
|
||||
|
||||
func publicHTTPBase(c *gin.Context) string {
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
|
||||
scheme = "https"
|
||||
}
|
||||
host := c.Request.Host
|
||||
if fwd := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); fwd != "" {
|
||||
host = strings.Split(fwd, ",")[0]
|
||||
}
|
||||
return scheme + "://" + host
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"ezops/model"
|
||||
"ezops/pkg/rdp/filestage"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ServeRDPStaged 供浏览器下载 RDPDR Download 目录捕获的暂存文件。// Logan.Li
|
||||
func ServeRDPStaged(c *gin.Context) {
|
||||
token := c.Param("token")
|
||||
item, ok := filestage.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)
|
||||
}
|
||||
+8
-1
@@ -57,7 +57,13 @@ func setupFrontend(r *gin.Engine) {
|
||||
f, err := staticFS.Open(cleanPath)
|
||||
if err == nil {
|
||||
f.Close()
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
if cleanPath == "index.html" || strings.HasSuffix(cleanPath, ".html") {
|
||||
// HTML 入口必须每次校验,避免浏览器长期执行旧版前端 bundle。// Logan.Li
|
||||
c.Header("Cache-Control", "no-store, no-cache, must-revalidate")
|
||||
} else {
|
||||
// 当前内嵌构建在本地验证阶段需要强制校验,避免旧 bundle 残留。// Logan.Li
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
}
|
||||
c.FileFromFS(path, httpFS)
|
||||
return
|
||||
}
|
||||
@@ -68,6 +74,7 @@ func setupFrontend(r *gin.Engine) {
|
||||
c.String(http.StatusInternalServerError, "前端文件缺失")
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "no-store, no-cache, must-revalidate")
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", indexBytes)
|
||||
})
|
||||
}
|
||||
@@ -12,7 +12,6 @@ require (
|
||||
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
|
||||
|
||||
@@ -78,8 +78,6 @@ 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=
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// AIAnalysisTrace 记录每次 AI 分析看到的上下文、模型输出和审计追踪信息。 // Logan.Li
|
||||
type AIAnalysisTrace struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
TraceID string `gorm:"size:64;index;not null" json:"trace_id"`
|
||||
SpanID string `gorm:"size:64;index;not null" json:"span_id"`
|
||||
ParentSpanID string `gorm:"size:64;index" json:"parent_span_id"`
|
||||
UserID uint `gorm:"index" json:"user_id"`
|
||||
Feature string `gorm:"size:64;index;not null" json:"feature"`
|
||||
Target string `gorm:"size:255;index" json:"target"`
|
||||
AlertID uint `gorm:"index" json:"alert_id"`
|
||||
Provider string `gorm:"size:50" json:"provider"`
|
||||
Model string `gorm:"size:100" json:"model"`
|
||||
Source string `gorm:"size:64" json:"source"`
|
||||
Status string `gorm:"size:32;index" json:"status"`
|
||||
ContextPack string `gorm:"type:text" json:"context_pack"`
|
||||
Result string `gorm:"type:text" json:"result"`
|
||||
EvidenceCount int `json:"evidence_count"`
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
Duration int64 `json:"duration"`
|
||||
Error string `gorm:"type:text" json:"error,omitempty"`
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 表名 // Logan.Li
|
||||
func (AIAnalysisTrace) TableName() string { return "ai_analysis_traces" }
|
||||
@@ -4,13 +4,20 @@ import "time"
|
||||
|
||||
// AuditSettings 审计策略配置(单行) // Logan.Li
|
||||
type AuditSettings struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
RetentionDays int `gorm:"default:180;not null" json:"retention_days"`
|
||||
RecordAll bool `gorm:"default:true" json:"record_all"`
|
||||
LastCleanupAt *time.Time `json:"last_cleanup_at,omitempty"`
|
||||
LastCleanupSummary string `gorm:"size:500" json:"last_cleanup_summary,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
RetentionDays int `gorm:"default:180;not null" json:"retention_days"`
|
||||
RecordAll bool `gorm:"default:true" json:"record_all"`
|
||||
SessionWatermarkEnabled bool `gorm:"default:true;not null" json:"session_watermark_enabled"`
|
||||
SessionRecordEnabled bool `gorm:"default:true;not null" json:"session_record_enabled"`
|
||||
ClipboardEnabled bool `gorm:"default:true;not null" json:"clipboard_enabled"`
|
||||
FileUploadEnabled bool `gorm:"default:true;not null" json:"file_upload_enabled"`
|
||||
FileDownloadEnabled bool `gorm:"default:true;not null" json:"file_download_enabled"`
|
||||
CommandPolicyEnabled bool `gorm:"default:false;not null" json:"command_policy_enabled"`
|
||||
ApprovalRequired bool `gorm:"default:false;not null" json:"approval_required"`
|
||||
LastCleanupAt *time.Time `json:"last_cleanup_at,omitempty"`
|
||||
LastCleanupSummary string `gorm:"size:500" json:"last_cleanup_summary,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (AuditSettings) TableName() string { return "sys_audit_settings" }
|
||||
@@ -26,4 +33,4 @@ func IsValidRetentionDays(days int) bool {
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ type EngineConfig struct {
|
||||
SafetyMode safety.Mode // 安全模式(默认 balanced)
|
||||
Core *core.Config // ReAct 配置(nil 用默认)
|
||||
MemoryDir string // Evolver 记忆目录(空则 data/agentcli)
|
||||
ExtraTools []tools.Tool // 平台注入工具,如 AIOps CMDB/告警/指标查询 // Logan.Li
|
||||
}
|
||||
|
||||
// Engine Agent CLI 引擎实例(每个 WebSocket 连接 / 任务一个) // Logan.Li
|
||||
@@ -106,6 +107,11 @@ func NewEngine(cfg EngineConfig) (*Engine, error) {
|
||||
}
|
||||
registry.Register(sshTool)
|
||||
}
|
||||
for _, tool := range cfg.ExtraTools {
|
||||
if tool != nil {
|
||||
registry.Register(tool)
|
||||
}
|
||||
}
|
||||
registry.Register(tools.NewAnalyzeOutputTool())
|
||||
|
||||
// 安全控制器
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// AIOpsContextProvider 是 Agent CLI 调用平台上下文的最小接口,具体查库实现在 pkg/aiopscontext。 // Logan.Li
|
||||
type AIOpsContextProvider interface {
|
||||
SearchAssets(ctx context.Context, keyword string, limit int) (interface{}, error)
|
||||
ListAlerts(ctx context.Context, target, severity, status, timeRange string, limit int) (interface{}, error)
|
||||
MetricSummary(ctx context.Context, target, metric, timeRange string, limit int) (interface{}, error)
|
||||
}
|
||||
|
||||
// NewAIOpsAssetSearchTool 创建资产搜索工具。 // Logan.Li
|
||||
func NewAIOpsAssetSearchTool(provider AIOpsContextProvider) Tool {
|
||||
return &aiopsAssetSearchTool{provider: provider}
|
||||
}
|
||||
|
||||
// NewAIOpsAlertListTool 创建告警查询工具。 // Logan.Li
|
||||
func NewAIOpsAlertListTool(provider AIOpsContextProvider) Tool {
|
||||
return &aiopsAlertListTool{provider: provider}
|
||||
}
|
||||
|
||||
// NewAIOpsMetricSummaryTool 创建指标摘要工具。 // Logan.Li
|
||||
func NewAIOpsMetricSummaryTool(provider AIOpsContextProvider) Tool {
|
||||
return &aiopsMetricSummaryTool{provider: provider}
|
||||
}
|
||||
|
||||
type aiopsAssetSearchTool struct {
|
||||
provider AIOpsContextProvider
|
||||
}
|
||||
|
||||
func (t *aiopsAssetSearchTool) Name() string { return "aiops_asset_search" }
|
||||
|
||||
func (t *aiopsAssetSearchTool) Description() string {
|
||||
return "搜索 AIOps 平台 CMDB/资产库中的服务器资产,返回脱敏后的主机名、IP、协议、系统、状态、Agent 状态和资源使用率。排障前应先用它确认目标对象。"
|
||||
}
|
||||
|
||||
func (t *aiopsAssetSearchTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"keyword": map[string]interface{}{"type": "string", "description": "主机名、IP、标签或描述关键字,可为空"},
|
||||
"limit": map[string]interface{}{"type": "integer", "description": "返回数量,默认 10,最大 50"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *aiopsAssetSearchTool) RiskLevel() RiskLevel { return RiskSafe }
|
||||
|
||||
func (t *aiopsAssetSearchTool) Execute(ctx context.Context, args map[string]interface{}) (*Result, error) {
|
||||
data, err := t.provider.SearchAssets(ctx, StringParam(args, "keyword"), IntParam(args, "limit"))
|
||||
return jsonResult(data, err)
|
||||
}
|
||||
|
||||
type aiopsAlertListTool struct {
|
||||
provider AIOpsContextProvider
|
||||
}
|
||||
|
||||
func (t *aiopsAlertListTool) Name() string { return "aiops_alert_list" }
|
||||
|
||||
func (t *aiopsAlertListTool) Description() string {
|
||||
return "查询 AIOps 平台近期告警,合并平台告警和 Agent 告警。可按目标、级别、状态、时间范围过滤,用于判断故障是否与近期告警相关。"
|
||||
}
|
||||
|
||||
func (t *aiopsAlertListTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"target": map[string]interface{}{"type": "string", "description": "主机名、IP、目标名称,可为空"},
|
||||
"severity": map[string]interface{}{"type": "string", "description": "critical/warning/info,可为空"},
|
||||
"status": map[string]interface{}{"type": "string", "description": "firing/resolved/active,可为空"},
|
||||
"time_range": map[string]interface{}{"type": "string", "description": "15m/30m/1h/2h/6h/24h/7d,默认 24h"},
|
||||
"limit": map[string]interface{}{"type": "integer", "description": "返回数量,默认 20,最大 100"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *aiopsAlertListTool) RiskLevel() RiskLevel { return RiskSafe }
|
||||
|
||||
func (t *aiopsAlertListTool) Execute(ctx context.Context, args map[string]interface{}) (*Result, error) {
|
||||
data, err := t.provider.ListAlerts(ctx,
|
||||
StringParam(args, "target"), StringParam(args, "severity"),
|
||||
StringParam(args, "status"), StringParam(args, "time_range"),
|
||||
IntParam(args, "limit"))
|
||||
return jsonResult(data, err)
|
||||
}
|
||||
|
||||
type aiopsMetricSummaryTool struct {
|
||||
provider AIOpsContextProvider
|
||||
}
|
||||
|
||||
func (t *aiopsMetricSummaryTool) Name() string { return "aiops_metric_summary" }
|
||||
|
||||
func (t *aiopsMetricSummaryTool) Description() string {
|
||||
return "查询主机 Agent 指标摘要,返回 latest/min/max/avg/p95/trend 和少量采样点。适合验证 CPU、内存、load 等告警是否真实持续。"
|
||||
}
|
||||
|
||||
func (t *aiopsMetricSummaryTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"target": map[string]interface{}{"type": "string", "description": "主机名或 IP,必填"},
|
||||
"metric": map[string]interface{}{"type": "string", "description": "cpu_usage/mem_usage/load1/load5/load15/cpu_iowait,默认 cpu_usage"},
|
||||
"time_range": map[string]interface{}{"type": "string", "description": "15m/30m/1h/2h/6h/24h/7d,默认 1h"},
|
||||
"limit": map[string]interface{}{"type": "integer", "description": "最多读取多少个样本,默认 240"},
|
||||
},
|
||||
"required": []string{"target"},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *aiopsMetricSummaryTool) RiskLevel() RiskLevel { return RiskSafe }
|
||||
|
||||
func (t *aiopsMetricSummaryTool) Execute(ctx context.Context, args map[string]interface{}) (*Result, error) {
|
||||
if StringParam(args, "target") == "" {
|
||||
return &Result{Success: false, Error: "target 不能为空", Output: ""}, nil
|
||||
}
|
||||
data, err := t.provider.MetricSummary(ctx,
|
||||
StringParam(args, "target"), StringParam(args, "metric"),
|
||||
StringParam(args, "time_range"), IntParam(args, "limit"))
|
||||
return jsonResult(data, err)
|
||||
}
|
||||
|
||||
func jsonResult(data interface{}, err error) (*Result, error) {
|
||||
if err != nil {
|
||||
return &Result{Success: false, Error: err.Error(), Output: err.Error()}, nil
|
||||
}
|
||||
b, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("JSON 序列化失败: %w", err)
|
||||
}
|
||||
return &Result{Success: true, Output: string(b)}, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package aiopscontext
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"ezops/pkg/agentcli/tools"
|
||||
)
|
||||
|
||||
// AgentProvider 适配 Agent CLI tools.AIOpsContextProvider。 // Logan.Li
|
||||
type AgentProvider struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
// NewAgentTools 返回可注册到 Agent CLI 的 AIOps 平台只读工具。 // Logan.Li
|
||||
func NewAgentTools(service *Service) []tools.Tool {
|
||||
provider := &AgentProvider{service: service}
|
||||
return []tools.Tool{
|
||||
tools.NewAIOpsAssetSearchTool(provider),
|
||||
tools.NewAIOpsAlertListTool(provider),
|
||||
tools.NewAIOpsMetricSummaryTool(provider),
|
||||
}
|
||||
}
|
||||
|
||||
// SearchAssets 查询资产。 // Logan.Li
|
||||
func (p *AgentProvider) SearchAssets(ctx context.Context, keyword string, limit int) (interface{}, error) {
|
||||
return p.service.SearchAssets(ctx, keyword, limit)
|
||||
}
|
||||
|
||||
// ListAlerts 查询告警。 // Logan.Li
|
||||
func (p *AgentProvider) ListAlerts(ctx context.Context, target, severity, status, timeRange string, limit int) (interface{}, error) {
|
||||
return p.service.ListAlerts(ctx, AlertQuery{
|
||||
Target: target, Severity: severity, Status: status, TimeRange: timeRange, Limit: limit,
|
||||
})
|
||||
}
|
||||
|
||||
// MetricSummary 查询指标摘要。 // Logan.Li
|
||||
func (p *AgentProvider) MetricSummary(ctx context.Context, target, metric, timeRange string, limit int) (interface{}, error) {
|
||||
return p.service.MetricSummary(ctx, target, metric, timeRange, limit)
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
package aiopscontext
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ezops/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Service 汇聚 AIOps 平台上下文,后续可复用为 MCP Server 的数据源。 // Logan.Li
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewService 创建上下文服务。 // Logan.Li
|
||||
func NewService(db *gorm.DB) *Service {
|
||||
return &Service{db: db}
|
||||
}
|
||||
|
||||
// Summary 返回平台总览上下文。 // Logan.Li
|
||||
func (s *Service) Summary(ctx context.Context) (*Summary, error) {
|
||||
out := Summary{
|
||||
HighLoadHosts: []AssetSummary{},
|
||||
RecentAlerts: []AlertSummary{},
|
||||
}
|
||||
db := s.db.WithContext(ctx)
|
||||
db.Model(&model.Server{}).Count(&out.ServerTotal)
|
||||
db.Model(&model.Server{}).Where("agent_status = ? OR status = ?", "online", "running").Count(&out.ServerOnline)
|
||||
db.Model(&model.KubernetesCluster{}).Count(&out.ClusterTotal)
|
||||
db.Model(&model.AlertEvent{}).Where("status = ?", "firing").Count(&out.FiringAlerts)
|
||||
db.Model(&model.AgentAlert{}).Where("resolved = ?", false).Count(&out.UnresolvedAlerts)
|
||||
|
||||
assets, err := s.SearchAssets(ctx, "", 8)
|
||||
if err == nil {
|
||||
for _, a := range assets {
|
||||
if a.CPUUsage >= 80 || a.MemoryUsage >= 80 || a.DiskUsage >= 85 {
|
||||
out.HighLoadHosts = append(out.HighLoadHosts, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
out.RecentAlerts, _ = s.ListAlerts(ctx, AlertQuery{Limit: 10})
|
||||
out.GeneratedAt = time.Now()
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// SearchAssets 查询资产摘要,默认按更新时间倒序返回。 // Logan.Li
|
||||
func (s *Service) SearchAssets(ctx context.Context, keyword string, limit int) ([]AssetSummary, error) {
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 10
|
||||
}
|
||||
var servers []model.Server
|
||||
q := s.db.WithContext(ctx).Model(&model.Server{})
|
||||
kw := strings.TrimSpace(keyword)
|
||||
if kw != "" {
|
||||
like := "%" + kw + "%"
|
||||
q = q.Where("hostname LIKE ? OR ip LIKE ? OR public_ip LIKE ? OR tags LIKE ? OR description LIKE ?",
|
||||
like, like, like, like, like)
|
||||
}
|
||||
if err := q.Order("updated_at DESC").Limit(limit).Find(&servers).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]AssetSummary, 0, len(servers))
|
||||
for _, srv := range servers {
|
||||
out = append(out, assetFromServer(srv))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListAlerts 查询统一告警摘要。 // Logan.Li
|
||||
func (s *Service) ListAlerts(ctx context.Context, query AlertQuery) ([]AlertSummary, error) {
|
||||
limit := query.Limit
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
since := sinceByRange(query.TimeRange, 24*time.Hour)
|
||||
targetLike := "%" + strings.TrimSpace(query.Target) + "%"
|
||||
|
||||
var events []model.AlertEvent
|
||||
q := s.db.WithContext(ctx).Model(&model.AlertEvent{}).Where("created_at >= ?", since)
|
||||
if query.Target != "" {
|
||||
q = q.Where("target LIKE ? OR target_name LIKE ? OR target_ip LIKE ? OR rule_name LIKE ?",
|
||||
targetLike, targetLike, targetLike, targetLike)
|
||||
}
|
||||
if query.Severity != "" {
|
||||
q = q.Where("severity = ?", query.Severity)
|
||||
}
|
||||
if query.Status != "" {
|
||||
q = q.Where("status = ?", query.Status)
|
||||
}
|
||||
if err := q.Order("created_at DESC").Limit(limit).Find(&events).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]AlertSummary, 0, len(events)+limit)
|
||||
for _, e := range events {
|
||||
out = append(out, alertFromEvent(e))
|
||||
}
|
||||
|
||||
var agentAlerts []model.AgentAlert
|
||||
aq := s.db.WithContext(ctx).Model(&model.AgentAlert{}).Where("created_at >= ?", since)
|
||||
if query.Target != "" {
|
||||
aq = aq.Where("hostname LIKE ? OR rule_name LIKE ?", targetLike, targetLike)
|
||||
}
|
||||
if query.Severity != "" {
|
||||
aq = aq.Where("severity = ?", query.Severity)
|
||||
}
|
||||
if query.Status == "firing" || query.Status == "active" {
|
||||
aq = aq.Where("resolved = ?", false)
|
||||
} else if query.Status == "resolved" {
|
||||
aq = aq.Where("resolved = ?", true)
|
||||
}
|
||||
if err := aq.Order("created_at DESC").Limit(limit).Find(&agentAlerts).Error; err == nil {
|
||||
for _, a := range agentAlerts {
|
||||
out = append(out, alertFromAgent(a))
|
||||
}
|
||||
}
|
||||
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
return out[i].StartedAt.After(out[j].StartedAt)
|
||||
})
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MetricSummary 查询主机指标摘要。 // Logan.Li
|
||||
func (s *Service) MetricSummary(ctx context.Context, target, metric, timeRange string, limit int) (*MetricSummary, error) {
|
||||
hostname, err := s.resolveHostname(ctx, target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if metric == "" {
|
||||
metric = "cpu_usage"
|
||||
}
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 240
|
||||
}
|
||||
since := sinceByRange(timeRange, time.Hour)
|
||||
var reports []model.AgentReport
|
||||
if err := s.db.WithContext(ctx).Where("hostname = ? AND report_time >= ?", hostname, since).
|
||||
Order("report_time ASC").Limit(limit).Find(&reports).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := &MetricSummary{Target: target, Hostname: hostname, Metric: metric, TimeRange: normalizeRangeLabel(timeRange), Points: []MetricPoint{}}
|
||||
values := make([]float64, 0, len(reports))
|
||||
for _, r := range reports {
|
||||
v, ok := metricValue(r, metric)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
values = append(values, v)
|
||||
if len(out.Points) < 60 || len(reports) <= 60 {
|
||||
out.Points = append(out.Points, MetricPoint{Time: r.ReportTime, Value: round2(v)})
|
||||
}
|
||||
}
|
||||
fillMetricStats(out, values)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// BuildPack 构建面向 LLM 的上下文包。 // Logan.Li
|
||||
func (s *Service) BuildPack(ctx context.Context, req ContextPackRequest) (*ContextPack, error) {
|
||||
if req.Limit <= 0 {
|
||||
req.Limit = 10
|
||||
}
|
||||
scope := strings.TrimSpace(req.Target)
|
||||
var primaryAlert *AlertSummary
|
||||
if req.AlertID > 0 {
|
||||
alert, err := s.GetAlert(ctx, req.AlertID)
|
||||
if err == nil {
|
||||
primaryAlert = alert
|
||||
if scope == "" {
|
||||
scope = alert.Target
|
||||
}
|
||||
}
|
||||
}
|
||||
pack := &ContextPack{
|
||||
Scope: scope, TimeRange: normalizeRangeLabel(req.TimeRange),
|
||||
Assets: []AssetSummary{}, Alerts: []AlertSummary{}, Metrics: []MetricSummary{},
|
||||
GeneratedAt: time.Now(), Evidence: []EvidenceItem{},
|
||||
}
|
||||
if scope != "" {
|
||||
pack.Assets, _ = s.SearchAssets(ctx, scope, req.Limit)
|
||||
for _, asset := range pack.Assets {
|
||||
pack.Evidence = append(pack.Evidence, EvidenceItem{
|
||||
Type: "asset", Source: "asset_servers",
|
||||
Summary: fmt.Sprintf("资产 %s ip=%s protocol=%s os=%s status=%s agent=%s",
|
||||
asset.Hostname, asset.IP, asset.Protocol, asset.OSType, asset.Status, asset.AgentStatus),
|
||||
Confidence: 0.9,
|
||||
})
|
||||
}
|
||||
}
|
||||
pack.Alerts, _ = s.ListAlerts(ctx, AlertQuery{Target: scope, TimeRange: req.TimeRange, Limit: req.Limit})
|
||||
if primaryAlert != nil {
|
||||
pack.Alerts = prependAlertIfMissing(pack.Alerts, *primaryAlert)
|
||||
}
|
||||
metrics := req.Metrics
|
||||
if len(metrics) == 0 {
|
||||
metrics = []string{"cpu_usage", "mem_usage", "load1"}
|
||||
}
|
||||
for _, metric := range metrics {
|
||||
if scope == "" {
|
||||
break
|
||||
}
|
||||
ms, err := s.MetricSummary(ctx, scope, metric, req.TimeRange, 240)
|
||||
if err == nil && ms.SampleSize > 0 {
|
||||
pack.Metrics = append(pack.Metrics, *ms)
|
||||
pack.Evidence = append(pack.Evidence, EvidenceItem{
|
||||
Type: "metric", Source: "monitor_agent_reports",
|
||||
Summary: fmt.Sprintf("%s 最近 %s: latest=%.2f avg=%.2f p95=%.2f max=%.2f trend=%s",
|
||||
ms.Metric, ms.TimeRange, ms.Latest, ms.Avg, ms.P95, ms.Max, ms.Trend),
|
||||
Confidence: 0.82,
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, a := range pack.Alerts {
|
||||
pack.Evidence = append(pack.Evidence, EvidenceItem{
|
||||
Type: "alert", Source: a.Source,
|
||||
Summary: fmt.Sprintf("[%s|%s] %s target=%s value=%.2f threshold=%.2f",
|
||||
a.Severity, a.Status, a.RuleName, a.Target, a.Value, a.Threshold),
|
||||
Confidence: 0.78,
|
||||
})
|
||||
}
|
||||
return pack, nil
|
||||
}
|
||||
|
||||
// GetAlert 获取单条平台告警摘要。 // Logan.Li
|
||||
func (s *Service) GetAlert(ctx context.Context, id uint) (*AlertSummary, error) {
|
||||
var event model.AlertEvent
|
||||
if err := s.db.WithContext(ctx).Take(&event, id).Error; err == nil {
|
||||
alert := alertFromEvent(event)
|
||||
return &alert, nil
|
||||
}
|
||||
var agentAlert model.AgentAlert
|
||||
if err := s.db.WithContext(ctx).Take(&agentAlert, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
alert := alertFromAgent(agentAlert)
|
||||
return &alert, nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveHostname(ctx context.Context, target string) (string, error) {
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" {
|
||||
return "", fmt.Errorf("target 不能为空")
|
||||
}
|
||||
var srv model.Server
|
||||
err := s.db.WithContext(ctx).Where("hostname = ? OR ip = ? OR public_ip = ?", target, target, target).Take(&srv).Error
|
||||
if err == nil {
|
||||
return srv.Hostname, nil
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return target, nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
func assetFromServer(srv model.Server) AssetSummary {
|
||||
return AssetSummary{
|
||||
ID: srv.ID, Hostname: srv.Hostname, IP: srv.IP, PublicIP: srv.PublicIP,
|
||||
Protocol: srv.Protocol, OSType: srv.OSType, OS: srv.OS, Status: srv.Status,
|
||||
AgentStatus: srv.AgentStatus, CPUUsage: srv.CPUUsage, MemoryUsage: srv.MemoryUsage,
|
||||
DiskUsage: srv.DiskUsage, Tags: srv.Tags, Description: srv.Description,
|
||||
LastCheckAt: srv.LastCheckAt, UpdatedAt: srv.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func alertFromEvent(e model.AlertEvent) AlertSummary {
|
||||
target := e.Target
|
||||
if target == "" {
|
||||
target = e.TargetName
|
||||
}
|
||||
if target == "" {
|
||||
target = e.TargetIP
|
||||
}
|
||||
started := e.StartedAt
|
||||
if started.IsZero() {
|
||||
started = e.CreatedAt
|
||||
}
|
||||
return AlertSummary{
|
||||
ID: e.ID, Source: "monitor_alert_events", RuleName: firstNonEmpty(e.RuleName, e.Title),
|
||||
Severity: e.Severity, Status: e.Status, Target: target, Metric: e.MetricName,
|
||||
Value: e.Value, Threshold: e.Threshold, Message: e.Message, StartedAt: started,
|
||||
ResolvedAt: e.ResolvedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func alertFromAgent(a model.AgentAlert) AlertSummary {
|
||||
status := "firing"
|
||||
if a.Resolved {
|
||||
status = "resolved"
|
||||
}
|
||||
return AlertSummary{
|
||||
ID: a.ID, Source: "monitor_agent_alerts", RuleName: a.RuleName,
|
||||
Severity: a.Severity, Status: status, Target: a.Hostname, Metric: a.Metric,
|
||||
Value: a.Value, Threshold: a.Threshold, Message: a.Message,
|
||||
StartedAt: a.CreatedAt, ResolvedAt: a.ResolvedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func prependAlertIfMissing(alerts []AlertSummary, alert AlertSummary) []AlertSummary {
|
||||
for _, item := range alerts {
|
||||
if item.Source == alert.Source && item.ID == alert.ID {
|
||||
return alerts
|
||||
}
|
||||
}
|
||||
return append([]AlertSummary{alert}, alerts...)
|
||||
}
|
||||
|
||||
func metricValue(r model.AgentReport, metric string) (float64, bool) {
|
||||
switch metric {
|
||||
case "cpu", "cpu_usage":
|
||||
return r.CPUUsage, true
|
||||
case "cpu_iowait":
|
||||
return r.CPUIOwait, true
|
||||
case "mem", "memory", "mem_usage", "memory_usage":
|
||||
return r.MemUsage, true
|
||||
case "mem_available":
|
||||
return r.MemAvailable, true
|
||||
case "swap", "mem_swap_used":
|
||||
return r.MemSwapUsed, true
|
||||
case "load1":
|
||||
return r.Load1, true
|
||||
case "load5":
|
||||
return r.Load5, true
|
||||
case "load15":
|
||||
return r.Load15, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func fillMetricStats(out *MetricSummary, values []float64) {
|
||||
out.SampleSize = len(values)
|
||||
if len(values) == 0 {
|
||||
out.Trend = "unknown"
|
||||
return
|
||||
}
|
||||
minV, maxV, sum := values[0], values[0], 0.0
|
||||
for _, v := range values {
|
||||
if v < minV {
|
||||
minV = v
|
||||
}
|
||||
if v > maxV {
|
||||
maxV = v
|
||||
}
|
||||
sum += v
|
||||
}
|
||||
sorted := append([]float64(nil), values...)
|
||||
sort.Float64s(sorted)
|
||||
p95Idx := int(math.Ceil(float64(len(sorted))*0.95)) - 1
|
||||
if p95Idx < 0 {
|
||||
p95Idx = 0
|
||||
}
|
||||
out.Latest = round2(values[len(values)-1])
|
||||
out.Min = round2(minV)
|
||||
out.Max = round2(maxV)
|
||||
out.Avg = round2(sum / float64(len(values)))
|
||||
out.P95 = round2(sorted[p95Idx])
|
||||
out.Trend = trendOf(values)
|
||||
}
|
||||
|
||||
func trendOf(values []float64) string {
|
||||
if len(values) < 4 {
|
||||
return "stable"
|
||||
}
|
||||
n := len(values)
|
||||
head := avg(values[:n/3])
|
||||
tail := avg(values[n-n/3:])
|
||||
if tail-head > 5 {
|
||||
return "up"
|
||||
}
|
||||
if head-tail > 5 {
|
||||
return "down"
|
||||
}
|
||||
return "stable"
|
||||
}
|
||||
|
||||
func avg(values []float64) float64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
sum := 0.0
|
||||
for _, v := range values {
|
||||
sum += v
|
||||
}
|
||||
return sum / float64(len(values))
|
||||
}
|
||||
|
||||
func sinceByRange(label string, fallback time.Duration) time.Time {
|
||||
switch strings.ToLower(strings.TrimSpace(label)) {
|
||||
case "15m":
|
||||
return time.Now().Add(-15 * time.Minute)
|
||||
case "30m":
|
||||
return time.Now().Add(-30 * time.Minute)
|
||||
case "2h":
|
||||
return time.Now().Add(-2 * time.Hour)
|
||||
case "6h":
|
||||
return time.Now().Add(-6 * time.Hour)
|
||||
case "24h", "1d":
|
||||
return time.Now().Add(-24 * time.Hour)
|
||||
case "7d":
|
||||
return time.Now().Add(-7 * 24 * time.Hour)
|
||||
default:
|
||||
return time.Now().Add(-fallback)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRangeLabel(label string) string {
|
||||
if strings.TrimSpace(label) == "" {
|
||||
return "1h"
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func round2(v float64) float64 {
|
||||
return math.Round(v*100) / 100
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package aiopscontext
|
||||
|
||||
import "time"
|
||||
|
||||
// Summary 平台智能上下文总览,供智能助手和 Agent 工具复用。 // Logan.Li
|
||||
type Summary struct {
|
||||
ServerTotal int64 `json:"server_total"`
|
||||
ServerOnline int64 `json:"server_online"`
|
||||
ClusterTotal int64 `json:"cluster_total"`
|
||||
FiringAlerts int64 `json:"firing_alerts"`
|
||||
UnresolvedAlerts int64 `json:"unresolved_alerts"`
|
||||
HighLoadHosts []AssetSummary `json:"high_load_hosts"`
|
||||
RecentAlerts []AlertSummary `json:"recent_alerts"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
}
|
||||
|
||||
// AssetSummary 脱敏后的资产摘要,不包含凭证字段。 // Logan.Li
|
||||
type AssetSummary struct {
|
||||
ID uint `json:"id"`
|
||||
Hostname string `json:"hostname"`
|
||||
IP string `json:"ip"`
|
||||
PublicIP string `json:"public_ip,omitempty"`
|
||||
Protocol string `json:"protocol"`
|
||||
OSType string `json:"os_type"`
|
||||
OS string `json:"os,omitempty"`
|
||||
Status string `json:"status"`
|
||||
AgentStatus string `json:"agent_status"`
|
||||
CPUUsage float64 `json:"cpu_usage"`
|
||||
MemoryUsage float64 `json:"memory_usage"`
|
||||
DiskUsage float64 `json:"disk_usage"`
|
||||
Tags string `json:"tags,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
LastCheckAt *time.Time `json:"last_check_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// AlertQuery 告警查询条件。 // Logan.Li
|
||||
type AlertQuery struct {
|
||||
Target string `json:"target,omitempty"`
|
||||
Severity string `json:"severity,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
TimeRange string `json:"time_range,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
// AlertSummary 统一告警摘要,合并平台告警和 Agent 告警。 // Logan.Li
|
||||
type AlertSummary struct {
|
||||
ID uint `json:"id"`
|
||||
Source string `json:"source"`
|
||||
RuleName string `json:"rule_name"`
|
||||
Severity string `json:"severity"`
|
||||
Status string `json:"status"`
|
||||
Target string `json:"target"`
|
||||
Metric string `json:"metric"`
|
||||
Value float64 `json:"value"`
|
||||
Threshold float64 `json:"threshold"`
|
||||
Message string `json:"message"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
||||
}
|
||||
|
||||
// MetricSummary 指标摘要,强调证据链需要的统计值和样本窗口。 // Logan.Li
|
||||
type MetricSummary struct {
|
||||
Target string `json:"target"`
|
||||
Hostname string `json:"hostname"`
|
||||
Metric string `json:"metric"`
|
||||
TimeRange string `json:"time_range"`
|
||||
SampleSize int `json:"sample_size"`
|
||||
Latest float64 `json:"latest"`
|
||||
Min float64 `json:"min"`
|
||||
Max float64 `json:"max"`
|
||||
Avg float64 `json:"avg"`
|
||||
P95 float64 `json:"p95"`
|
||||
Trend string `json:"trend"`
|
||||
Points []MetricPoint `json:"points"`
|
||||
}
|
||||
|
||||
// MetricPoint 指标采样点,默认只返回少量抽样,避免 token 膨胀。 // Logan.Li
|
||||
type MetricPoint struct {
|
||||
Time time.Time `json:"time"`
|
||||
Value float64 `json:"value"`
|
||||
}
|
||||
|
||||
// ContextPackRequest 上下文包请求。 // Logan.Li
|
||||
type ContextPackRequest struct {
|
||||
Target string `json:"target"`
|
||||
AlertID uint `json:"alert_id"`
|
||||
Metrics []string `json:"metrics"`
|
||||
TimeRange string `json:"time_range"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
// ContextPack 面向 LLM 的平台上下文包。 // Logan.Li
|
||||
type ContextPack struct {
|
||||
Scope string `json:"scope"`
|
||||
TimeRange string `json:"time_range"`
|
||||
Assets []AssetSummary `json:"assets"`
|
||||
Alerts []AlertSummary `json:"alerts"`
|
||||
Metrics []MetricSummary `json:"metrics"`
|
||||
Evidence []EvidenceItem `json:"evidence"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
}
|
||||
|
||||
// EvidenceItem 供模型引用的证据摘要。 // Logan.Li
|
||||
type EvidenceItem struct {
|
||||
Type string `json:"type"`
|
||||
Source string `json:"source"`
|
||||
Summary string `json:"summary"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
@@ -132,6 +132,7 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
&model.SessionCommand{}, // 终端会话命令审计 // Logan.Li
|
||||
&model.LLMConfig{}, // 大语言模型配置(AI 智能助手) // Logan.Li
|
||||
&model.ChatHistory{}, // 对话历史记录(按用户隔离) // Logan.Li
|
||||
&model.AIAnalysisTrace{}, // AI 分析上下文与结果审计回放 // Logan.Li
|
||||
&model.MetricsAggregate{}, // 指标时序聚合(Redis → MySQL)// Logan.Li
|
||||
&model.MonitorTarget{}, // 监控目标(主机/中间件/设备/站点) // Logan.Li
|
||||
&model.MonitorMetric{}, // 监控指标定义 // Logan.Li
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
package cliprdr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode/utf16"
|
||||
)
|
||||
|
||||
// Sender 负责把 CLIPRDR PDU 通过 MCS SendDataRequest 发往指定 channel ID。
|
||||
// 由协议栈注入(通常封装 buildMCSSendDataRequest + transport.WriteX224Data + writeMu)。// Logan.Li
|
||||
type Sender interface {
|
||||
SendChannelPDU(channelID uint16, data []byte) error
|
||||
}
|
||||
|
||||
// Handler 实现 RDP 剪贴板虚拟通道(CF_UNICODETEXT / CF_TEXT 文本格式)。
|
||||
//
|
||||
// - onRemote: 远端剪贴板文本到达时回调(协议栈应转成 WS type=clipboard 帧推给浏览器)
|
||||
// - getLocal: 远端请求本地剪贴板时回调取值(协议栈从 inputLoop 维护的本地剪贴板缓存读取)
|
||||
//
|
||||
// 两个回调都是线程安全的调用点:Handler 内部用 mu 保护自身状态,
|
||||
// 调用方负责 Sender 的并发写(writeMu)。
|
||||
type Handler struct {
|
||||
sender Sender
|
||||
channelID uint16
|
||||
|
||||
mu sync.Mutex
|
||||
useLongFormatNames bool
|
||||
serverCapsReceived bool
|
||||
monitorReady bool
|
||||
localText string // 本地剪贴板文本(由 SetLocalText 更新,供远端请求时回填)
|
||||
suppressNextChange bool // 抑制 echo 回环:远端→本地 触发的本地变更不再回推远端
|
||||
|
||||
onRemote func(text string) // 远端文本到达回调(可能为 nil)
|
||||
initialFormatListSent bool // 初始化阶段只允许发送一次空 FormatList,匹配 FreeRDP/IronRDP 时序。// Logan.Li
|
||||
}
|
||||
|
||||
// NewHandler 创建剪贴板处理器。
|
||||
// - onRemote: 远端剪贴板内容到达时调用(转成 WS clipboard 帧推送浏览器)
|
||||
// - getLocal: 已废弃,本地文本请用 SetLocalText 主动设置(浏览器同步剪贴板时写入)
|
||||
func NewHandler(onRemote func(text string)) *Handler {
|
||||
return &Handler{onRemote: onRemote}
|
||||
}
|
||||
|
||||
// Attach 绑定发送通道与会话分配的 channel ID(在 MCS ChannelJoin 成功后调用)。
|
||||
// 附加后主动发 CB_CLIP_CAPS 声明客户端能力(必须用 Long Format Names),
|
||||
// 否则 Windows 不知道格式协商方式,会忽略后续 FormatList。 // Logan.Li
|
||||
func (h *Handler) Attach(sender Sender, channelID uint16) {
|
||||
h.mu.Lock()
|
||||
h.sender = sender
|
||||
h.channelID = channelID
|
||||
h.useLongFormatNames = true // 客户端强制 Long Format Names,与发送的 caps 一致
|
||||
h.mu.Unlock()
|
||||
log.Printf("[cliprdr] attached channelID=%d", channelID)
|
||||
}
|
||||
|
||||
// Init 在会话激活后主动发起剪贴板握手。
|
||||
// MS-RDPECLIP 规定服务端先发 CB_CLIP_CAPS + CB_MONITOR_READY,但部分 Windows 版本
|
||||
// (尤其开启了剪贴板重定向策略的)会等客户端先声明能力。主动发 caps + formatList
|
||||
// 可兼容两种情况,不会产生冲突(服务端收到多余的 caps 会忽略)。// Logan.Li
|
||||
func (h *Handler) Init() {
|
||||
h.mu.Lock()
|
||||
if h.sender == nil {
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
monitorReady := h.monitorReady
|
||||
needInitial := !h.initialFormatListSent
|
||||
if needInitial {
|
||||
h.initialFormatListSent = true
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if monitorReady {
|
||||
log.Printf("[cliprdr] Debug: %s", fmt.Sprintln("cliprdr init: server MonitorReady already handled"))
|
||||
return
|
||||
}
|
||||
// 部分 Windows 会先等客户端声明能力;延迟主动初始化兼顾标准时序和真实服务器兼容性。// Logan.Li
|
||||
h.sendClipCaps()
|
||||
if needInitial {
|
||||
h.sendEmptyFormatList()
|
||||
}
|
||||
log.Printf("[cliprdr] Debug: %s", fmt.Sprintln("cliprdr init: proactive caps and initial FormatList sent"))
|
||||
}
|
||||
|
||||
// SetLocalText 更新本地剪贴板文本(浏览器把本地剪贴板同步过来时调用)。
|
||||
// 设置后会向远端发送 FormatList 通知远端"本地剪贴板有新内容",
|
||||
// 除非 suppress 为 true(用于抑制远端→本地 echo 回环)。
|
||||
func (h *Handler) SetLocalText(text string) {
|
||||
h.mu.Lock()
|
||||
h.localText = text
|
||||
suppress := h.suppressNextChange
|
||||
h.suppressNextChange = false
|
||||
sender := h.sender
|
||||
h.mu.Unlock()
|
||||
|
||||
if suppress {
|
||||
log.Printf("[cliprdr] Debug: %s", fmt.Sprintln("cliprdr: local text set with suppress (echo from remote), skip FormatList"))
|
||||
return
|
||||
}
|
||||
if sender != nil {
|
||||
h.sendFormatList()
|
||||
log.Printf("[cliprdr] Debug: %s", fmt.Sprintln("cliprdr: local text changed, sent FormatList", "len", len(text)))
|
||||
}
|
||||
}
|
||||
|
||||
// ChannelID 返回绑定的通道 ID(未 Attach 返回 0)。
|
||||
func (h *Handler) ChannelID() uint16 {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.channelID
|
||||
}
|
||||
|
||||
// RequestRemoteText 在用户触发远端 Ctrl+C/Ctrl+X 后主动请求文本内容,
|
||||
// 兼容部分 Windows 不主动发送 CB_FORMAT_LIST 的场景。// Logan.Li
|
||||
func (h *Handler) RequestRemoteText() {
|
||||
h.mu.Lock()
|
||||
ready := h.sender != nil
|
||||
h.mu.Unlock()
|
||||
if !ready {
|
||||
return
|
||||
}
|
||||
h.sendFormatDataRequest(cfUnicodeText)
|
||||
}
|
||||
|
||||
// Process 处理从 MCS SendDataIndication 收到的 cliprdr PDU(已去掉 MCS/SEC 头的原始通道数据)。
|
||||
// 由协议栈 readLoop 在识别到 cliprdr channel ID 后调用。
|
||||
func (h *Handler) Process(data []byte) {
|
||||
if len(data) < 8 {
|
||||
return
|
||||
}
|
||||
msgType := binary.LittleEndian.Uint16(data[0:2])
|
||||
msgFlags := binary.LittleEndian.Uint16(data[2:4])
|
||||
dataLen := binary.LittleEndian.Uint32(data[4:8])
|
||||
if int(dataLen) > len(data)-8 {
|
||||
log.Printf("[cliprdr] Warn: %s", fmt.Sprintln("cliprdr: PDU dataLen exceeds payload", "dataLen", dataLen, "have", len(data)-8))
|
||||
return
|
||||
}
|
||||
body := data[8 : 8+dataLen]
|
||||
|
||||
log.Printf("[cliprdr] Debug: %s", fmt.Sprintln("cliprdr recv", "msgType", msgType, "msgFlags", msgFlags, "dataLen", dataLen))
|
||||
|
||||
switch msgType {
|
||||
case msgClipCaps:
|
||||
h.processClipCaps(body)
|
||||
case msgMonitorReady:
|
||||
h.processMonitorReady()
|
||||
case msgFormatList:
|
||||
h.processFormatList(body)
|
||||
case msgFormatListResponse:
|
||||
// 远端确认收到本地的 FormatList,无需动作
|
||||
if msgFlags&flagResponseOK == 0 {
|
||||
log.Printf("[cliprdr] Warn: %s", fmt.Sprintln("cliprdr: FormatListResponse FAIL"))
|
||||
}
|
||||
case msgFormatDataRequest:
|
||||
h.processFormatDataRequest(body)
|
||||
case msgFormatDataResponse:
|
||||
h.processFormatDataResponse(body, msgFlags)
|
||||
case msgLockClipdata, msgUnlockClipdata:
|
||||
// 文本模式不处理锁
|
||||
default:
|
||||
log.Printf("[cliprdr] Debug: %s", fmt.Sprintln("cliprdr: unhandled msgType", "msgType", msgType))
|
||||
}
|
||||
}
|
||||
|
||||
// --- CB_CLIP_CAPS(2.2.2.1)-------------------------------------------------
|
||||
|
||||
func (h *Handler) processClipCaps(body []byte) {
|
||||
if len(body) < 4 {
|
||||
return
|
||||
}
|
||||
cCapSets := binary.LittleEndian.Uint16(body[0:2])
|
||||
offset := 4 // cCapabilitySets(2) + pad1(2)
|
||||
for i := 0; i < int(cCapSets); i++ {
|
||||
if offset+4 > len(body) {
|
||||
break
|
||||
}
|
||||
capType := binary.LittleEndian.Uint16(body[offset:])
|
||||
capLen := binary.LittleEndian.Uint16(body[offset+2:])
|
||||
if capType == capsTypeGeneral && capLen >= 12 && offset+12 <= len(body) {
|
||||
generalFlags := binary.LittleEndian.Uint32(body[offset+8:])
|
||||
h.mu.Lock()
|
||||
h.useLongFormatNames = generalFlags&flagUseLongFormatNames != 0
|
||||
useLong := h.useLongFormatNames
|
||||
h.mu.Unlock()
|
||||
log.Printf("[cliprdr] Debug: %s", fmt.Sprintln("cliprdr: server caps", "generalFlags", generalFlags, "longNames", useLong))
|
||||
}
|
||||
offset += int(capLen)
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.serverCapsReceived = true
|
||||
localReady := h.localText != ""
|
||||
ready := h.monitorReady
|
||||
h.mu.Unlock()
|
||||
// 非标准顺序:MonitorReady 先到时,在拿到 caps 后补发 FormatList
|
||||
if ready && localReady {
|
||||
h.sendFormatList()
|
||||
}
|
||||
}
|
||||
|
||||
// --- CB_MONITOR_READY(2.2.2.2)--------------------------------------------
|
||||
|
||||
func (h *Handler) processMonitorReady() {
|
||||
log.Printf("[cliprdr] Debug: %s", fmt.Sprintln("cliprdr: server Monitor Ready"))
|
||||
h.mu.Lock()
|
||||
h.monitorReady = true
|
||||
capsReady := h.serverCapsReceived
|
||||
localReady := h.localText != ""
|
||||
needInitial := !h.initialFormatListSent
|
||||
if needInitial {
|
||||
h.initialFormatListSent = true
|
||||
}
|
||||
h.mu.Unlock()
|
||||
// MS-RDPECLIP §1.3.2.1: server 先发 CB_CLIP_CAPS 再发 CB_MONITOR_READY。
|
||||
// 拿到 caps 才知道是否用 Long Format Names,故 FormatList 等 caps 就绪后再发。
|
||||
h.sendClipCaps()
|
||||
if needInitial && !localReady {
|
||||
h.sendEmptyFormatList()
|
||||
log.Printf("[cliprdr] Debug: %s", fmt.Sprintln("cliprdr: sent initial empty FormatList"))
|
||||
}
|
||||
if capsReady && localReady {
|
||||
h.sendFormatList()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Format List(2.2.3)---------------------------------------------------
|
||||
|
||||
func (h *Handler) sendFormatList() {
|
||||
h.mu.Lock()
|
||||
useLong := h.useLongFormatNames
|
||||
h.initialFormatListSent = true
|
||||
h.mu.Unlock()
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
if useLong {
|
||||
// Long Format Name: formatId(4) + null-terminated UTF-16LE name
|
||||
binary.Write(body, binary.LittleEndian, uint32(cfUnicodeText))
|
||||
binary.Write(body, binary.LittleEndian, uint16(0)) // 空名 = 标准格式
|
||||
binary.Write(body, binary.LittleEndian, uint32(cfText))
|
||||
binary.Write(body, binary.LittleEndian, uint16(0))
|
||||
} else {
|
||||
// Short Format Name: formatId(4) + name[32]
|
||||
binary.Write(body, binary.LittleEndian, uint32(cfUnicodeText))
|
||||
body.Write(make([]byte, 32))
|
||||
binary.Write(body, binary.LittleEndian, uint32(cfText))
|
||||
body.Write(make([]byte, 32))
|
||||
}
|
||||
h.sendPDU(msgFormatList, 0, body.Bytes())
|
||||
}
|
||||
|
||||
func (h *Handler) sendEmptyFormatList() {
|
||||
// 初始化空 FormatList 是 FreeRDP 允许的首包,用来完成剪贴板客户端初始化。// Logan.Li
|
||||
h.sendPDU(msgFormatList, 0, nil)
|
||||
}
|
||||
|
||||
func (h *Handler) processFormatList(body []byte) {
|
||||
formats := h.parseFormatList(body)
|
||||
log.Printf("[cliprdr] Debug: %s", fmt.Sprintln("cliprdr: server FormatList", "formats", formats))
|
||||
|
||||
// 始终回复 OK
|
||||
h.sendPDU(msgFormatListResponse, flagResponseOK, nil)
|
||||
|
||||
// 优先请求 Unicode 文本,其次 ANSI
|
||||
for _, f := range formats {
|
||||
if f == cfUnicodeText {
|
||||
h.sendFormatDataRequest(cfUnicodeText)
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, f := range formats {
|
||||
if f == cfText {
|
||||
h.sendFormatDataRequest(cfText)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) parseFormatList(body []byte) []uint32 {
|
||||
h.mu.Lock()
|
||||
useLong := h.useLongFormatNames
|
||||
h.mu.Unlock()
|
||||
|
||||
var formats []uint32
|
||||
if useLong {
|
||||
// Long: formatId(4) + null-term UTF-16LE name
|
||||
offset := 0
|
||||
for offset+4 <= len(body) {
|
||||
formats = append(formats, binary.LittleEndian.Uint32(body[offset:]))
|
||||
offset += 4
|
||||
// 跳过 name(null-terminated UTF-16LE)
|
||||
for offset+1 < len(body) {
|
||||
if body[offset] == 0 && body[offset+1] == 0 {
|
||||
offset += 2
|
||||
break
|
||||
}
|
||||
offset += 2
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Short: formatId(4) + name[32] = 36 字节/项
|
||||
for offset := 0; offset+36 <= len(body); offset += 36 {
|
||||
formats = append(formats, binary.LittleEndian.Uint32(body[offset:]))
|
||||
}
|
||||
}
|
||||
return formats
|
||||
}
|
||||
|
||||
// --- Format Data Request / Response(2.2.5)-------------------------------
|
||||
|
||||
func (h *Handler) sendFormatDataRequest(formatID uint32) {
|
||||
b := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(b, formatID)
|
||||
h.sendPDU(msgFormatDataRequest, 0, b)
|
||||
log.Printf("[cliprdr] Debug: %s", fmt.Sprintln("cliprdr: sent FormatDataRequest", "formatID", formatID))
|
||||
}
|
||||
|
||||
func (h *Handler) processFormatDataRequest(body []byte) {
|
||||
if len(body) < 4 {
|
||||
h.sendPDU(msgFormatDataResponse, flagResponseFail, nil)
|
||||
return
|
||||
}
|
||||
requestedFormat := binary.LittleEndian.Uint32(body[0:4])
|
||||
h.mu.Lock()
|
||||
text := h.localText
|
||||
h.mu.Unlock()
|
||||
log.Printf("[cliprdr] Debug: %s", fmt.Sprintln("cliprdr: server requests format", "formatID", requestedFormat, "localLen", len(text)))
|
||||
|
||||
switch requestedFormat {
|
||||
case cfUnicodeText:
|
||||
encoded := encodeUTF16LE(text + "\x00")
|
||||
h.sendPDU(msgFormatDataResponse, flagResponseOK, encoded)
|
||||
case cfText:
|
||||
h.sendPDU(msgFormatDataResponse, flagResponseOK, []byte(text+"\x00"))
|
||||
default:
|
||||
h.sendPDU(msgFormatDataResponse, flagResponseFail, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) processFormatDataResponse(body []byte, msgFlags uint16) {
|
||||
if msgFlags&flagResponseOK == 0 {
|
||||
log.Printf("[cliprdr] Warn: %s", fmt.Sprintln("cliprdr: FormatDataResponse FAIL"))
|
||||
return
|
||||
}
|
||||
// 尝试按 UTF-16LE 解码(CF_UNICODETEXT)
|
||||
text := decodeUTF16LE(body)
|
||||
text = strings.TrimRight(text, "\x00")
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
log.Printf("[cliprdr] Debug: %s", fmt.Sprintln("cliprdr: remote clipboard text", "len", len(text)))
|
||||
// 标记抑制下一次本地变更(避免 echo 回环:远端→本地触发的本地变更不再推回远端)
|
||||
h.mu.Lock()
|
||||
h.suppressNextChange = true
|
||||
cb := h.onRemote
|
||||
h.mu.Unlock()
|
||||
if cb != nil {
|
||||
cb(text)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Send helpers --------------------------------------------------------
|
||||
|
||||
func (h *Handler) sendClipCaps() {
|
||||
// General capability set: type(2) + length(2) + version(4) + flags(4) = 12
|
||||
cap := &bytes.Buffer{}
|
||||
binary.Write(cap, binary.LittleEndian, uint16(capsTypeGeneral))
|
||||
binary.Write(cap, binary.LittleEndian, uint16(12))
|
||||
binary.Write(cap, binary.LittleEndian, uint32(capsVersion2))
|
||||
binary.Write(cap, binary.LittleEndian, uint32(flagUseLongFormatNames))
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
binary.Write(body, binary.LittleEndian, uint16(1)) // cCapabilitySets
|
||||
binary.Write(body, binary.LittleEndian, uint16(0)) // pad1
|
||||
body.Write(cap.Bytes())
|
||||
|
||||
h.sendPDU(msgClipCaps, 0, body.Bytes())
|
||||
}
|
||||
|
||||
func (h *Handler) sendPDU(msgType, msgFlags uint16, body []byte) {
|
||||
h.mu.Lock()
|
||||
sender := h.sender
|
||||
chID := h.channelID
|
||||
h.mu.Unlock()
|
||||
if sender == nil {
|
||||
return
|
||||
}
|
||||
buf := &bytes.Buffer{}
|
||||
binary.Write(buf, binary.LittleEndian, msgType)
|
||||
binary.Write(buf, binary.LittleEndian, msgFlags)
|
||||
binary.Write(buf, binary.LittleEndian, uint32(len(body)))
|
||||
buf.Write(body)
|
||||
if err := sender.SendChannelPDU(chID, buf.Bytes()); err != nil {
|
||||
log.Printf("[cliprdr] Warn: %s", fmt.Sprintln("cliprdr: send PDU failed", "msgType", msgType, "err", err))
|
||||
}
|
||||
}
|
||||
|
||||
// --- UTF-16LE helpers ----------------------------------------------------
|
||||
|
||||
func decodeUTF16LE(b []byte) string {
|
||||
if len(b) < 2 {
|
||||
return ""
|
||||
}
|
||||
if len(b)%2 != 0 {
|
||||
b = b[:len(b)-1]
|
||||
}
|
||||
u16 := make([]uint16, len(b)/2)
|
||||
for i := range u16 {
|
||||
u16[i] = binary.LittleEndian.Uint16(b[i*2:])
|
||||
}
|
||||
return string(utf16.Decode(u16))
|
||||
}
|
||||
|
||||
func encodeUTF16LE(s string) []byte {
|
||||
u16 := utf16.Encode([]rune(s))
|
||||
b := make([]byte, len(u16)*2)
|
||||
for i, v := range u16 {
|
||||
binary.LittleEndian.PutUint16(b[i*2:], v)
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Package cliprdr 实现 RDP 剪贴板虚拟通道扩展(MS-RDPECLIP)的客户端侧,
|
||||
// 支持浏览器与远端 Windows 之间的双向文本剪贴板同步。// Logan.Li
|
||||
//
|
||||
// 协议序列(MS-RDPECLIP §1.3.2):
|
||||
//
|
||||
// 初始化:
|
||||
// Server ── CB_CLIP_CAPS ──► Client
|
||||
// Server ── CB_MONITOR_READY ──► Client
|
||||
// Client ── CB_CLIP_CAPS ──► Server
|
||||
// Client ── CB_FORMAT_LIST ──► Server
|
||||
// Server ── CB_FORMAT_LIST_RESPONSE ──► Client
|
||||
//
|
||||
// 远端→本地(远端复制,本地粘贴):
|
||||
// Server ── CB_FORMAT_LIST ──► Client (远端声明可用格式)
|
||||
// Client ── CB_FORMAT_LIST_RESPONSE ──► Server
|
||||
// Client ── CB_FORMAT_DATA_REQUEST ──► Server (本地请求文本数据)
|
||||
// Server ── CB_FORMAT_DATA_RESPONSE ──► Client (远端返回剪贴板内容)
|
||||
//
|
||||
// 本地→远端(本地复制,远端粘贴):
|
||||
// Client ── CB_FORMAT_LIST ──► Server (本地声明可用格式)
|
||||
// Server ── CB_FORMAT_DATA_REQUEST ──► Client (远端请求文本数据)
|
||||
// Client ── CB_FORMAT_DATA_RESPONSE ──► Server (本地返回剪贴板内容)
|
||||
package cliprdr
|
||||
|
||||
// CLIPRDR PDU msgType(MS-RDPECLIP 2.2.1)
|
||||
const (
|
||||
msgMonitorReady = 0x0001
|
||||
msgFormatList = 0x0002
|
||||
msgFormatListResponse = 0x0003
|
||||
msgFormatDataRequest = 0x0004
|
||||
msgFormatDataResponse = 0x0005
|
||||
msgTempDirectory = 0x0006
|
||||
msgClipCaps = 0x0007
|
||||
msgFilecontentsRequest = 0x0008
|
||||
msgFilecontentsResponse = 0x0009
|
||||
msgLockClipdata = 0x000A
|
||||
msgUnlockClipdata = 0x000B
|
||||
)
|
||||
|
||||
// msgFlags(MS-RDPECLIP 2.2.2)
|
||||
const (
|
||||
flagResponseOK = 0x0001
|
||||
flagResponseFail = 0x0002
|
||||
flagAsciiNames = 0x0004
|
||||
)
|
||||
|
||||
// 通用能力集 GeneralFlags(MS-RDPECLIP 2.2.2.1.1.1.1)
|
||||
const (
|
||||
capsTypeGeneral = 0x0001
|
||||
capsVersion2 = 0x00000002
|
||||
flagUseLongFormatNames = 0x00000002
|
||||
)
|
||||
|
||||
// Windows 标准剪贴板格式 ID(WinUser.h)
|
||||
const (
|
||||
cfText = 1 // CF_TEXT ANSI 文本
|
||||
cfUnicodeText = 13 // CF_UNICODETEXT UTF-16 文本
|
||||
)
|
||||
|
||||
// 通道声明常量(供协议栈在 MCS Connect-Initial 里声明通道时复用,避免魔法值分散)
|
||||
const (
|
||||
ChannelName = "cliprdr"
|
||||
// ChannelOptions 与 grdp/FreeRDP 保持一致:INITIALIZED | ENCRYPT_RDP | SHOW_PROTOCOL
|
||||
ChannelOptions = 0x80000000 | 0x40000000 | 0x00200000
|
||||
)
|
||||
@@ -0,0 +1,602 @@
|
||||
package rdpdr
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"log"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf16"
|
||||
)
|
||||
|
||||
const (
|
||||
irpCreate uint32 = 0x00000000
|
||||
irpClose uint32 = 0x00000002
|
||||
irpRead uint32 = 0x00000003
|
||||
irpWrite uint32 = 0x00000004
|
||||
irpQueryInfo uint32 = 0x00000005
|
||||
irpSetInfo uint32 = 0x00000006
|
||||
irpDeviceCtrl uint32 = 0x0000000e
|
||||
irpQueryVolume uint32 = 0x0000000a
|
||||
irpDirControl uint32 = 0x0000000c
|
||||
irpLockControl uint32 = 0x00000011
|
||||
|
||||
irpMnQueryDir uint32 = 0x00000001
|
||||
irpMnNotifyChangeDir uint32 = 0x00000002
|
||||
|
||||
statusSuccess uint32 = 0x00000000
|
||||
statusNoMoreFiles uint32 = 0x80000006
|
||||
statusAccessDenied uint32 = 0xc0000022
|
||||
statusObjectNameNotFound uint32 = 0xc0000034
|
||||
statusNoSuchFile uint32 = 0xc000000f
|
||||
statusFileIsDirectory uint32 = 0xc00000ba
|
||||
statusNotSupported uint32 = 0xc00000bb
|
||||
statusInvalidDeviceReq uint32 = 0xc0000010
|
||||
statusInvalidParameter uint32 = 0xc000000d
|
||||
statusEndOfFile uint32 = 0xc0000011
|
||||
|
||||
fileOpen uint32 = 0x00000001
|
||||
fileCreate uint32 = 0x00000002
|
||||
fileOpenIf uint32 = 0x00000003
|
||||
fileOverwrite uint32 = 0x00000004
|
||||
fileOverwriteIf uint32 = 0x00000005
|
||||
|
||||
fileDirectoryFile uint32 = 0x00000001
|
||||
fileNonDirectoryFile uint32 = 0x00000040
|
||||
|
||||
fileDirectoryInformation uint32 = 1
|
||||
fileFullDirectoryInformation uint32 = 2
|
||||
fileBothDirectoryInformation uint32 = 3
|
||||
fileBasicInformation uint32 = 4
|
||||
fileStandardInformation uint32 = 5
|
||||
fileInternalInformation uint32 = 6
|
||||
fileEaInformation uint32 = 7
|
||||
fileAccessInformation uint32 = 8
|
||||
fileNameInformation uint32 = 9
|
||||
fileNamesInformation uint32 = 12
|
||||
filePositionInformation uint32 = 14
|
||||
fileModeInformation uint32 = 16
|
||||
fileAlignmentInformation uint32 = 17
|
||||
fileEndOfFileInformation uint32 = 20
|
||||
fileNetworkOpenInformation uint32 = 34
|
||||
fileAttributeTagInformation uint32 = 35
|
||||
|
||||
fileFsVolumeInformation uint32 = 1
|
||||
fileFsSizeInformation uint32 = 3
|
||||
fileFsDeviceInformation uint32 = 4
|
||||
fileFsAttributeInformation uint32 = 5
|
||||
fileFsFullSizeInformation uint32 = 7
|
||||
|
||||
fileAttrDirectory uint32 = 0x00000010
|
||||
fileAttrNormal uint32 = 0x00000080
|
||||
fileAttrArchive uint32 = 0x00000020
|
||||
|
||||
fileOpened byte = 0x01
|
||||
fileCreated byte = 0x02
|
||||
fileOverwritten byte = 0x03
|
||||
|
||||
downloadDirName = "Download"
|
||||
uploadDirName = "Upload"
|
||||
)
|
||||
|
||||
const windowsFiletimeOffset = 116444736000000000
|
||||
|
||||
type ioRequest struct {
|
||||
deviceID uint32
|
||||
fileID uint32
|
||||
completionID uint32
|
||||
majorFn uint32
|
||||
minorFn uint32
|
||||
payload []byte
|
||||
}
|
||||
|
||||
type openFile struct {
|
||||
path string
|
||||
name string
|
||||
isDir bool
|
||||
data []byte
|
||||
written bool
|
||||
dirEntries []dirEntry
|
||||
dirIndex int
|
||||
}
|
||||
|
||||
type dirEntry struct {
|
||||
name string
|
||||
isDir bool
|
||||
size uint64
|
||||
}
|
||||
|
||||
func (h *Handler) handleIORequest(body []byte) {
|
||||
if len(body) < 20 {
|
||||
return
|
||||
}
|
||||
req := ioRequest{
|
||||
deviceID: binary.LittleEndian.Uint32(body[0:4]),
|
||||
fileID: binary.LittleEndian.Uint32(body[4:8]),
|
||||
completionID: binary.LittleEndian.Uint32(body[8:12]),
|
||||
majorFn: binary.LittleEndian.Uint32(body[12:16]),
|
||||
minorFn: binary.LittleEndian.Uint32(body[16:20]),
|
||||
payload: body[20:],
|
||||
}
|
||||
log.Printf("[rdpdr] io request device=%d file=%d completion=%d major=0x%x minor=0x%x payload=%d", req.deviceID, req.fileID, req.completionID, req.majorFn, req.minorFn, len(req.payload))
|
||||
switch req.majorFn {
|
||||
case irpCreate:
|
||||
h.handleCreate(req)
|
||||
case irpClose:
|
||||
h.handleClose(req)
|
||||
case irpRead:
|
||||
h.handleRead(req)
|
||||
case irpWrite:
|
||||
h.handleWrite(req)
|
||||
case irpQueryInfo:
|
||||
h.handleQueryInfo(req)
|
||||
case irpSetInfo:
|
||||
h.handleSetInfo(req)
|
||||
case irpDeviceCtrl:
|
||||
h.handleDeviceControl(req)
|
||||
case irpQueryVolume:
|
||||
h.handleQueryVolume(req)
|
||||
case irpDirControl:
|
||||
if req.minorFn == irpMnQueryDir {
|
||||
h.handleQueryDirectory(req)
|
||||
return
|
||||
}
|
||||
if req.minorFn == irpMnNotifyChangeDir {
|
||||
h.handleNotifyChangeDirectory(req)
|
||||
return
|
||||
}
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusNotSupported, nil)
|
||||
case irpLockControl:
|
||||
h.handleLockControl(req)
|
||||
default:
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusNotSupported, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleCreate(req ioRequest) {
|
||||
var out [5]byte
|
||||
if len(req.payload) < 32 {
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusInvalidParameter, out[:])
|
||||
return
|
||||
}
|
||||
disposition := binary.LittleEndian.Uint32(req.payload[20:24])
|
||||
options := binary.LittleEndian.Uint32(req.payload[24:28])
|
||||
pathLen := binary.LittleEndian.Uint32(req.payload[28:32])
|
||||
if uint32(len(req.payload)) < 32+pathLen {
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusInvalidParameter, out[:])
|
||||
return
|
||||
}
|
||||
vpath := normalizeVirtualPath(decodeUTF16(req.payload[32 : 32+pathLen]))
|
||||
isDir := options&fileDirectoryFile != 0
|
||||
kind, name := classifyVirtualPath(vpath)
|
||||
if kind == "missing" {
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusObjectNameNotFound, out[:])
|
||||
return
|
||||
}
|
||||
switch kind {
|
||||
case "root", "download", "upload":
|
||||
isDir = true
|
||||
case "file", "upload_file":
|
||||
// Explorer 拖拽复制时可能带上目录相关 CreateOptions,虚拟盘文件必须按真实文件打开。// Logan.Li
|
||||
isDir = false
|
||||
}
|
||||
var initialData []byte
|
||||
if kind == "upload_file" {
|
||||
data, ok := h.getUpload(name)
|
||||
if !ok {
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusObjectNameNotFound, out[:])
|
||||
return
|
||||
}
|
||||
initialData = data
|
||||
}
|
||||
if isDir && options&fileNonDirectoryFile != 0 {
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusFileIsDirectory, out[:])
|
||||
return
|
||||
}
|
||||
if kind == "file" && disposition == fileOpen {
|
||||
// 虚拟 Download 目录只负责接收远端写入,读取历史文件留给上传目录实现。// Logan.Li
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusObjectNameNotFound, out[:])
|
||||
return
|
||||
}
|
||||
info := fileOpened
|
||||
if disposition == fileCreate {
|
||||
info = fileCreated
|
||||
}
|
||||
if disposition == fileOverwrite || disposition == fileOverwriteIf {
|
||||
info = fileOverwritten
|
||||
}
|
||||
fileID := h.storeOpenFile(&openFile{path: vpath, name: name, isDir: isDir, data: initialData})
|
||||
binary.LittleEndian.PutUint32(out[0:4], fileID)
|
||||
out[4] = info
|
||||
log.Printf("[rdpdr] create path=%q fileID=%d dir=%t", vpath, fileID, isDir)
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out[:])
|
||||
}
|
||||
|
||||
func (h *Handler) handleClose(req ioRequest) {
|
||||
file, ok := h.removeOpenFile(req.fileID)
|
||||
if ok && !file.isDir && file.written && len(file.data) > 0 && h.onDownload != nil {
|
||||
data := append([]byte(nil), file.data...)
|
||||
h.onDownload(Download{Name: file.name, Data: data})
|
||||
log.Printf("[rdpdr] download captured name=%s size=%d", file.name, len(data))
|
||||
}
|
||||
var out [5]byte
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out[:])
|
||||
}
|
||||
|
||||
func (h *Handler) handleRead(req ioRequest) {
|
||||
if len(req.payload) < 20 {
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusInvalidParameter, nil)
|
||||
return
|
||||
}
|
||||
length := int(binary.LittleEndian.Uint32(req.payload[0:4]))
|
||||
offset := int(binary.LittleEndian.Uint64(req.payload[4:12]))
|
||||
file, ok := h.getOpenFile(req.fileID)
|
||||
if !ok || file.isDir {
|
||||
log.Printf("[rdpdr] read missing file=%d offset=%d length=%d", req.fileID, offset, length)
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusNoSuchFile, nil)
|
||||
return
|
||||
}
|
||||
if length <= 0 {
|
||||
var out [4]byte
|
||||
log.Printf("[rdpdr] read zero file=%d path=%q offset=%d", req.fileID, file.path, offset)
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out[:])
|
||||
return
|
||||
}
|
||||
if offset >= len(file.data) {
|
||||
var out [4]byte
|
||||
// Explorer 复制虚拟盘文件时更稳定地接受 0 字节成功读作为 EOF。// Logan.Li
|
||||
log.Printf("[rdpdr] read eof file=%d path=%q offset=%d size=%d", req.fileID, file.path, offset, len(file.data))
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out[:])
|
||||
return
|
||||
}
|
||||
end := offset + length
|
||||
if end > len(file.data) {
|
||||
end = len(file.data)
|
||||
}
|
||||
out := make([]byte, 4+end-offset)
|
||||
binary.LittleEndian.PutUint32(out[0:4], uint32(end-offset))
|
||||
copy(out[4:], file.data[offset:end])
|
||||
log.Printf("[rdpdr] read file=%d path=%q offset=%d requested=%d returned=%d size=%d", req.fileID, file.path, offset, length, end-offset, len(file.data))
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out)
|
||||
}
|
||||
|
||||
func (h *Handler) handleWrite(req ioRequest) {
|
||||
if len(req.payload) < 32 {
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusInvalidParameter, nil)
|
||||
return
|
||||
}
|
||||
length := int(binary.LittleEndian.Uint32(req.payload[0:4]))
|
||||
offset := int(binary.LittleEndian.Uint64(req.payload[4:12]))
|
||||
chunk := req.payload[32:]
|
||||
if length > len(chunk) {
|
||||
length = len(chunk)
|
||||
}
|
||||
file, ok := h.getOpenFile(req.fileID)
|
||||
if !ok || file.isDir {
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusNoSuchFile, nil)
|
||||
return
|
||||
}
|
||||
need := offset + length
|
||||
if need > len(file.data) {
|
||||
next := make([]byte, need)
|
||||
copy(next, file.data)
|
||||
file.data = next
|
||||
}
|
||||
copy(file.data[offset:need], chunk[:length])
|
||||
file.written = true
|
||||
var out [5]byte
|
||||
binary.LittleEndian.PutUint32(out[0:4], uint32(length))
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out[:])
|
||||
}
|
||||
|
||||
func (h *Handler) handleQueryInfo(req ioRequest) {
|
||||
if len(req.payload) < 4 {
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusInvalidParameter, nil)
|
||||
return
|
||||
}
|
||||
infoClass := binary.LittleEndian.Uint32(req.payload[0:4])
|
||||
file, ok := h.getOpenFile(req.fileID)
|
||||
if !ok {
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusNoSuchFile, nil)
|
||||
return
|
||||
}
|
||||
attrs := fileAttrArchive
|
||||
if file.isDir {
|
||||
attrs = fileAttrDirectory
|
||||
}
|
||||
size := uint64(len(file.data))
|
||||
log.Printf("[rdpdr] query info file=%d class=%d path=%q dir=%t size=%d", req.fileID, infoClass, file.path, file.isDir, size)
|
||||
switch infoClass {
|
||||
case fileBasicInformation:
|
||||
out := make([]byte, 40)
|
||||
binary.LittleEndian.PutUint32(out[0:4], 36)
|
||||
writeFileTimes(out[4:36])
|
||||
binary.LittleEndian.PutUint32(out[36:40], attrs)
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out)
|
||||
case fileStandardInformation:
|
||||
out := make([]byte, 26)
|
||||
binary.LittleEndian.PutUint32(out[0:4], 22)
|
||||
binary.LittleEndian.PutUint64(out[4:12], align4096(size))
|
||||
binary.LittleEndian.PutUint64(out[12:20], size)
|
||||
binary.LittleEndian.PutUint32(out[20:24], 1)
|
||||
if file.isDir {
|
||||
out[25] = 1
|
||||
}
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out)
|
||||
case fileAttributeTagInformation:
|
||||
out := make([]byte, 12)
|
||||
binary.LittleEndian.PutUint32(out[0:4], 8)
|
||||
binary.LittleEndian.PutUint32(out[4:8], attrs)
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out)
|
||||
case fileInternalInformation:
|
||||
out := make([]byte, 12)
|
||||
binary.LittleEndian.PutUint32(out[0:4], 8)
|
||||
binary.LittleEndian.PutUint64(out[4:12], uint64(req.fileID))
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out)
|
||||
case fileEaInformation:
|
||||
out := make([]byte, 8)
|
||||
binary.LittleEndian.PutUint32(out[0:4], 4)
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out)
|
||||
case fileAccessInformation, fileModeInformation, fileAlignmentInformation:
|
||||
out := make([]byte, 8)
|
||||
binary.LittleEndian.PutUint32(out[0:4], 4)
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out)
|
||||
case fileNameInformation:
|
||||
name := encodeUTF16(fileDisplayName(file))
|
||||
out := make([]byte, 8+len(name))
|
||||
binary.LittleEndian.PutUint32(out[0:4], uint32(4+len(name)))
|
||||
binary.LittleEndian.PutUint32(out[4:8], uint32(len(name)))
|
||||
copy(out[8:], name)
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out)
|
||||
case filePositionInformation:
|
||||
out := make([]byte, 12)
|
||||
binary.LittleEndian.PutUint32(out[0:4], 8)
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out)
|
||||
case fileNetworkOpenInformation:
|
||||
out := make([]byte, 60)
|
||||
binary.LittleEndian.PutUint32(out[0:4], 56)
|
||||
writeFileTimes(out[4:36])
|
||||
binary.LittleEndian.PutUint64(out[36:44], align4096(size))
|
||||
binary.LittleEndian.PutUint64(out[44:52], size)
|
||||
binary.LittleEndian.PutUint32(out[52:56], attrs)
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out)
|
||||
default:
|
||||
log.Printf("[rdpdr] query info unsupported class=%d file=%d path=%q", infoClass, req.fileID, file.path)
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusNotSupported, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleSetInfo(req ioRequest) {
|
||||
var out [4]byte
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out[:])
|
||||
}
|
||||
|
||||
func (h *Handler) handleQueryVolume(req ioRequest) {
|
||||
if len(req.payload) < 4 {
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusInvalidParameter, nil)
|
||||
return
|
||||
}
|
||||
infoClass := binary.LittleEndian.Uint32(req.payload[0:4])
|
||||
switch infoClass {
|
||||
case fileFsVolumeInformation:
|
||||
label := encodeUTF16(h.driveName)
|
||||
out := make([]byte, 21+len(label))
|
||||
binary.LittleEndian.PutUint32(out[0:4], uint32(17+len(label)))
|
||||
binary.LittleEndian.PutUint64(out[4:12], filetimeNow())
|
||||
binary.LittleEndian.PutUint32(out[12:16], 0x41494f50)
|
||||
binary.LittleEndian.PutUint32(out[16:20], uint32(len(label)))
|
||||
copy(out[21:], label)
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out)
|
||||
case fileFsSizeInformation:
|
||||
out := make([]byte, 28)
|
||||
binary.LittleEndian.PutUint32(out[0:4], 24)
|
||||
binary.LittleEndian.PutUint64(out[4:12], 1024*1024)
|
||||
binary.LittleEndian.PutUint64(out[12:20], 1024*1024)
|
||||
binary.LittleEndian.PutUint32(out[20:24], 8)
|
||||
binary.LittleEndian.PutUint32(out[24:28], 512)
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out)
|
||||
case fileFsDeviceInformation:
|
||||
out := make([]byte, 12)
|
||||
binary.LittleEndian.PutUint32(out[0:4], 8)
|
||||
binary.LittleEndian.PutUint32(out[4:8], 0x00000007)
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out)
|
||||
case fileFsAttributeInformation:
|
||||
fsName := encodeUTF16("AIOPS")
|
||||
out := make([]byte, 16+len(fsName))
|
||||
binary.LittleEndian.PutUint32(out[0:4], uint32(12+len(fsName)))
|
||||
binary.LittleEndian.PutUint32(out[4:8], 0x00000007)
|
||||
binary.LittleEndian.PutUint32(out[8:12], 255)
|
||||
binary.LittleEndian.PutUint32(out[12:16], uint32(len(fsName)))
|
||||
copy(out[16:], fsName)
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out)
|
||||
case fileFsFullSizeInformation:
|
||||
out := make([]byte, 36)
|
||||
binary.LittleEndian.PutUint32(out[0:4], 32)
|
||||
binary.LittleEndian.PutUint64(out[4:12], 1024*1024)
|
||||
binary.LittleEndian.PutUint64(out[12:20], 1024*1024)
|
||||
binary.LittleEndian.PutUint64(out[20:28], 1024*1024)
|
||||
binary.LittleEndian.PutUint32(out[28:32], 8)
|
||||
binary.LittleEndian.PutUint32(out[32:36], 512)
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out)
|
||||
default:
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusNotSupported, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleDeviceControl(req ioRequest) {
|
||||
var out [4]byte
|
||||
ioctl := uint32(0)
|
||||
if len(req.payload) >= 12 {
|
||||
ioctl = binary.LittleEndian.Uint32(req.payload[8:12])
|
||||
}
|
||||
// 资源管理器复制时会探测若干 FSCTL;明确返回“不支持”比 invalid parameter 更接近本地盘行为。// Logan.Li
|
||||
log.Printf("[rdpdr] device control file=%d ioctl=0x%x payload=%d", req.fileID, ioctl, len(req.payload))
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusInvalidDeviceReq, out[:])
|
||||
}
|
||||
|
||||
func (h *Handler) handleNotifyChangeDirectory(req ioRequest) {
|
||||
// Windows 资源管理器会长期挂起目录变更通知;立即成功返回会触发重复刷新和“地址无效”。// Logan.Li
|
||||
log.Printf("[rdpdr] notify change pending file=%d", req.fileID)
|
||||
}
|
||||
|
||||
func (h *Handler) handleLockControl(req ioRequest) {
|
||||
var out [4]byte
|
||||
// 上传暂存文件是只读内存文件,锁请求直接成功,避免 Explorer 复制流程中断。// Logan.Li
|
||||
log.Printf("[rdpdr] lock control file=%d payload=%d", req.fileID, len(req.payload))
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, out[:])
|
||||
}
|
||||
|
||||
func (h *Handler) handleQueryDirectory(req ioRequest) {
|
||||
if len(req.payload) < 32 {
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusInvalidParameter, nil)
|
||||
return
|
||||
}
|
||||
infoClass := binary.LittleEndian.Uint32(req.payload[0:4])
|
||||
initial := req.payload[4] != 0
|
||||
file, ok := h.getOpenFile(req.fileID)
|
||||
if !ok || !file.isDir {
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusNoSuchFile, nil)
|
||||
return
|
||||
}
|
||||
if initial {
|
||||
file.dirIndex = 0
|
||||
if file.path == "" {
|
||||
file.dirEntries = []dirEntry{{name: "."}, {name: ".."}, {name: downloadDirName, isDir: true}, {name: uploadDirName, isDir: true}}
|
||||
} else if strings.EqualFold(file.path, uploadDirName) {
|
||||
file.dirEntries = append([]dirEntry{{name: "."}, {name: ".."}}, h.listUploads()...)
|
||||
} else {
|
||||
file.dirEntries = []dirEntry{{name: "."}, {name: ".."}}
|
||||
}
|
||||
}
|
||||
log.Printf("[rdpdr] query dir file=%d class=%d path=%q initial=%t index=%d entries=%d", req.fileID, infoClass, file.path, initial, file.dirIndex, len(file.dirEntries))
|
||||
if file.dirIndex >= len(file.dirEntries) {
|
||||
var out [5]byte
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusNoMoreFiles, out[:])
|
||||
return
|
||||
}
|
||||
entry := file.dirEntries[file.dirIndex]
|
||||
file.dirIndex++
|
||||
h.sendIOCompletion(req.deviceID, req.completionID, statusSuccess, buildDirInfo(infoClass, entry))
|
||||
}
|
||||
|
||||
func buildDirInfo(infoClass uint32, entry dirEntry) []byte {
|
||||
name := encodeUTF16(entry.name)
|
||||
nameWithNull := append(append([]byte(nil), name...), 0, 0)
|
||||
attrs := fileAttrNormal
|
||||
if entry.isDir || entry.name == "." || entry.name == ".." {
|
||||
attrs = fileAttrDirectory
|
||||
}
|
||||
if infoClass == fileNamesInformation {
|
||||
out := make([]byte, 16+len(nameWithNull))
|
||||
binary.LittleEndian.PutUint32(out[0:4], uint32(12+len(nameWithNull)))
|
||||
binary.LittleEndian.PutUint32(out[12:16], uint32(len(nameWithNull)))
|
||||
copy(out[16:], nameWithNull)
|
||||
return out
|
||||
}
|
||||
fixed := 64
|
||||
if infoClass == fileFullDirectoryInformation {
|
||||
fixed = 68
|
||||
}
|
||||
if infoClass == fileBothDirectoryInformation {
|
||||
fixed = 93
|
||||
}
|
||||
out := make([]byte, 4+fixed+len(nameWithNull))
|
||||
binary.LittleEndian.PutUint32(out[0:4], uint32(fixed+len(nameWithNull)))
|
||||
writeFileTimes(out[12:44])
|
||||
binary.LittleEndian.PutUint64(out[44:52], entry.size)
|
||||
binary.LittleEndian.PutUint64(out[52:60], align4096(entry.size))
|
||||
binary.LittleEndian.PutUint32(out[60:64], attrs)
|
||||
binary.LittleEndian.PutUint32(out[64:68], uint32(len(nameWithNull)))
|
||||
copy(out[4+fixed:], nameWithNull)
|
||||
return out
|
||||
}
|
||||
|
||||
func fileDisplayName(file *openFile) string {
|
||||
if file.name != "" {
|
||||
return file.name
|
||||
}
|
||||
if file.path != "" {
|
||||
parts := strings.Split(file.path, "\\")
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
return "\\"
|
||||
}
|
||||
|
||||
func classifyVirtualPath(vpath string) (string, string) {
|
||||
if vpath == "" {
|
||||
return "root", ""
|
||||
}
|
||||
parts := strings.Split(vpath, "\\")
|
||||
if len(parts) == 1 && strings.EqualFold(parts[0], downloadDirName) {
|
||||
return "download", downloadDirName
|
||||
}
|
||||
if len(parts) == 1 && strings.EqualFold(parts[0], uploadDirName) {
|
||||
return "upload", uploadDirName
|
||||
}
|
||||
if len(parts) >= 2 && strings.EqualFold(parts[0], downloadDirName) {
|
||||
name := parts[len(parts)-1]
|
||||
if name == "" || name == "." || name == ".." {
|
||||
return "missing", ""
|
||||
}
|
||||
return "file", name
|
||||
}
|
||||
if len(parts) >= 2 && strings.EqualFold(parts[0], uploadDirName) {
|
||||
name := parts[len(parts)-1]
|
||||
if name == "" || name == "." || name == ".." {
|
||||
return "missing", ""
|
||||
}
|
||||
return "upload_file", name
|
||||
}
|
||||
return "missing", ""
|
||||
}
|
||||
|
||||
func normalizeVirtualPath(raw string) string {
|
||||
clean := strings.Trim(strings.ReplaceAll(raw, "/", "\\"), "\\\x00 ")
|
||||
if clean == "" || clean == "." {
|
||||
return ""
|
||||
}
|
||||
parts := strings.FieldsFunc(clean, func(r rune) bool { return r == '\\' })
|
||||
safe := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part == "" || part == "." || part == ".." {
|
||||
continue
|
||||
}
|
||||
safe = append(safe, part)
|
||||
}
|
||||
return strings.ReplaceAll(path.Clean(strings.Join(safe, "/")), "/", "\\")
|
||||
}
|
||||
|
||||
func encodeUTF16(s string) []byte {
|
||||
u16 := utf16.Encode([]rune(s))
|
||||
out := make([]byte, len(u16)*2)
|
||||
for i, v := range u16 {
|
||||
binary.LittleEndian.PutUint16(out[i*2:], v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func decodeUTF16(data []byte) string {
|
||||
u16 := make([]uint16, len(data)/2)
|
||||
for i := range u16 {
|
||||
u16[i] = binary.LittleEndian.Uint16(data[i*2:])
|
||||
}
|
||||
for len(u16) > 0 && u16[len(u16)-1] == 0 {
|
||||
u16 = u16[:len(u16)-1]
|
||||
}
|
||||
return string(utf16.Decode(u16))
|
||||
}
|
||||
|
||||
func writeFileTimes(out []byte) {
|
||||
ft := filetimeNow()
|
||||
for i := 0; i+8 <= len(out); i += 8 {
|
||||
binary.LittleEndian.PutUint64(out[i:i+8], ft)
|
||||
}
|
||||
}
|
||||
|
||||
func filetimeNow() uint64 {
|
||||
return uint64(time.Now().UnixNano()/100) + windowsFiletimeOffset
|
||||
}
|
||||
|
||||
func align4096(v uint64) uint64 {
|
||||
return (v + 4095) &^ 4095
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package rdpdr
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type captureSender struct {
|
||||
data [][]byte
|
||||
}
|
||||
|
||||
func (s *captureSender) SendChannelPDU(_ uint16, data []byte) error {
|
||||
s.data = append(s.data, append([]byte(nil), data...))
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestClassifyVirtualPathUploadFile(t *testing.T) {
|
||||
kind, name := classifyVirtualPath(`Upload\php.ini`)
|
||||
if kind != "upload_file" || name != "php.ini" {
|
||||
t.Fatalf("Upload file should be classified as upload_file, got kind=%q name=%q", kind, name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCreateUploadFileOpensAsFile(t *testing.T) {
|
||||
sender := &captureSender{}
|
||||
handler := NewHandler("goclient", "AIOps")
|
||||
handler.Attach(sender, 1004)
|
||||
handler.AddUpload("php.ini", []byte("ok"))
|
||||
|
||||
encodedPath := encodeUTF16(`Upload\php.ini`)
|
||||
payload := make([]byte, 32+len(encodedPath))
|
||||
binary.LittleEndian.PutUint32(payload[20:24], fileOpen)
|
||||
binary.LittleEndian.PutUint32(payload[24:28], fileDirectoryFile)
|
||||
binary.LittleEndian.PutUint32(payload[28:32], uint32(len(encodedPath)))
|
||||
copy(payload[32:], encodedPath)
|
||||
|
||||
handler.handleCreate(ioRequest{
|
||||
deviceID: 1,
|
||||
completionID: 7,
|
||||
majorFn: irpCreate,
|
||||
payload: payload,
|
||||
})
|
||||
if len(sender.data) == 0 {
|
||||
t.Fatal("expected create completion")
|
||||
}
|
||||
completion := sender.data[len(sender.data)-1]
|
||||
if len(completion) < 21 {
|
||||
t.Fatalf("completion too short: %d", len(completion))
|
||||
}
|
||||
if status := binary.LittleEndian.Uint32(completion[12:16]); status != statusSuccess {
|
||||
t.Fatalf("expected success, got status=0x%08x", status)
|
||||
}
|
||||
fileID := binary.LittleEndian.Uint32(completion[16:20])
|
||||
file, ok := handler.getOpenFile(fileID)
|
||||
if !ok {
|
||||
t.Fatalf("open file %d not stored", fileID)
|
||||
}
|
||||
if file.isDir {
|
||||
t.Fatalf("upload file opened as directory: %#v", file)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReadEOFReturnsZeroLengthSuccess(t *testing.T) {
|
||||
sender := &captureSender{}
|
||||
handler := NewHandler("goclient", "AIOps")
|
||||
handler.Attach(sender, 1004)
|
||||
fileID := handler.storeOpenFile(&openFile{path: `Upload\x.yaml`, name: "x.yaml", data: []byte("abc")})
|
||||
|
||||
payload := make([]byte, 20)
|
||||
binary.LittleEndian.PutUint32(payload[0:4], 4096)
|
||||
binary.LittleEndian.PutUint64(payload[4:12], 3)
|
||||
handler.handleRead(ioRequest{
|
||||
deviceID: 1,
|
||||
fileID: fileID,
|
||||
completionID: 9,
|
||||
payload: payload,
|
||||
})
|
||||
|
||||
completion := sender.data[len(sender.data)-1]
|
||||
if status := binary.LittleEndian.Uint32(completion[12:16]); status != statusSuccess {
|
||||
t.Fatalf("expected EOF success, got status=0x%08x", status)
|
||||
}
|
||||
if got := binary.LittleEndian.Uint32(completion[16:20]); got != 0 {
|
||||
t.Fatalf("expected zero read length at EOF, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDeviceControlReturnsInvalidDeviceRequest(t *testing.T) {
|
||||
sender := &captureSender{}
|
||||
handler := NewHandler("goclient", "AIOps")
|
||||
handler.Attach(sender, 1004)
|
||||
|
||||
payload := make([]byte, 32)
|
||||
binary.LittleEndian.PutUint32(payload[8:12], 0x000900a8)
|
||||
handler.handleDeviceControl(ioRequest{
|
||||
deviceID: 1,
|
||||
fileID: 2,
|
||||
completionID: 10,
|
||||
payload: payload,
|
||||
})
|
||||
|
||||
completion := sender.data[len(sender.data)-1]
|
||||
if status := binary.LittleEndian.Uint32(completion[12:16]); status != statusInvalidDeviceReq {
|
||||
t.Fatalf("expected invalid device request, got status=0x%08x", status)
|
||||
}
|
||||
if got := binary.LittleEndian.Uint32(completion[16:20]); got != 0 {
|
||||
t.Fatalf("expected empty device control output, got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
// Package rdpdr 实现 RDP 设备重定向静态虚拟通道的最小握手。// Logan.Li
|
||||
package rdpdr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"unicode/utf16"
|
||||
)
|
||||
|
||||
type Sender interface {
|
||||
SendChannelPDU(channelID uint16, data []byte) error
|
||||
}
|
||||
|
||||
const ChannelName = "rdpdr"
|
||||
|
||||
const (
|
||||
componentRDPDR = 0x4472
|
||||
|
||||
pakIDServerAnnounce = 0x496e
|
||||
pakIDClientIDConfirm = 0x4343
|
||||
pakIDClientName = 0x434e
|
||||
pakIDServerCoreCap = 0x5350
|
||||
pakIDClientCoreCap = 0x4350
|
||||
pakIDDeviceListAnnounce = 0x4441
|
||||
pakIDDeviceReply = 0x6472
|
||||
pakIDDeviceIORequest = 0x4952
|
||||
pakIDDeviceIOCompletion = 0x4943
|
||||
pakIDUserLoggedOn = 0x554c
|
||||
|
||||
capGeneralType = 0x0001
|
||||
capDriveType = 0x0004
|
||||
|
||||
deviceTypeDisk = 0x00000008
|
||||
)
|
||||
|
||||
type Download struct {
|
||||
Name string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
sender Sender
|
||||
channelID uint16
|
||||
clientID uint32
|
||||
gotServerCap bool
|
||||
gotIDConfirm bool
|
||||
serverIOCode1 uint32
|
||||
computerName string
|
||||
driveName string
|
||||
devicePreannounced bool
|
||||
deviceAnnounced bool
|
||||
mu sync.Mutex
|
||||
nextFileID uint32
|
||||
files map[uint32]*openFile
|
||||
uploads map[string][]byte
|
||||
onDownload func(Download)
|
||||
}
|
||||
|
||||
func NewHandler(computerName string, driveName string) *Handler {
|
||||
if computerName == "" {
|
||||
computerName = "goclient"
|
||||
}
|
||||
if driveName == "" {
|
||||
driveName = "AIOPS"
|
||||
}
|
||||
return &Handler{computerName: computerName, driveName: driveName, nextFileID: 1, files: make(map[uint32]*openFile), uploads: make(map[string][]byte)}
|
||||
}
|
||||
|
||||
func (h *Handler) SetDownloadHandler(fn func(Download)) {
|
||||
h.onDownload = fn
|
||||
}
|
||||
|
||||
func (h *Handler) AddUpload(name string, data []byte) {
|
||||
if name == "" || len(data) == 0 {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.uploads[name] = append([]byte(nil), data...)
|
||||
h.mu.Unlock()
|
||||
log.Printf("[rdpdr] upload staged name=%s size=%d", name, len(data))
|
||||
}
|
||||
|
||||
func (h *Handler) listUploads() []dirEntry {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
out := make([]dirEntry, 0, len(h.uploads))
|
||||
for name, data := range h.uploads {
|
||||
out = append(out, dirEntry{name: name, size: uint64(len(data))})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *Handler) getUpload(name string) ([]byte, bool) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
data, ok := h.uploads[name]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return append([]byte(nil), data...), true
|
||||
}
|
||||
|
||||
func (h *Handler) Attach(sender Sender, channelID uint16) {
|
||||
h.sender = sender
|
||||
h.channelID = channelID
|
||||
log.Printf("[rdpdr] attached channelID=%d", channelID)
|
||||
}
|
||||
|
||||
func (h *Handler) PreannounceDevices() {
|
||||
h.mu.Lock()
|
||||
if h.devicePreannounced || h.sender == nil || h.channelID == 0 {
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
h.devicePreannounced = true
|
||||
h.mu.Unlock()
|
||||
// 部分 Windows 会在图形首帧后才启动 rdpdr;预公告客户端能力和磁盘设备,尽早完成驱动器重定向。// Logan.Li
|
||||
log.Printf("[rdpdr] preannounce devices client=%s drive=%s", h.computerName, h.driveName)
|
||||
h.sendClientName()
|
||||
h.sendCoreCapResponse()
|
||||
h.sendDeviceList(true)
|
||||
}
|
||||
|
||||
func (h *Handler) Process(data []byte) {
|
||||
if len(data) < 4 {
|
||||
return
|
||||
}
|
||||
component := binary.LittleEndian.Uint16(data[0:2])
|
||||
packetID := binary.LittleEndian.Uint16(data[2:4])
|
||||
body := data[4:]
|
||||
if component != componentRDPDR {
|
||||
log.Printf("[rdpdr] ignore component=0x%x packet=0x%x len=%d", component, packetID, len(data))
|
||||
return
|
||||
}
|
||||
log.Printf("[rdpdr] recv packet=0x%x len=%d", packetID, len(body))
|
||||
switch packetID {
|
||||
case pakIDServerAnnounce:
|
||||
h.handleServerAnnounce(body)
|
||||
case pakIDServerCoreCap:
|
||||
h.handleServerCoreCap(body)
|
||||
case pakIDClientIDConfirm:
|
||||
h.handleClientIDConfirm(body)
|
||||
case pakIDDeviceReply:
|
||||
h.handleDeviceReply(body)
|
||||
case pakIDDeviceIORequest:
|
||||
h.handleIORequest(body)
|
||||
case pakIDUserLoggedOn:
|
||||
h.sendDeviceListOnce()
|
||||
default:
|
||||
log.Printf("[rdpdr] unhandled packet=0x%x len=%d", packetID, len(body))
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleServerAnnounce(body []byte) {
|
||||
if len(body) < 8 {
|
||||
return
|
||||
}
|
||||
h.clientID = binary.LittleEndian.Uint32(body[4:8])
|
||||
log.Printf("[rdpdr] server announce clientID=%d version=%d.%d", h.clientID, binary.LittleEndian.Uint16(body[0:2]), binary.LittleEndian.Uint16(body[2:4]))
|
||||
|
||||
buf := make([]byte, 12)
|
||||
binary.LittleEndian.PutUint16(buf[0:2], componentRDPDR)
|
||||
binary.LittleEndian.PutUint16(buf[2:4], pakIDClientIDConfirm)
|
||||
binary.LittleEndian.PutUint16(buf[4:6], 1)
|
||||
binary.LittleEndian.PutUint16(buf[6:8], 0x000c)
|
||||
binary.LittleEndian.PutUint32(buf[8:12], h.clientID)
|
||||
h.send(buf)
|
||||
h.sendClientName()
|
||||
}
|
||||
|
||||
func (h *Handler) sendClientName() {
|
||||
runes := utf16.Encode([]rune(h.computerName + "\x00"))
|
||||
name := make([]byte, len(runes)*2)
|
||||
for i, r := range runes {
|
||||
binary.LittleEndian.PutUint16(name[i*2:], r)
|
||||
}
|
||||
buf := make([]byte, 16+len(name))
|
||||
binary.LittleEndian.PutUint16(buf[0:2], componentRDPDR)
|
||||
binary.LittleEndian.PutUint16(buf[2:4], pakIDClientName)
|
||||
binary.LittleEndian.PutUint32(buf[4:8], 1)
|
||||
binary.LittleEndian.PutUint32(buf[12:16], uint32(len(name)))
|
||||
copy(buf[16:], name)
|
||||
h.send(buf)
|
||||
}
|
||||
|
||||
func (h *Handler) handleServerCoreCap(body []byte) {
|
||||
if len(body) < 4 {
|
||||
return
|
||||
}
|
||||
count := binary.LittleEndian.Uint16(body[0:2])
|
||||
offset := 4
|
||||
for i := 0; i < int(count) && offset+8 <= len(body); i++ {
|
||||
capType := binary.LittleEndian.Uint16(body[offset:])
|
||||
capLen := int(binary.LittleEndian.Uint16(body[offset+2:]))
|
||||
if capLen < 8 || offset+capLen > len(body) {
|
||||
break
|
||||
}
|
||||
if capType == capGeneralType && capLen >= 24 {
|
||||
h.serverIOCode1 = binary.LittleEndian.Uint32(body[offset+20 : offset+24])
|
||||
}
|
||||
offset += capLen
|
||||
}
|
||||
h.gotServerCap = true
|
||||
h.trySendReady()
|
||||
}
|
||||
|
||||
func (h *Handler) handleClientIDConfirm(body []byte) {
|
||||
if len(body) >= 8 {
|
||||
h.clientID = binary.LittleEndian.Uint32(body[4:8])
|
||||
}
|
||||
h.gotIDConfirm = true
|
||||
h.trySendReady()
|
||||
}
|
||||
|
||||
func (h *Handler) trySendReady() {
|
||||
if !h.gotServerCap || !h.gotIDConfirm {
|
||||
return
|
||||
}
|
||||
h.sendCoreCapResponse()
|
||||
// 能力协商完成后立即公告虚拟盘,避免首次登录后资源管理器等待网络位置刷新。// Logan.Li
|
||||
h.sendDeviceListOnce()
|
||||
}
|
||||
|
||||
func (h *Handler) sendCoreCapResponse() {
|
||||
const generalCapLen = 44
|
||||
const driveCapLen = 8
|
||||
buf := bytes.Buffer{}
|
||||
writeLE16(&buf, componentRDPDR)
|
||||
writeLE16(&buf, pakIDClientCoreCap)
|
||||
writeLE16(&buf, 2)
|
||||
writeLE16(&buf, 0)
|
||||
|
||||
writeLE16(&buf, capGeneralType)
|
||||
writeLE16(&buf, generalCapLen)
|
||||
writeLE32(&buf, 2)
|
||||
writeLE32(&buf, 0)
|
||||
writeLE32(&buf, 0)
|
||||
writeLE16(&buf, 1)
|
||||
writeLE16(&buf, 0x000c)
|
||||
ioCode1 := h.serverIOCode1 & 0x0000ffff
|
||||
if ioCode1 == 0 {
|
||||
ioCode1 = 0x0000ffff
|
||||
}
|
||||
writeLE32(&buf, ioCode1)
|
||||
writeLE32(&buf, 0)
|
||||
writeLE32(&buf, 0x00000007)
|
||||
writeLE32(&buf, 0x00000001)
|
||||
writeLE32(&buf, 0)
|
||||
writeLE32(&buf, 0)
|
||||
|
||||
writeLE16(&buf, capDriveType)
|
||||
writeLE16(&buf, driveCapLen)
|
||||
writeLE32(&buf, 2)
|
||||
h.send(buf.Bytes())
|
||||
}
|
||||
|
||||
func (h *Handler) sendDeviceList(includeDevices bool) {
|
||||
deviceData := []byte(nil)
|
||||
deviceCount := uint32(0)
|
||||
totalLen := 8
|
||||
if includeDevices {
|
||||
deviceCount = 1
|
||||
deviceData = []byte(h.driveName)
|
||||
totalLen += 20 + len(deviceData)
|
||||
}
|
||||
if deviceCount == 0 {
|
||||
// FreeRDP 在没有预登录设备时直接跳过空公告,避免 Windows 记录一个无设备列表。// Logan.Li
|
||||
log.Printf("[rdpdr] skip empty device list announce")
|
||||
return
|
||||
}
|
||||
buf := make([]byte, totalLen)
|
||||
binary.LittleEndian.PutUint16(buf[0:2], componentRDPDR)
|
||||
binary.LittleEndian.PutUint16(buf[2:4], pakIDDeviceListAnnounce)
|
||||
binary.LittleEndian.PutUint32(buf[4:8], deviceCount)
|
||||
if includeDevices {
|
||||
// 公告一个磁盘设备,Windows 里显示为 \\goclient\AIOPS。// Logan.Li
|
||||
binary.LittleEndian.PutUint32(buf[8:12], deviceTypeDisk)
|
||||
binary.LittleEndian.PutUint32(buf[12:16], 1)
|
||||
copy(buf[16:24], []byte(dosDeviceName(h.driveName)))
|
||||
binary.LittleEndian.PutUint32(buf[24:28], uint32(len(deviceData)))
|
||||
copy(buf[28:], deviceData)
|
||||
}
|
||||
h.send(buf)
|
||||
log.Printf("[rdpdr] device list announced devices=%d name=%s", deviceCount, h.driveName)
|
||||
}
|
||||
|
||||
func (h *Handler) sendDeviceListOnce() {
|
||||
h.mu.Lock()
|
||||
if h.deviceAnnounced {
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
h.deviceAnnounced = true
|
||||
h.mu.Unlock()
|
||||
h.sendDeviceList(true)
|
||||
}
|
||||
|
||||
func dosDeviceName(name string) string {
|
||||
if name == "" {
|
||||
return "AIOPS"
|
||||
}
|
||||
if len(name) > 8 {
|
||||
return name[:8]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func (h *Handler) send(data []byte) {
|
||||
if h.sender == nil || h.channelID == 0 {
|
||||
return
|
||||
}
|
||||
if err := h.sender.SendChannelPDU(h.channelID, data); err != nil {
|
||||
log.Printf("[rdpdr] send failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) sendIOCompletion(deviceID, completionID, status uint32, output []byte) {
|
||||
buf := make([]byte, 16+len(output))
|
||||
binary.LittleEndian.PutUint16(buf[0:2], componentRDPDR)
|
||||
binary.LittleEndian.PutUint16(buf[2:4], pakIDDeviceIOCompletion)
|
||||
binary.LittleEndian.PutUint32(buf[4:8], deviceID)
|
||||
binary.LittleEndian.PutUint32(buf[8:12], completionID)
|
||||
binary.LittleEndian.PutUint32(buf[12:16], status)
|
||||
copy(buf[16:], output)
|
||||
h.send(buf)
|
||||
}
|
||||
|
||||
func (h *Handler) allocFileID() uint32 {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
id := h.nextFileID
|
||||
h.nextFileID++
|
||||
return id
|
||||
}
|
||||
|
||||
func (h *Handler) storeOpenFile(file *openFile) uint32 {
|
||||
id := h.allocFileID()
|
||||
h.mu.Lock()
|
||||
h.files[id] = file
|
||||
h.mu.Unlock()
|
||||
return id
|
||||
}
|
||||
|
||||
func (h *Handler) getOpenFile(id uint32) (*openFile, bool) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
file, ok := h.files[id]
|
||||
return file, ok
|
||||
}
|
||||
|
||||
func (h *Handler) removeOpenFile(id uint32) (*openFile, bool) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
file, ok := h.files[id]
|
||||
if ok {
|
||||
delete(h.files, id)
|
||||
}
|
||||
return file, ok
|
||||
}
|
||||
|
||||
func (h *Handler) handleDeviceReply(body []byte) {
|
||||
if len(body) < 8 {
|
||||
return
|
||||
}
|
||||
deviceID := binary.LittleEndian.Uint32(body[0:4])
|
||||
status := binary.LittleEndian.Uint32(body[4:8])
|
||||
if status == statusSuccess {
|
||||
h.mu.Lock()
|
||||
h.deviceAnnounced = true
|
||||
h.mu.Unlock()
|
||||
}
|
||||
log.Printf("[rdpdr] device reply deviceID=%d status=0x%08x", deviceID, status)
|
||||
}
|
||||
|
||||
func writeLE16(w *bytes.Buffer, v uint16) {
|
||||
_ = binary.Write(w, binary.LittleEndian, v)
|
||||
}
|
||||
|
||||
func writeLE32(w *bytes.Buffer, v uint32) {
|
||||
_ = binary.Write(w, binary.LittleEndian, v)
|
||||
}
|
||||
|
||||
func (h *Handler) String() string {
|
||||
return fmt.Sprintf("rdpdr(channel=%d)", h.channelID)
|
||||
}
|
||||
@@ -31,9 +31,14 @@ type ClientEvent struct {
|
||||
Code string `json:"code,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Down bool `json:"down,omitempty"`
|
||||
Ctrl bool `json:"ctrl,omitempty"`
|
||||
Alt bool `json:"alt,omitempty"`
|
||||
Shift bool `json:"shift,omitempty"`
|
||||
Meta bool `json:"meta,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// ServerFrame 是后端推给浏览器 Canvas 的统一帧协议 // Logan.Li
|
||||
@@ -46,6 +51,9 @@ type ServerFrame struct {
|
||||
Image string `json:"image,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
RGBA []byte `json:"-"`
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package rdpfilestage
|
||||
package filestage
|
||||
|
||||
import (
|
||||
"sync"
|
||||
@@ -38,4 +38,4 @@ func Get(token string) (Item, bool) {
|
||||
return Item{}, false
|
||||
}
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
@@ -136,17 +136,10 @@ 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 {
|
||||
if err := s.Key(0x0013, true); 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 {
|
||||
if err := s.Key(0x0013, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.Key(0xE05B, false); err != nil {
|
||||
@@ -157,5 +150,9 @@ func (s *InputSender) AutoExec(command string) error {
|
||||
return err
|
||||
}
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
return s.Key(0x001C, true)
|
||||
if err := s.Key(0x001C, true); err != nil {
|
||||
return err
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
return s.Key(0x001C, false)
|
||||
}
|
||||
@@ -37,6 +37,17 @@ const (
|
||||
gccSCSecurity = 0x0C02
|
||||
gccSCNet = 0x0C03
|
||||
|
||||
// 静态虚拟通道声明(MS-RDPBCGR 2.2.7.1.1 Channel Pool)
|
||||
// channel name 固定 8 字节 ASCII,不足补 0;options 控制通道加密/压缩行为 // Logan.Li
|
||||
channelNameRdpdr = "rdpdr"
|
||||
channelNameRdpsnd = "rdpsnd"
|
||||
channelNameCliprdr = "cliprdr"
|
||||
channelNameDrdynvc = "drdynvc"
|
||||
channelOptionInitialized = 0x80000000
|
||||
channelOptionEncryptRDP = 0x40000000
|
||||
channelOptionShowProtocol = 0x00200000
|
||||
channelOptionCompressRDP = 0x00800000
|
||||
|
||||
secFlagInfoPkt = 0x0040
|
||||
secFlagLicensePkt = 0x0080
|
||||
|
||||
@@ -65,11 +76,15 @@ 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
|
||||
UserDataBytes int
|
||||
ServerCoreVersion uint32
|
||||
ServerChannelID uint16
|
||||
ServerChannelCount uint16
|
||||
ServerChannelIDs []uint16
|
||||
// ChannelNameByID 记录服务端在 Connect-Response 里分配的 channel ID 对应的名称。
|
||||
// 客户端在 Connect-Initial 里声明的 channel def 顺序与服务端返回的 ServerChannelIDs 一一对应,
|
||||
// 据此建立 ID→名称映射,供 readLoop 按通道分发虚拟通道数据(cliprdr/rdpdr)。// Logan.Li
|
||||
ChannelNameByID map[uint16]string
|
||||
ServerEncryption uint32
|
||||
ServerEncryptionLvl uint32
|
||||
UserChannelID uint16
|
||||
@@ -179,7 +194,7 @@ func probeMCSConnectOnce(ctx context.Context, cfg Config, address string, opts c
|
||||
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)
|
||||
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)
|
||||
}
|
||||
@@ -258,7 +273,7 @@ func sendClientInfoAndReadDemand(rw io.ReadWriter, cfg Config, userID uint16) (m
|
||||
return first, demand, nil
|
||||
}
|
||||
|
||||
func sendConfirmActiveAndFinalize(rw io.ReadWriter, cfg Config, userID uint16, demand demandActiveProbe) ([]uint8, error) {
|
||||
func sendConfirmActiveAndFinalize(rw io.ReadWriter, cfg Config, userID uint16, demand demandActiveProbe) ([]uint8, []mcsDataIndication, error) {
|
||||
rdpDebugf(cfg, "finalize start userID=%d shared=0x%08x", userID, demand.sharedID)
|
||||
pdus := []struct {
|
||||
name string
|
||||
@@ -274,22 +289,22 @@ func sendConfirmActiveAndFinalize(rw io.ReadWriter, cfg Config, userID uint16, d
|
||||
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)
|
||||
return nil, 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
|
||||
return append(types, stepTypes...), nil, err
|
||||
} else if len(stepTypes) > 0 {
|
||||
types = append(types, stepTypes...)
|
||||
}
|
||||
}
|
||||
}
|
||||
readTypes, err := waitFinalizeResponses(rw, cfg, 5*time.Second)
|
||||
readTypes, pending, err := waitFinalizeResponses(rw, cfg, 5*time.Second)
|
||||
if err != nil {
|
||||
return types, err
|
||||
return types, pending, err
|
||||
}
|
||||
types = append(types, readTypes...)
|
||||
return types, nil
|
||||
return types, pending, nil
|
||||
}
|
||||
|
||||
func isRDPStepMode(cfg Config) bool {
|
||||
@@ -321,7 +336,7 @@ func readFinalizeStep(rw io.ReadWriter, name string, timeout time.Duration) ([]u
|
||||
return []uint8{got.type2}, nil
|
||||
}
|
||||
|
||||
func waitFinalizeResponses(rw io.ReadWriter, cfg Config, timeout time.Duration) ([]uint8, error) {
|
||||
func waitFinalizeResponses(rw io.ReadWriter, cfg Config, timeout time.Duration) ([]uint8, []mcsDataIndication, error) {
|
||||
if conn, ok := rw.(interface{ SetReadDeadline(time.Time) error }); ok {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(timeout))
|
||||
defer conn.SetReadDeadline(time.Time{})
|
||||
@@ -333,28 +348,36 @@ func waitFinalizeResponses(rw io.ReadWriter, cfg Config, timeout time.Duration)
|
||||
{type2: pduType2FontMap},
|
||||
}
|
||||
var types []uint8
|
||||
var pending []mcsDataIndication
|
||||
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, pending, nil
|
||||
}
|
||||
return types, fmt.Errorf("读取 Client Finalize 响应失败: %w", err)
|
||||
return types, pending, fmt.Errorf("读取 Client Finalize 响应失败: %w", err)
|
||||
}
|
||||
data, err := parseMCSDataIndication(payload)
|
||||
if err != nil {
|
||||
return types, err
|
||||
return types, pending, err
|
||||
}
|
||||
// 虚拟通道数据(cliprdr/rdpdr 等,channel ≠ 1003 global)在 finalize 阶段
|
||||
// 先缓存,等 SessionEngine 创建对应 handler 后再分发。 // Logan.Li
|
||||
if data.channelID != mcsGlobalChannelID {
|
||||
rdpDebugf(cfg, "finalize skip virtual channel data channel=%d payload=%d", data.channelID, len(data.payload))
|
||||
pending = append(pending, data)
|
||||
continue
|
||||
}
|
||||
got, err := parseShareDataPDUInfo(data.payload)
|
||||
if err != nil {
|
||||
return types, err
|
||||
return types, pending, 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)
|
||||
return types, pending, fmt.Errorf("服务端返回 ErrorInfo: code=0x%08x(%s) seen=%v raw=%s", got.errorInfo, rdpErrorInfoName(got.errorInfo), types, got.rawPrefix)
|
||||
}
|
||||
if got.type2 == pduType2SaveSessionInfo {
|
||||
continue
|
||||
@@ -368,7 +391,7 @@ func waitFinalizeResponses(rw io.ReadWriter, cfg Config, timeout time.Duration)
|
||||
break
|
||||
}
|
||||
}
|
||||
return types, nil
|
||||
return types, pending, nil
|
||||
}
|
||||
|
||||
func readNextSlowPathPayload(r io.Reader, cfg Config, timeout time.Duration) ([]byte, error) {
|
||||
@@ -492,13 +515,13 @@ func buildConfirmCapabilities(cfg Config) ([]byte, uint16) {
|
||||
writeCapability(&out, capType, body)
|
||||
count++
|
||||
}
|
||||
if cfg.MCSMode == "grdp-comment" || cfg.MCSMode == "step-grdp-comment" {
|
||||
if cfg.MCSMode == "comment" || cfg.MCSMode == "step-comment" || 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" {
|
||||
if cfg.MCSMode == "bpp32" || cfg.MCSMode == "step-bpp32" || cfg.MCSMode == "modern" || cfg.MCSMode == "step-modern" || cfg.MCSMode == "raw" || cfg.MCSMode == "step-raw" || cfg.MCSMode == "grdp-modern" || cfg.MCSMode == "step-grdp-modern" || cfg.MCSMode == "grdp-raw" || cfg.MCSMode == "step-grdp-raw" {
|
||||
preferredBPP = 32
|
||||
}
|
||||
writeLE16(&bitmap, preferredBPP)
|
||||
@@ -509,7 +532,7 @@ func buildConfirmCapabilities(cfg Config) ([]byte, uint16) {
|
||||
writeLE16(&bitmap, uint16(height))
|
||||
writeLE16(&bitmap, 0)
|
||||
writeLE16(&bitmap, 1)
|
||||
if cfg.MCSMode == "grdp-raw" || cfg.MCSMode == "step-grdp-raw" {
|
||||
if cfg.MCSMode == "raw" || cfg.MCSMode == "step-raw" || cfg.MCSMode == "grdp-raw" || cfg.MCSMode == "step-grdp-raw" {
|
||||
writeLE16(&bitmap, 0)
|
||||
} else {
|
||||
writeLE16(&bitmap, 1)
|
||||
@@ -544,7 +567,7 @@ func buildConfirmCapabilities(cfg Config) ([]byte, uint16) {
|
||||
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" {
|
||||
if cfg.MCSMode == "modern" || cfg.MCSMode == "step-modern" || cfg.MCSMode == "raw" || cfg.MCSMode == "step-raw" || 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"))
|
||||
@@ -575,7 +598,7 @@ func buildConfirmCapabilities(cfg Config) ([]byte, uint16) {
|
||||
addCapability(0x0010, make([]byte, 48))
|
||||
addCapability(0x0013, buildBitmapCacheRev2Capability())
|
||||
addCapability(0x0011, mustHex("0000000000000000"))
|
||||
addCapability(0x0014, mustHex("0000000000000000"))
|
||||
addCapability(0x0014, mustHex("0000000040060000"))
|
||||
addCapability(0x001a, mustHex("00000000"))
|
||||
return out.Bytes(), count
|
||||
}
|
||||
@@ -887,6 +910,15 @@ func parseMCSChannelJoinConfirm(data []byte, expectedUserID uint16, expectedChan
|
||||
return channelID, nil
|
||||
}
|
||||
|
||||
// ParseMCSChannel 解析 MCS SendDataIndication,返回 channel ID(探针诊断用)。// Logan.Li
|
||||
func ParseMCSChannel(data []byte) (uint16, error) {
|
||||
ind, err := parseMCSDataIndication(data)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return ind.channelID, nil
|
||||
}
|
||||
|
||||
func parseMCSDataIndication(data []byte) (mcsDataIndication, error) {
|
||||
r := bytes.NewReader(data)
|
||||
pdu, err := readMCSPDUHeader(r)
|
||||
@@ -1118,7 +1150,7 @@ func buildGCCClientCore(cfg Config) []byte {
|
||||
}
|
||||
highColorDepth := uint16(24)
|
||||
supportedColorDepths := uint16(0x000f)
|
||||
if cfg.MCSMode == "bpp32" || cfg.MCSMode == "step-bpp32" || cfg.MCSMode == "grdp-modern" || cfg.MCSMode == "step-grdp-modern" {
|
||||
if cfg.MCSMode == "bpp32" || cfg.MCSMode == "step-bpp32" || cfg.MCSMode == "modern" || cfg.MCSMode == "step-modern" || cfg.MCSMode == "grdp-modern" || cfg.MCSMode == "step-grdp-modern" {
|
||||
highColorDepth = 32
|
||||
supportedColorDepths = 0x001f
|
||||
}
|
||||
@@ -1148,9 +1180,28 @@ func buildGCCClientCore(cfg Config) []byte {
|
||||
return withGCCBlock(gccCSCore, body.Bytes())
|
||||
}
|
||||
|
||||
// declaredStaticChannels 是客户端在 MCS Connect-Initial 里声明的静态虚拟通道。
|
||||
// 顺序与服务端 Connect-Response 的 SCNet.ChannelIdArray 一一对应,保持接近 MSTSC/FreeRDP 的通道组合。// Logan.Li
|
||||
var declaredStaticChannels = []struct {
|
||||
Name string
|
||||
Options uint32
|
||||
}{
|
||||
{Name: channelNameRdpdr, Options: channelOptionInitialized | channelOptionEncryptRDP},
|
||||
{Name: channelNameRdpsnd, Options: channelOptionInitialized | channelOptionEncryptRDP},
|
||||
{Name: channelNameCliprdr, Options: channelOptionInitialized | channelOptionEncryptRDP | channelOptionShowProtocol},
|
||||
{Name: channelNameDrdynvc, Options: channelOptionInitialized | channelOptionCompressRDP},
|
||||
}
|
||||
|
||||
func buildGCCClientNetwork() []byte {
|
||||
body := bytes.Buffer{}
|
||||
writeLE32(&body, 0)
|
||||
writeLE32(&body, uint32(len(declaredStaticChannels))) // channelCount
|
||||
for _, ch := range declaredStaticChannels {
|
||||
// channel name: 8 字节 ASCII,不足补 0 // Logan.Li
|
||||
nameBuf := make([]byte, 8)
|
||||
copy(nameBuf, ch.Name)
|
||||
body.Write(nameBuf)
|
||||
writeLE32(&body, ch.Options)
|
||||
}
|
||||
return withGCCBlock(gccCSNet, body.Bytes())
|
||||
}
|
||||
|
||||
@@ -1256,6 +1307,13 @@ func parseGCCServerBlock(blockType uint16, body []byte, info *MCSProbeInfo) {
|
||||
for i := 0; i < int(info.ServerChannelCount) && r.Len() >= 2; i++ {
|
||||
channelID, _ := readLE16(r)
|
||||
info.ServerChannelIDs = append(info.ServerChannelIDs, channelID)
|
||||
// 与 buildGCCClientNetwork 声明的 declaredStaticChannels 按顺序对应,建立 ID→名称映射。 // Logan.Li
|
||||
if i < len(declaredStaticChannels) {
|
||||
if info.ChannelNameByID == nil {
|
||||
info.ChannelNameByID = make(map[uint16]string)
|
||||
}
|
||||
info.ChannelNameByID[channelID] = declaredStaticChannels[i].Name
|
||||
}
|
||||
}
|
||||
case 0x0C04:
|
||||
info.MessageChannelID, _ = readLE16(r)
|
||||
|
||||
@@ -19,6 +19,11 @@ type Session struct {
|
||||
SharedID uint32
|
||||
Width int
|
||||
Height int
|
||||
// ChannelNameByID 记录服务端分配的静态虚拟通道 ID→名称(cliprdr 等),
|
||||
// 供 readLoop 按通道分发数据给对应 Handler。空表示服务端未授权任何虚拟通道。// Logan.Li
|
||||
ChannelNameByID map[uint16]string
|
||||
// PendingChannelPDUs 缓存握手收尾阶段提前到达的虚拟通道包,避免 cliprdr 初始化被吞。// Logan.Li
|
||||
PendingChannelPDUs []mcsDataIndication
|
||||
}
|
||||
|
||||
// OpenSession 完成 CredSSP + MCS/GCC 握手并进入可收帧状态 // Logan.Li
|
||||
@@ -31,7 +36,7 @@ func OpenSession(ctx context.Context, cfg Config) (*Session, error) {
|
||||
cfg.MCSMode = strings.TrimSpace(os.Getenv("RDP_MCS_MODE"))
|
||||
}
|
||||
if cfg.MCSMode == "" {
|
||||
cfg.MCSMode = "grdp-modern"
|
||||
cfg.MCSMode = "modern"
|
||||
}
|
||||
address := fmt.Sprintf("%s:%d", cfg.Host, port)
|
||||
strategies := []credssp.Options{
|
||||
@@ -80,6 +85,9 @@ func openSessionOnce(ctx context.Context, cfg Config, address string, opts creds
|
||||
}
|
||||
mcsInfo, _ := parseMCSConnectResponse(payload)
|
||||
joinIDs := append([]uint16{mcsGlobalChannelID, userID}, mcsInfo.ServerChannelIDs...)
|
||||
if mcsInfo.MessageChannelID != 0 {
|
||||
joinIDs = append(joinIDs, mcsInfo.MessageChannelID)
|
||||
}
|
||||
for _, channelID := range joinIDs {
|
||||
if _, err := joinMCSChannel(tlsConn, userID, channelID); err != nil {
|
||||
_ = tlsConn.Close()
|
||||
@@ -91,14 +99,17 @@ func openSessionOnce(ctx context.Context, cfg Config, address string, opts creds
|
||||
_ = tlsConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if _, err := sendConfirmActiveAndFinalize(tlsConn, cfg, userID, demand); err != nil {
|
||||
_, pending, err := sendConfirmActiveAndFinalize(tlsConn, cfg, userID, demand)
|
||||
if err != nil {
|
||||
_ = tlsConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := requestDisplayUpdates(tlsConn, cfg, userID, demand.sharedID); err != nil {
|
||||
morePending, err := requestDisplayUpdates(tlsConn, cfg, userID, demand.sharedID)
|
||||
if err != nil {
|
||||
_ = tlsConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
pending = append(pending, morePending...)
|
||||
width := cfg.Width
|
||||
if width <= 0 {
|
||||
width = 1280
|
||||
@@ -109,29 +120,34 @@ func openSessionOnce(ctx context.Context, cfg Config, address string, opts creds
|
||||
}
|
||||
_ = tlsConn.SetDeadline(time.Time{})
|
||||
return &Session{
|
||||
Conn: tlsConn,
|
||||
UserID: userID,
|
||||
SharedID: demand.sharedID,
|
||||
Width: width,
|
||||
Height: height,
|
||||
Conn: tlsConn,
|
||||
UserID: userID,
|
||||
SharedID: demand.sharedID,
|
||||
Width: width,
|
||||
Height: height,
|
||||
ChannelNameByID: mcsInfo.ChannelNameByID,
|
||||
PendingChannelPDUs: pending,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func requestDisplayUpdates(rw io.ReadWriter, cfg Config, userID uint16, sharedID uint32) error {
|
||||
func requestDisplayUpdates(rw io.ReadWriter, cfg Config, userID uint16, sharedID uint32) ([]mcsDataIndication, error) {
|
||||
var pending []mcsDataIndication
|
||||
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)
|
||||
if ind, perr := parseMCSDataIndication(pkt.Payload); perr == nil && ind.channelID != mcsGlobalChannelID {
|
||||
pending = append(pending, ind)
|
||||
}
|
||||
}
|
||||
}
|
||||
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 pending, fmt.Errorf("发送 SuppressOutput 失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
return pending, nil
|
||||
}
|
||||
|
||||
func buildSuppressOutputBody(cfg Config, allow bool) []byte {
|
||||
|
||||
@@ -2,16 +2,82 @@ package rdp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf16"
|
||||
|
||||
"ezops/pkg/rdp/channels/cliprdr"
|
||||
"ezops/pkg/rdp/channels/rdpdr"
|
||||
"ezops/pkg/rdp/display"
|
||||
"ezops/pkg/rdp/filestage"
|
||||
"ezops/pkg/rdp/trace"
|
||||
"ezops/pkg/rdp/transport"
|
||||
)
|
||||
|
||||
// channelSender 适配 cliprdr.Sender 接口:把虚拟通道 PDU 通过 MCS SendDataRequest
|
||||
// 发往指定 channelID,复用会话 writeMu 避免与 FastPath 输入并发写连接。// Logan.Li
|
||||
type channelSender struct {
|
||||
conn io.ReadWriter
|
||||
userID uint16
|
||||
writeMu *sync.Mutex
|
||||
showProtocol bool
|
||||
}
|
||||
|
||||
// 虚拟通道数据标志(MS-RDPBCGR 2.2.6.1 Virtual Channel PDU Header)
|
||||
const (
|
||||
secFlagChannelPkt = 0x0004
|
||||
channelFlagFirst = 0x00000001
|
||||
channelFlagLast = 0x00000002
|
||||
channelFlagShow = 0x00000010
|
||||
channelChunkLength = 1600 // 单个 MCS PDU 的虚拟通道数据上限
|
||||
)
|
||||
|
||||
// SendChannelPDU 按 MS-RDPBCGR 静态虚拟通道格式发送:
|
||||
// 所有静态虚拟通道发送都带 Channel PDU Header,showProtocol 只控制 flags。// Logan.Li
|
||||
func (s *channelSender) SendChannelPDU(channelID uint16, data []byte) error {
|
||||
s.writeMu.Lock()
|
||||
defer s.writeMu.Unlock()
|
||||
totalLen := uint32(len(data))
|
||||
baseFlag := uint32(0)
|
||||
if s.showProtocol {
|
||||
baseFlag |= channelFlagShow
|
||||
}
|
||||
remaining := len(data)
|
||||
offset := 0
|
||||
first := true
|
||||
for remaining > 0 {
|
||||
chunkSize := remaining
|
||||
if chunkSize > channelChunkLength {
|
||||
chunkSize = channelChunkLength
|
||||
}
|
||||
flag := baseFlag
|
||||
if first {
|
||||
flag |= channelFlagFirst
|
||||
first = false
|
||||
}
|
||||
remaining -= chunkSize
|
||||
if remaining == 0 {
|
||||
flag |= channelFlagLast
|
||||
}
|
||||
// 虚拟通道数据格式: [Channel PDU Header(totalLen+flags)] + [data]。// Logan.Li
|
||||
buf := make([]byte, 8+chunkSize)
|
||||
binary.LittleEndian.PutUint32(buf[0:], totalLen) // Channel PDU totalLen
|
||||
binary.LittleEndian.PutUint32(buf[4:], flag) // Channel PDU flags
|
||||
copy(buf[8:], data[offset:offset+chunkSize])
|
||||
if err := transport.WriteX224Data(s.conn, buildMCSSendDataRequest(s.userID, channelID, buf)); err != nil {
|
||||
return err
|
||||
}
|
||||
offset += chunkSize
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SessionEngine 是自研完整 RDP 图形会话引擎 // Logan.Li
|
||||
type SessionEngine struct{}
|
||||
|
||||
@@ -43,11 +109,77 @@ func (e *SessionEngine) Run(ctx context.Context, cfg Config, sink FrameSink, eve
|
||||
input := NewInputSenderWithLock(sess.Conn, writeMu)
|
||||
receiver := NewFastPathReceiver()
|
||||
|
||||
// 剪贴板虚拟通道:服务端授权了 cliprdr 通道才创建 Handler。// Logan.Li
|
||||
var clip *cliprdr.Handler
|
||||
var clipChannelID uint16
|
||||
var drive *rdpdr.Handler
|
||||
var driveChannelID uint16
|
||||
log.Printf("[RDP] mcs channels userID=%d global=%d granted=%d map=%v", sess.UserID, mcsGlobalChannelID, len(sess.ChannelNameByID), sess.ChannelNameByID)
|
||||
for id, name := range sess.ChannelNameByID {
|
||||
if name == cliprdr.ChannelName {
|
||||
clipChannelID = id
|
||||
}
|
||||
if name == rdpdr.ChannelName {
|
||||
driveChannelID = id
|
||||
}
|
||||
}
|
||||
if driveChannelID != 0 {
|
||||
drive = rdpdr.NewHandler("Go-client", "AIOps")
|
||||
drive.SetDownloadHandler(func(file rdpdr.Download) {
|
||||
if text, ok := decodeRDPClipboardFallback(file); ok {
|
||||
_ = sink.SendFrame(ServerFrame{Type: "clipboard", Text: text, Message: "已读取远端剪贴板"})
|
||||
log.Printf("[RDP] rdpdr clipboard fallback ready len=%d", len(text))
|
||||
return
|
||||
}
|
||||
token := filestage.Put(file.Name, file.Data, 30*time.Minute)
|
||||
_ = sink.SendFrame(ServerFrame{
|
||||
Type: "rdp_file_download",
|
||||
Name: file.Name,
|
||||
URL: "/api/v1/rdp-staged/" + token,
|
||||
Size: int64(len(file.Data)),
|
||||
Message: "已捕获远端文件:" + file.Name,
|
||||
})
|
||||
log.Printf("[RDP] rdpdr download ready name=%s size=%d token=%s", file.Name, len(file.Data), token)
|
||||
})
|
||||
drive.Attach(&channelSender{conn: sess.Conn, userID: sess.UserID, writeMu: writeMu}, driveChannelID)
|
||||
log.Printf("[RDP] rdpdr channel attached id=%d", driveChannelID)
|
||||
drive.PreannounceDevices()
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
drive.PreannounceDevices()
|
||||
}()
|
||||
}
|
||||
if clipChannelID != 0 {
|
||||
clip = cliprdr.NewHandler(func(text string) {
|
||||
// 远端→本地:把远端剪贴板文本通过 WS clipboard 帧推给浏览器
|
||||
_ = sink.SendFrame(ServerFrame{Type: "clipboard", Text: text})
|
||||
log.Printf("[RDP] clipboard remote→local len=%d", len(text))
|
||||
})
|
||||
clip.Attach(&channelSender{conn: sess.Conn, userID: sess.UserID, writeMu: writeMu, showProtocol: true}, clipChannelID)
|
||||
log.Printf("[RDP] cliprdr channel attached id=%d", clipChannelID)
|
||||
go func() {
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
clip.Init()
|
||||
}()
|
||||
for _, ind := range sess.PendingChannelPDUs {
|
||||
if ind.channelID == clipChannelID {
|
||||
e.dispatchVirtualChannelPDU(ind, clip, clipChannelID)
|
||||
}
|
||||
if drive != nil && ind.channelID == driveChannelID {
|
||||
e.dispatchRdpdrPDU(ind, drive, driveChannelID)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Printf("[RDP] cliprdr channel NOT granted by server, clipboard sync disabled (channels=%v)", sess.ChannelNameByID)
|
||||
}
|
||||
|
||||
// 会话激活后主动发起 cliprdr 握手,兼容等待客户端先声明能力的 Windows 版本。// Logan.Li
|
||||
|
||||
readErr := make(chan error, 1)
|
||||
go func() {
|
||||
readErr <- e.readLoop(ctx, sess, receiver, sink, writeMu)
|
||||
readErr <- e.readLoop(ctx, sess, receiver, sink, writeMu, clip, clipChannelID, drive, driveChannelID)
|
||||
}()
|
||||
go e.inputLoop(ctx, input, sink, events)
|
||||
go e.inputLoop(ctx, input, sink, events, clip, drive)
|
||||
|
||||
for {
|
||||
select {
|
||||
@@ -62,7 +194,7 @@ func (e *SessionEngine) Run(ctx context.Context, cfg Config, sink FrameSink, eve
|
||||
}
|
||||
}
|
||||
|
||||
func (e *SessionEngine) inputLoop(ctx context.Context, input *InputSender, sink FrameSink, events <-chan ClientEvent) {
|
||||
func (e *SessionEngine) inputLoop(ctx context.Context, input *InputSender, sink FrameSink, events <-chan ClientEvent, clip *cliprdr.Handler, drive *rdpdr.Handler) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -74,18 +206,19 @@ func (e *SessionEngine) inputLoop(ctx context.Context, input *InputSender, sink
|
||||
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 {
|
||||
if err := e.handleEvent(input, sink, event, clip, drive); 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 {
|
||||
func (e *SessionEngine) readLoop(ctx context.Context, sess *Session, receiver *FastPathReceiver, sink FrameSink, writeMu *sync.Mutex, clip *cliprdr.Handler, clipChannelID uint16, drive *rdpdr.Handler, driveChannelID uint16) error {
|
||||
framebuffer := display.NewFramebuffer(sess.Width, sess.Height)
|
||||
ticker := time.NewTicker(16 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
refreshRequested := false
|
||||
usefulFrameSeen := false
|
||||
flushFrame := func() {
|
||||
frame, ok := framebuffer.FlushRGBA()
|
||||
if !ok {
|
||||
@@ -105,15 +238,23 @@ func (e *SessionEngine) readLoop(ctx context.Context, sess *Session, receiver *F
|
||||
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)
|
||||
}
|
||||
}()
|
||||
if isBlackFullScreen(rect, sess.Width, sess.Height) {
|
||||
if !refreshRequested {
|
||||
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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
if !usefulFrameSeen {
|
||||
// 登录初期的纯黑全屏帧通常是 RDP 激活噪声,跳过可避免浏览器闪黑。 // Logan.Li
|
||||
trace.Logf("engine=rdp skip startup black fullscreen")
|
||||
continue
|
||||
}
|
||||
}
|
||||
usefulFrameSeen = true
|
||||
framebuffer.Draw(display.Bitmap{
|
||||
X: rect.DestLeft,
|
||||
Y: rect.DestTop,
|
||||
@@ -161,6 +302,22 @@ func (e *SessionEngine) readLoop(ctx context.Context, sess *Session, receiver *F
|
||||
if got.packet.FastPath {
|
||||
rects, err = receiver.Handle(got.packet.SecFlag, got.packet.Payload)
|
||||
} else {
|
||||
// SlowPath:先解析 channelID 判断是图形(global 1003)还是虚拟通道(cliprdr 等) // Logan.Li
|
||||
if ind, perr := parseMCSDataIndication(got.packet.Payload); perr == nil {
|
||||
if clip != nil && ind.channelID == clipChannelID {
|
||||
e.dispatchVirtualChannelPDU(ind, clip, clipChannelID)
|
||||
continue
|
||||
}
|
||||
if drive != nil && ind.channelID == driveChannelID {
|
||||
e.dispatchRdpdrPDU(ind, drive, driveChannelID)
|
||||
continue
|
||||
}
|
||||
if ind.channelID != mcsGlobalChannelID {
|
||||
log.Printf("[RDP] virtual channel ignored channel=%d name=%s payload=%d", ind.channelID, sess.ChannelNameByID[ind.channelID], len(ind.payload))
|
||||
continue
|
||||
}
|
||||
logSlowPathSummary(ind, clipChannelID)
|
||||
}
|
||||
rects, err = receiver.HandleSlowPath(got.packet.Payload)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -193,6 +350,129 @@ func isBlackFullScreen(rect BitmapRect, width, height int) bool {
|
||||
return limit > 0
|
||||
}
|
||||
|
||||
func (e *SessionEngine) dispatchRdpdrPDU(ind mcsDataIndication, drive *rdpdr.Handler, driveChannelID uint16) {
|
||||
if drive == nil || ind.channelID != driveChannelID {
|
||||
return
|
||||
}
|
||||
prefixLen := len(ind.payload)
|
||||
if prefixLen > 12 {
|
||||
prefixLen = 12
|
||||
}
|
||||
log.Printf("[RDP] rdpdr payload prefix=%x len=%d", ind.payload[:prefixLen], len(ind.payload))
|
||||
if len(ind.payload) >= 4 && binary.LittleEndian.Uint16(ind.payload[0:2]) == 0x4472 {
|
||||
log.Printf("[RDP] rdpdr recv channel=%d rawLen=%d raw=true", ind.channelID, len(ind.payload))
|
||||
drive.Process(ind.payload)
|
||||
return
|
||||
}
|
||||
if len(ind.payload) >= 8 && binary.LittleEndian.Uint32(ind.payload[0:4]) <= uint32(len(ind.payload)) && binary.LittleEndian.Uint16(ind.payload[4:6]) == 0x4472 {
|
||||
log.Printf("[RDP] rdpdr recv channel=%d rawLen=%d lengthOnlyHeader=true", ind.channelID, len(ind.payload))
|
||||
drive.Process(ind.payload[4:])
|
||||
return
|
||||
}
|
||||
if len(ind.payload) >= 8 && binary.LittleEndian.Uint16(ind.payload[4:6]) == 0x4472 {
|
||||
log.Printf("[RDP] rdpdr recv channel=%d rawLen=%d securityHeader=true", ind.channelID, len(ind.payload))
|
||||
drive.Process(ind.payload[4:])
|
||||
return
|
||||
}
|
||||
if p, flag, ok := parseVirtualChannelPDU(ind.payload); ok {
|
||||
log.Printf("[RDP] rdpdr recv channel=%d rawLen=%d flag=0x%x", ind.channelID, len(ind.payload), flag)
|
||||
if flag&channelFlagFirst != 0 && flag&channelFlagLast != 0 {
|
||||
drive.Process(p)
|
||||
} else {
|
||||
log.Printf("[RDP] rdpdr fragmented chunk flag=0x%x len=%d", flag, len(p))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *SessionEngine) dispatchVirtualChannelPDU(ind mcsDataIndication, clip *cliprdr.Handler, clipChannelID uint16) {
|
||||
if clip == nil || ind.channelID != clipChannelID {
|
||||
return
|
||||
}
|
||||
prefixLen := len(ind.payload)
|
||||
if prefixLen > 16 {
|
||||
prefixLen = 16
|
||||
}
|
||||
log.Printf("[RDP] cliprdr payload prefix=%x len=%d", ind.payload[:prefixLen], len(ind.payload))
|
||||
if p, flag, ok := parseVirtualChannelPDU(ind.payload); ok {
|
||||
log.Printf("[RDP] cliprdr recv channel=%d rawLen=%d flag=0x%x", ind.channelID, len(ind.payload), flag)
|
||||
if flag&channelFlagFirst != 0 && flag&channelFlagLast != 0 {
|
||||
clip.Process(p)
|
||||
} else {
|
||||
log.Printf("[RDP] cliprdr fragmented chunk flag=0x%x len=%d", flag, len(p))
|
||||
}
|
||||
} else {
|
||||
log.Printf("[RDP] cliprdr parse failed channel=%d rawLen=%d", ind.channelID, len(ind.payload))
|
||||
}
|
||||
}
|
||||
|
||||
func parseVirtualChannelPDU(payload []byte) ([]byte, uint32, bool) {
|
||||
if len(payload) < 8 {
|
||||
return nil, 0, false
|
||||
}
|
||||
offset := 0
|
||||
if hasVirtualChannelSecurityHeader(payload) {
|
||||
offset = 4
|
||||
}
|
||||
totalLen := int(binary.LittleEndian.Uint32(payload[offset:]))
|
||||
flag := binary.LittleEndian.Uint32(payload[offset+4:])
|
||||
dataStart := offset + 8
|
||||
if totalLen < 0 || dataStart+totalLen > len(payload) {
|
||||
return nil, 0, false
|
||||
}
|
||||
return payload[dataStart : dataStart+totalLen], flag, true
|
||||
}
|
||||
|
||||
func hasVirtualChannelSecurityHeader(payload []byte) bool {
|
||||
if len(payload) < 12 || binary.LittleEndian.Uint16(payload[0:2]) != secFlagChannelPkt {
|
||||
return false
|
||||
}
|
||||
totalLen := int(binary.LittleEndian.Uint32(payload[4:8]))
|
||||
flag := binary.LittleEndian.Uint32(payload[8:12])
|
||||
if totalLen < 0 || 12+totalLen > len(payload) {
|
||||
return false
|
||||
}
|
||||
// 只有后移 4 字节后能看到合法 Channel PDU flags,才认为存在 security header。// Logan.Li
|
||||
return flag&(channelFlagFirst|channelFlagLast) != 0
|
||||
}
|
||||
|
||||
func logSlowPathSummary(ind mcsDataIndication, clipChannelID uint16) {
|
||||
prefixLen := len(ind.payload)
|
||||
if prefixLen > 24 {
|
||||
prefixLen = 24
|
||||
}
|
||||
prefix := fmt.Sprintf("%x", ind.payload[:prefixLen])
|
||||
type1 := uint16(0)
|
||||
type2 := byte(0)
|
||||
if len(ind.payload) >= 15 {
|
||||
type1 = binary.LittleEndian.Uint16(ind.payload[6:8])
|
||||
type2 = ind.payload[14]
|
||||
}
|
||||
log.Printf("[RDP] slowpath packet channel=%d payload=%d sec=0x%04x type1=0x%04x type2=0x%02x prefix=%s (clipID=%d)", ind.channelID, len(ind.payload), ind.securityFlags, type1, type2, prefix, clipChannelID)
|
||||
}
|
||||
|
||||
// isChannelData 判断一个 SlowPath MCS 包是否发往指定虚拟通道 ID。// Logan.Li
|
||||
func isChannelData(payload []byte, channelID uint16) bool {
|
||||
ind, err := parseMCSDataIndication(payload)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return ind.channelID == channelID
|
||||
}
|
||||
|
||||
// extractChannelPayload 从 SlowPath MCS 包中提取虚拟通道负载(已去掉 MCS/SEC 头)。// Logan.Li
|
||||
func extractChannelPayload(payload []byte, channelID uint16) []byte {
|
||||
ind, err := parseMCSDataIndication(payload)
|
||||
if err != nil || ind.channelID != channelID {
|
||||
return nil
|
||||
}
|
||||
// 虚拟通道数据:payload 前 4 字节是 securityHeader(encrypt/decrypt 标志),
|
||||
// 之后的 raw data 即 CLIPRDR PDU。NLA 模式下通道不额外加密,直接取 data 段。 // Logan.Li
|
||||
if len(ind.payload) < 4 {
|
||||
return nil
|
||||
}
|
||||
return ind.payload[4:]
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -218,7 +498,7 @@ func (e *SessionEngine) ackSurfaceFrames(sess *Session, frameIDs []uint32, write
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *SessionEngine) handleEvent(input *InputSender, sink FrameSink, event ClientEvent) error {
|
||||
func (e *SessionEngine) handleEvent(input *InputSender, sink FrameSink, event ClientEvent, clip *cliprdr.Handler, drive *rdpdr.Handler) error {
|
||||
switch event.Type {
|
||||
case "mousemove", "mouse_move":
|
||||
return input.MouseMove(event.X, event.Y)
|
||||
@@ -229,16 +509,71 @@ func (e *SessionEngine) handleEvent(input *InputSender, sink FrameSink, event Cl
|
||||
case "wheel":
|
||||
return input.MouseWheel(event.X, event.Y, event.DeltaY)
|
||||
case "key":
|
||||
if event.Meta {
|
||||
event.Ctrl = true
|
||||
}
|
||||
if event.Down && (event.Ctrl || event.Meta) && (event.Code == "KeyC" || event.Code == "KeyX") && clip != nil {
|
||||
go func() {
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
clip.RequestRemoteText()
|
||||
}()
|
||||
}
|
||||
if event.Code == "MetaLeft" || event.Code == "MetaRight" {
|
||||
event.Code = "ControlLeft"
|
||||
event.Key = "Control"
|
||||
}
|
||||
// 可打印字符按 Unicode 下发,避免远端键盘布局/修饰键状态导致审计有记录但远端无输入。 // Logan.Li
|
||||
if event.Down && len([]rune(event.Key)) == 1 && !event.Ctrl && !event.Alt && !event.Meta {
|
||||
return input.TypeText(event.Key)
|
||||
}
|
||||
if !event.Down && len([]rune(event.Key)) == 1 && !event.Ctrl && !event.Alt && !event.Meta {
|
||||
return nil
|
||||
}
|
||||
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)
|
||||
case "remote_copy":
|
||||
if err := sendCopyShortcut(input); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
if clip != nil {
|
||||
go func() {
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
clip.RequestRemoteText()
|
||||
}()
|
||||
}
|
||||
go func() {
|
||||
time.Sleep(900 * time.Millisecond)
|
||||
log.Printf("[RDP] clipboard explicit fallback auto_exec start")
|
||||
if err := input.AutoExec(buildClipboardFallbackCommand()); err != nil {
|
||||
log.Printf("[RDP] clipboard explicit fallback auto_exec failed: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("[RDP] clipboard explicit fallback auto_exec sent")
|
||||
}()
|
||||
return sink.SendFrame(ServerFrame{Type: "status", Message: "已发送 Ctrl+C 到远端,等待剪贴板回传"})
|
||||
case "clipboard":
|
||||
// 本地→远端:优先走 cliprdr 协议级同步(通知远端本地剪贴板变更),
|
||||
// 远端粘贴时主动拉取;cliprdr 不可用时回退到 Unicode 键盘模拟。// Logan.Li
|
||||
if event.Text == "" {
|
||||
return nil
|
||||
}
|
||||
if clip != nil {
|
||||
clip.SetLocalText(event.Text)
|
||||
}
|
||||
return input.TypeText(event.Text)
|
||||
case "rdp_file_upload":
|
||||
if drive == nil || event.Name == "" || event.Text == "" {
|
||||
return nil
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(event.Text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
drive.AddUpload(event.Name, data)
|
||||
return sink.SendFrame(ServerFrame{Type: "status", Message: "已放入 AIOps\\Upload:" + event.Name})
|
||||
case "auto_exec":
|
||||
if event.Text == "" {
|
||||
return nil
|
||||
@@ -253,6 +588,44 @@ func (e *SessionEngine) handleEvent(input *InputSender, sink FrameSink, event Cl
|
||||
}
|
||||
}
|
||||
|
||||
func sendCopyShortcut(input *InputSender) error {
|
||||
// 工具按钮专用复制快捷键,避免复用前端 key 序列导致修饰键状态漂移。// Logan.Li
|
||||
if err := input.Key(0x001D, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := input.Key(0x002E, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := input.Key(0x002E, false); err != nil {
|
||||
return err
|
||||
}
|
||||
return input.Key(0x001D, false)
|
||||
}
|
||||
|
||||
func buildClipboardFallbackCommand() string {
|
||||
ps := `$ErrorActionPreference='SilentlyContinue';$v='';try{$v=Get-Clipboard -Raw -ErrorAction Stop}catch{};if([string]::IsNullOrEmpty($v)){try{Add-Type -AssemblyName System.Windows.Forms;$v=[Windows.Forms.Clipboard]::GetText()}catch{}};if(-not [string]::IsNullOrEmpty($v)){$utf8=New-Object System.Text.UTF8Encoding -ArgumentList $false;$paths=@('\\tsclient\AIOps\Download\aiops-clipboard.txt','\\Go-client\AIOps\Download\aiops-clipboard.txt');foreach($p in $paths){try{[System.IO.File]::WriteAllText($p,$v,$utf8);break}catch{}}}`
|
||||
encoded := encodePowerShellCommand(ps)
|
||||
return "powershell -NoProfile -STA -WindowStyle Hidden -EncodedCommand " + encoded
|
||||
}
|
||||
|
||||
func encodePowerShellCommand(command string) string {
|
||||
u16 := utf16.Encode([]rune(command))
|
||||
raw := make([]byte, len(u16)*2)
|
||||
for i, v := range u16 {
|
||||
binary.LittleEndian.PutUint16(raw[i*2:], v)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(raw)
|
||||
}
|
||||
|
||||
func decodeRDPClipboardFallback(file rdpdr.Download) (string, bool) {
|
||||
if file.Name != "aiops-clipboard.txt" || len(file.Data) == 0 {
|
||||
return "", false
|
||||
}
|
||||
text := string(file.Data)
|
||||
text = strings.TrimPrefix(text, "\ufeff")
|
||||
return text, text != ""
|
||||
}
|
||||
|
||||
// ProbeSessionFrames 用于诊断:建立会话后读取若干秒位图帧数量 // Logan.Li
|
||||
func ProbeSessionFrames(ctx context.Context, cfg Config, seconds int) (int, error) {
|
||||
sess, err := OpenSession(ctx, cfg)
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -17,9 +17,10 @@ type agentStaticPaths struct {
|
||||
}
|
||||
|
||||
var agentBinaryFiles = map[string]string{
|
||||
"linux-amd64": "aiops-agent-linux-amd64",
|
||||
"linux-arm64": "aiops-agent-linux-arm64",
|
||||
"darwin-arm64": "aiops-agent-darwin-arm64",
|
||||
"linux-amd64": "aiops-agent-linux-amd64",
|
||||
"linux-arm64": "aiops-agent-linux-arm64",
|
||||
"darwin-arm64": "aiops-agent-darwin-arm64",
|
||||
"windows-amd64": "aiops-agent-windows-amd64.exe",
|
||||
}
|
||||
|
||||
// registerAgentDownloads 注册 Agent 安装脚本和二进制下载路由,避免维护 backend/agent 重复目录。 // Logan.Li
|
||||
@@ -39,6 +40,9 @@ func registerAgentDownloads(r *gin.Engine, log *zap.Logger) {
|
||||
r.GET("/agent/install.sh", func(c *gin.Context) {
|
||||
serveAgentStaticFile(c, paths.deployDir, "install.sh")
|
||||
})
|
||||
r.GET("/agent/install.ps1", func(c *gin.Context) {
|
||||
serveAgentStaticFile(c, paths.deployDir, "install.ps1")
|
||||
})
|
||||
for _, fileName := range agentBinaryFiles {
|
||||
name := fileName
|
||||
r.GET("/agent/"+name, func(c *gin.Context) {
|
||||
|
||||
@@ -125,9 +125,8 @@ 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)
|
||||
// RDPDR Download 捕获文件拉取(token 鉴权)。// Logan.Li
|
||||
api.GET("/rdp-staged/:token", controller.ServeRDPStaged)
|
||||
|
||||
// Webhook 触发路由(无需 JWT,由 Webhook Token 签名验证)
|
||||
pipelineHandler := controller.NewPipelineHandler(db, redisCache, ti)
|
||||
@@ -262,12 +261,6 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
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
|
||||
@@ -941,6 +934,8 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
|
||||
// AI 智能助手 - LLM 配置 + 对话代理(OpenAI 兼容) // Logan.Li
|
||||
llmHandler := controller.NewLLMHandler(db)
|
||||
aiopsContextHandler := controller.NewAIOpsContextHandler(db)
|
||||
aiopsAlertAnalysisHandler := controller.NewAIOpsAlertAnalysisHandler(db)
|
||||
aiopsGroup := authorized.Group("/aiops")
|
||||
{
|
||||
aiopsGroup.GET("/llm-config", llmHandler.GetLLMConfig)
|
||||
@@ -950,6 +945,11 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
aiopsGroup.POST("/chat/stream", llmHandler.ChatStream)
|
||||
aiopsGroup.GET("/chat-history", llmHandler.GetChatHistory)
|
||||
aiopsGroup.DELETE("/chat-history", llmHandler.ClearChatHistory)
|
||||
aiopsGroup.GET("/context/summary", aiopsContextHandler.Summary)
|
||||
aiopsGroup.POST("/context/pack", aiopsContextHandler.BuildPack)
|
||||
aiopsGroup.POST("/alert-analysis", aiopsAlertAnalysisHandler.Analyze)
|
||||
aiopsGroup.GET("/analysis-traces", aiopsAlertAnalysisHandler.ListAnalysisTraces)
|
||||
aiopsGroup.GET("/analysis-traces/:trace_id", aiopsAlertAnalysisHandler.GetAnalysisTrace)
|
||||
}
|
||||
|
||||
// 指标浏览器 — 监控指标元数据 + 时序查询 // Logan.Li
|
||||
|
||||
Whitespace-only changes.
Reference in new issue
Block a user