feat: 完善堡垒机与 Agent 智能运维能力

- 支持 Windows Agent 构建、安装脚本和统一上报协议

- 增强 RDP 剪贴板、虚拟盘和文件传输能力

- 新增 AIOps 上下文工具、告警 RCA trace 落库和回放接口

- 重构 docs 模块文档并补充终端、堡垒机、监控、AI Agent 设计
This commit is contained in:
Logan.Li committed 2026-07-05 23:49:36 +08:00
1 parent a9513c2056
commit e0d652d211
169 files changed
+12192 -9116

No files matched your search

+199 -11
View File
@@ -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 {