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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user