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
+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 {
|
||||
|
||||
Reference in new issue
Block a user