feat(rdp): 新增 RDP 远程桌面/SMB/账单同步能力 + 清理编译产物
主要变更: - RDP 远程桌面:新增 backend/pkg/rdp(RDP 引擎 + credssp/display/rle/trace)、 backend/controller/rdp*(连接/凭证/文件/会话/实时)、frontend RDP 组件与 API - Windows SMB 文件传输:backend/pkg/winsmb - 云账单同步:backend/pkg/billingsync + billing_sync/aliyun_billing,前端账单详情 - Agent CLI Kubernetes 工具:kubernetes_action/get、pod_execute/logs - 服务器导入助手:server_import_helpers、ImportCredentialSettings - 国际化与组件重构(cloud/deploy/monitor/server/system 等多模块) - 清理编译产物二进制:移除 backend/server、backend/agent/bin/aiops-agent-* - .gitignore:新增 backend/agent/ 与 .cache/(避免 334MB go-build 缓存误入库) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
777d9e4e71
commit
76ec9387f0
202 files changed
+16555
-2543
No files matched your search
@@ -18,13 +18,42 @@ import (
|
||||
"ezops/pkg/agentcli/llm"
|
||||
"ezops/pkg/agentcli/safety"
|
||||
"ezops/pkg/agentcli/tools"
|
||||
k8sclient "ezops/pkg/kubernetesclient"
|
||||
"ezops/pkg/remoteexec"
|
||||
)
|
||||
|
||||
// EngineConfig 引擎配置(由 controller 从 model.LLMConfig + model.Server 构建后注入) // Logan.Li
|
||||
// RunMode Agent 运行模式 // Logan.Li
|
||||
const (
|
||||
RunModeHost = "host"
|
||||
RunModeKubernetes = "kubernetes"
|
||||
)
|
||||
|
||||
// NormalizeRunMode 规范化运行模式(兼容旧 k8s 参数) // Logan.Li
|
||||
func NormalizeRunMode(mode string) string {
|
||||
if mode == "k8s" || mode == RunModeKubernetes {
|
||||
return RunModeKubernetes
|
||||
}
|
||||
if mode == "" {
|
||||
return RunModeHost
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
// KubernetesTarget Kubernetes 模式目标上下文(由 controller 注入) // Logan.Li
|
||||
type KubernetesTarget struct {
|
||||
Client *k8sclient.Client
|
||||
ClusterName string
|
||||
Namespace string
|
||||
PodName string
|
||||
Container string
|
||||
}
|
||||
|
||||
// EngineConfig 引擎配置(由 controller 从 model.LLMConfig + 目标上下文构建后注入) // Logan.Li
|
||||
type EngineConfig struct {
|
||||
Mode string // host | kubernetes
|
||||
LLM llm.Config // LLM 客户端配置(endpoint 已解析)
|
||||
SSH remoteexec.SSHConfig // 目标主机 SSH 配置(含凭据)
|
||||
SSH remoteexec.SSHConfig // 主机模式 SSH 配置
|
||||
Kubernetes *KubernetesTarget // Kubernetes 模式目标
|
||||
SafetyMode safety.Mode // 安全模式(默认 balanced)
|
||||
Core *core.Config // ReAct 配置(nil 用默认)
|
||||
MemoryDir string // Evolver 记忆目录(空则 data/agentcli)
|
||||
@@ -47,25 +76,40 @@ func NewEngine(cfg EngineConfig) (*Engine, error) {
|
||||
if cfg.MemoryDir != "" {
|
||||
coreCfg.MemoryDir = cfg.MemoryDir
|
||||
}
|
||||
mode := cfg.SafetyMode
|
||||
if mode == "" {
|
||||
mode = safety.ModeBalanced
|
||||
safetyMode := cfg.SafetyMode
|
||||
if safetyMode == "" {
|
||||
safetyMode = safety.ModeBalanced
|
||||
}
|
||||
|
||||
// LLM 客户端
|
||||
client := llm.NewClient(cfg.LLM)
|
||||
|
||||
// 工具注册中心:ssh_execute(SSH 长连接)+ analyze_output
|
||||
// 工具注册中心:按模式注册互斥工具集 // Logan.Li
|
||||
registry := tools.NewRegistry()
|
||||
sshTool, err := tools.NewSSHExecuteTool(cfg.SSH, coreCfg.ToolTimeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ssh_execute 初始化失败: %w", err)
|
||||
runMode := NormalizeRunMode(cfg.Mode)
|
||||
coreCfg.Mode = runMode
|
||||
|
||||
switch runMode {
|
||||
case RunModeKubernetes:
|
||||
if cfg.Kubernetes == nil || cfg.Kubernetes.Client == nil {
|
||||
return nil, fmt.Errorf("Kubernetes 模式缺少集群客户端")
|
||||
}
|
||||
kt := cfg.Kubernetes
|
||||
registry.Register(tools.NewKubernetesGetTool(kt.Client, kt.Namespace))
|
||||
registry.Register(tools.NewPodLogsTool(kt.Client, kt.Namespace, kt.PodName, kt.Container))
|
||||
registry.Register(tools.NewPodExecuteTool(kt.Client, kt.Namespace, kt.PodName, kt.Container, coreCfg.ToolTimeout))
|
||||
registry.Register(tools.NewKubernetesActionTool(kt.Client, kt.Namespace))
|
||||
default:
|
||||
sshTool, err := tools.NewSSHExecuteTool(cfg.SSH, coreCfg.ToolTimeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ssh_execute 初始化失败: %w", err)
|
||||
}
|
||||
registry.Register(sshTool)
|
||||
}
|
||||
registry.Register(sshTool)
|
||||
registry.Register(tools.NewAnalyzeOutputTool())
|
||||
|
||||
// 安全控制器
|
||||
sc := safety.NewController(mode)
|
||||
sc := safety.NewController(safetyMode)
|
||||
|
||||
// Evolver(LLM 可用时启用学习;失败降级)
|
||||
ev, err := evolver.NewEvolverEngine(coreCfg.MemoryDir, client)
|
||||
@@ -86,9 +130,9 @@ func (e *Engine) SetConfirmFn(fn safety.ConfirmFn) { e.safety.SetConfirmFn(fn) }
|
||||
// SetAutoApprove 测试用:自动批准(生产禁用) // Logan.Li
|
||||
func (e *Engine) SetAutoApprove(b bool) { e.safety.SetAutoApprove(b) }
|
||||
|
||||
// Run 执行任务(ReAct 循环);prior 为上一轮对话历史(不含 system),priorTokens 为累计 token // Logan.Li
|
||||
func (e *Engine) Run(ctx context.Context, query, serverInfo string, prior []llm.Message, priorTokens int) (*core.RunResult, error) {
|
||||
return e.agent.Run(ctx, query, serverInfo, prior, priorTokens)
|
||||
// Run 执行任务(ReAct 循环);targetInfo 为目标上下文摘要;prior 为上一轮对话历史(不含 system) // Logan.Li
|
||||
func (e *Engine) Run(ctx context.Context, query, targetInfo string, prior []llm.Message, priorTokens int) (*core.RunResult, error) {
|
||||
return e.agent.Run(ctx, query, targetInfo, prior, priorTokens)
|
||||
}
|
||||
|
||||
// EvolveStats 进化统计(供审计展示) // Logan.Li
|
||||
|
||||
@@ -61,6 +61,7 @@ type RunResult struct {
|
||||
|
||||
// Config Agent 运行配置 // Logan.Li
|
||||
type Config struct {
|
||||
Mode string // host | kubernetes
|
||||
MaxIterations int
|
||||
Temperature float64
|
||||
ToolTimeout time.Duration
|
||||
@@ -135,7 +136,7 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string, prior []llm.M
|
||||
|
||||
// 工具描述
|
||||
toolDescs := a.buildToolDescriptions()
|
||||
systemPrompt := prompt.BuildSystemPrompt(serverInfo, toolDescs, evolveCtx)
|
||||
systemPrompt := prompt.BuildSystemPrompt(a.config.Mode, serverInfo, toolDescs, evolveCtx)
|
||||
|
||||
messages := []llm.Message{{Role: "system", Content: systemPrompt}}
|
||||
if len(prior) > 0 {
|
||||
@@ -191,7 +192,7 @@ func (a *Agent) Run(ctx context.Context, query, serverInfo string, prior []llm.M
|
||||
}
|
||||
messages = append(messages, llm.Message{
|
||||
Role: "user",
|
||||
Content: "【系统提示】请立即调用 ssh_execute 工具在目标主机执行命令获取实际结果,不要仅用文字描述计划。",
|
||||
Content: fmt.Sprintf("【系统提示】请立即调用 %s 工具获取实际结果,不要仅用文字描述计划。", a.primaryExecTool()),
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -303,8 +304,8 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist
|
||||
success = false
|
||||
}
|
||||
|
||||
// 超长 ssh 输出自动分析,避免 token 爆炸 // Logan.Li
|
||||
if tc.Function.Name == "ssh_execute" && success && len(output) > a.autoAnalyzeThreshold() {
|
||||
// 超长命令输出自动分析,避免 token 爆炸 // Logan.Li
|
||||
if (tc.Function.Name == "ssh_execute" || tc.Function.Name == "pod_execute") && success && len(output) > a.autoAnalyzeThreshold() {
|
||||
if analyzed := a.autoAnalyzeOutput(ctx, output); analyzed != "" {
|
||||
output = analyzed
|
||||
}
|
||||
@@ -364,6 +365,14 @@ func (a *Agent) roundNum(iter int) int {
|
||||
return a.config.RoundOffset + iter + 1
|
||||
}
|
||||
|
||||
// primaryExecTool 返回当前模式下的主执行工具名 // Logan.Li
|
||||
func (a *Agent) primaryExecTool() string {
|
||||
if a.config.Mode == "kubernetes" || a.config.Mode == "k8s" {
|
||||
return "kubernetes_get"
|
||||
}
|
||||
return "ssh_execute"
|
||||
}
|
||||
|
||||
// lastToolOutput 取最近一条工具 observation,供最终答案为空时回退展示 // Logan.Li
|
||||
func lastToolOutput(messages []llm.Message) string {
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Package prompt 构建 Agent CLI 的 System Prompt(五层架构 + 动态记忆注入)。 // Logan.Li
|
||||
// 设计参考 opsxcli internal/agent/prompt,角色改写为 aiops Web 运维 Agent 语境。
|
||||
package prompt
|
||||
|
||||
import (
|
||||
@@ -7,34 +6,59 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BuildSystemPrompt 构建五层 System Prompt。 // Logan.Li
|
||||
// - Layer 1: 角色定位(aiops 智能运维 Agent)
|
||||
// - Layer 2: 安全规则(黑名单/高危确认/优先只读)
|
||||
// - Layer 3: 工具说明(由 registry 动态生成)
|
||||
// - Layer 4: 工作方式(ReAct 循环:思考→执行→观察→失败则换法重试→直到解决)
|
||||
// - Layer 5: 记忆注入(Evolver 提供的历史经验)
|
||||
//
|
||||
// serverInfo:目标主机摘要;toolDescs:工具描述文本;evolveContext:历史经验。
|
||||
func BuildSystemPrompt(serverInfo, toolDescs, evolveContext string) string {
|
||||
const runModeKubernetes = "kubernetes"
|
||||
|
||||
// normalizeMode 规范化运行模式(兼容旧 k8s,避免 import 循环) // Logan.Li
|
||||
func normalizeMode(mode string) string {
|
||||
if mode == "k8s" || mode == runModeKubernetes {
|
||||
return runModeKubernetes
|
||||
}
|
||||
if mode == "" {
|
||||
return "host"
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
// BuildSystemPrompt 构建五层 System Prompt。mode: host|kubernetes // Logan.Li
|
||||
func BuildSystemPrompt(mode, targetInfo, toolDescs, evolveContext string) string {
|
||||
mode = normalizeMode(mode)
|
||||
var sb strings.Builder
|
||||
|
||||
// Layer 1: 角色
|
||||
sb.WriteString("你是 aiops 平台的 Agent CLI —— 一个企业级智能运维 Agent," +
|
||||
"类似 Claude Code / Codex,但运行在 Web 页面上,通过 SSH 工具远程操作真实服务器。\n")
|
||||
sb.WriteString("你的职责是:根据用户的自然语言任务描述,自主在目标服务器上排查故障、安装与配置软件、修复问题," +
|
||||
"并通过 WebSocket 把每一步思考、执行的命令和输出实时反馈给用户。\n\n")
|
||||
if mode == runModeKubernetes {
|
||||
sb.WriteString("你是 aiops 平台的 Agent CLI —— 一个企业级 Kubernetes 智能运维 Agent," +
|
||||
"运行在 Web 页面上,通过 kubernetes_get / pod_logs / pod_execute / kubernetes_action 操作真实 Kubernetes 集群。\n")
|
||||
sb.WriteString("你的职责是:根据用户自然语言任务,自主查询集群/命名空间资源状态、分析事件与日志、" +
|
||||
"必要时进入 Pod 容器排障,并通过 WebSocket 实时反馈每一步思考与结果。\n\n")
|
||||
} else {
|
||||
sb.WriteString("你是 aiops 平台的 Agent CLI —— 一个企业级智能运维 Agent," +
|
||||
"类似 Claude Code / Codex,但运行在 Web 页面上,通过 SSH 工具远程操作真实服务器。\n")
|
||||
sb.WriteString("你的职责是:根据用户的自然语言任务描述,自主在目标服务器上排查故障、安装与配置软件、修复问题," +
|
||||
"并通过 WebSocket 把每一步思考、执行的命令和输出实时反馈给用户。\n\n")
|
||||
}
|
||||
|
||||
// 目标主机
|
||||
if strings.TrimSpace(serverInfo) != "" {
|
||||
sb.WriteString("【目标主机】\n" + serverInfo + "\n\n")
|
||||
// 目标上下文
|
||||
if strings.TrimSpace(targetInfo) != "" {
|
||||
label := "目标主机"
|
||||
if mode == runModeKubernetes {
|
||||
label = "目标集群上下文"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("【%s】\n%s\n\n", label, targetInfo))
|
||||
}
|
||||
|
||||
// Layer 2: 安全规则
|
||||
sb.WriteString("【安全规则】\n")
|
||||
sb.WriteString("1. 破坏性命令(rm -rf /、mkfs、dd 覆写磁盘等)被严格禁止,绝不执行。\n")
|
||||
sb.WriteString("2. 高危操作(reboot、systemctl restart、kill、卸载软件等)需要用户确认后才能执行。\n")
|
||||
sb.WriteString("3. 排障时优先使用只读命令(df/ps/cat/grep/tail/systemctl status/journalctl)收集信息,确认根因后再动手修复。\n")
|
||||
sb.WriteString("4. 不要执行任何你不确定后果的命令;宁可多问用户,也不盲目操作。\n\n")
|
||||
sb.WriteString("2. 高危操作(删除 Pod、重启 Deployment、扩缩容、换镜像等)需要用户确认后才能执行。\n")
|
||||
if mode == runModeKubernetes {
|
||||
sb.WriteString("3. 排障时优先使用只读工具:kubernetes_get 查资源状态、pod_logs 看日志;确认根因后再 pod_execute 或 kubernetes_action。\n")
|
||||
sb.WriteString("4. 容器内可能没有 systemctl、宿主机路径;不要假设节点级命令可用。\n")
|
||||
sb.WriteString("5. 未选定 Pod 时,用 kubernetes_get 和 pod_logs(带 pod 参数)排障;选定 Pod 后可直接 pod_execute。\n")
|
||||
} else {
|
||||
sb.WriteString("3. 排障时优先使用只读命令(df/ps/cat/grep/tail/systemctl status/journalctl)收集信息,确认根因后再动手修复。\n")
|
||||
sb.WriteString("4. 不要执行任何你不确定后果的命令;宁可多问用户,也不盲目操作。\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Layer 3: 工具
|
||||
if strings.TrimSpace(toolDescs) != "" {
|
||||
@@ -45,20 +69,24 @@ func BuildSystemPrompt(serverInfo, toolDescs, evolveContext string) string {
|
||||
sb.WriteString("【工作方式(ReAct 循环)】\n")
|
||||
sb.WriteString("你采用「思考-行动-观察」循环解决任务:\n")
|
||||
sb.WriteString("1. 思考:分析当前状态,决定下一步。在回复中先用简短文字说明你的判断和计划。\n")
|
||||
sb.WriteString("2. 行动:调用 ssh_execute 执行命令(一次只做关键的、可验证的一步)。\n")
|
||||
sb.WriteString("3. 观察:仔细阅读命令输出(含 exit_code,0 表示成功)。\n")
|
||||
sb.WriteString("4. 循环:如果命令失败或问题未解决,分析错误原因,换一种方法重试,直到真正解决。不要因一次失败就放弃。\n")
|
||||
if mode == runModeKubernetes {
|
||||
sb.WriteString("2. 行动:优先 kubernetes_get / pod_logs;需要容器内信息时用 pod_execute;变更用 kubernetes_action(一次一步)。\n")
|
||||
sb.WriteString("3. 观察:仔细阅读工具输出;pod_execute 输出含 [exit_code=0] 表示成功。\n")
|
||||
} else {
|
||||
sb.WriteString("2. 行动:调用 ssh_execute 执行命令(一次只做关键的、可验证的一步)。\n")
|
||||
sb.WriteString("3. 观察:仔细阅读命令输出(含 exit_code,0 表示成功)。\n")
|
||||
}
|
||||
sb.WriteString("4. 循环:如果失败或问题未解决,分析原因,换一种方法重试,直到真正解决。不要因一次失败就放弃。\n")
|
||||
sb.WriteString("5. 收尾:问题解决后,用清晰的中文总结:问题根因、你做了什么、最终结果、后续建议。然后停止调用工具。\n\n")
|
||||
|
||||
sb.WriteString("【重要】务必真正解决问题,而不是假装解决。每一步都要基于实际命令输出判断," +
|
||||
"禁止编造不存在的输出。命令超时或连接失败时,向用户说明并建议排查方向。\n\n")
|
||||
sb.WriteString("【重要】务必真正解决问题,而不是假装解决。每一步都要基于实际工具输出判断," +
|
||||
"禁止编造不存在的输出。超时或 API 失败时,向用户说明并建议排查方向。\n\n")
|
||||
|
||||
// Layer 5: 记忆注入
|
||||
if strings.TrimSpace(evolveContext) != "" {
|
||||
sb.WriteString("【历史经验(来自自进化引擎)】\n" + evolveContext + "\n\n")
|
||||
}
|
||||
|
||||
// 输出要求
|
||||
sb.WriteString("【输出要求】使用简体中文,简洁专业。思考与命令穿插呈现,避免冗长空话。")
|
||||
return sb.String()
|
||||
}
|
||||
@@ -70,4 +98,4 @@ func FormatToolDescriptions(items []struct{ Name, Desc string }) string {
|
||||
sb.WriteString(fmt.Sprintf("- %s: %s\n", it.Name, it.Desc))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ func (t *AnalyzeOutputTool) Name() string { return "analyze_output" }
|
||||
// Description 给 LLM 的说明 // Logan.Li
|
||||
func (t *AnalyzeOutputTool) Description() string {
|
||||
return "分析一段(可能很长的)命令输出文本:统计行数、提取疑似错误/告警行、必要时截断保留首尾。" +
|
||||
"当 ssh_execute 返回内容过长时调用此工具提炼关键信息再继续推理。"
|
||||
"当 ssh_execute 或 pod_execute 返回内容过长时调用此工具提炼关键信息再继续推理。"
|
||||
}
|
||||
|
||||
// Parameters 参数定义 // Logan.Li
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// kubernetes_action.go - Kubernetes 集群变更操作(高风险,需用户确认) // Logan.Li
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
k8sclient "ezops/pkg/kubernetesclient"
|
||||
)
|
||||
|
||||
// KubernetesActionTool 执行 Kubernetes 变更动作 // Logan.Li
|
||||
type KubernetesActionTool struct {
|
||||
client *k8sclient.Client
|
||||
namespace string
|
||||
}
|
||||
|
||||
// NewKubernetesActionTool 创建变更工具 // Logan.Li
|
||||
func NewKubernetesActionTool(client *k8sclient.Client, namespace string) *KubernetesActionTool {
|
||||
return &KubernetesActionTool{client: client, namespace: namespace}
|
||||
}
|
||||
|
||||
// Name 工具名 // Logan.Li
|
||||
func (t *KubernetesActionTool) Name() string { return "kubernetes_action" }
|
||||
|
||||
// Description 给 LLM 的说明 // Logan.Li
|
||||
func (t *KubernetesActionTool) Description() string {
|
||||
return "对 Kubernetes 资源执行变更操作(高风险,需用户确认)。" +
|
||||
"action 支持: restart_deployment, scale_deployment, update_image, restart_statefulset, delete_pod, suspend_cronjob。" +
|
||||
"变更前请先用 kubernetes_get 确认当前状态。"
|
||||
}
|
||||
|
||||
// Parameters JSON Schema // Logan.Li
|
||||
func (t *KubernetesActionTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"action": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "操作类型: restart_deployment|scale_deployment|update_image|restart_statefulset|delete_pod|suspend_cronjob",
|
||||
},
|
||||
"namespace": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "命名空间,默认会话绑定命名空间",
|
||||
},
|
||||
"name": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "资源名称(Deployment/StatefulSet/CronJob/Pod)",
|
||||
},
|
||||
"replicas": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "scale_deployment 目标副本数",
|
||||
},
|
||||
"image": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "update_image 新镜像地址",
|
||||
},
|
||||
"container": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "update_image 目标容器名,可省略(默认第一个)",
|
||||
},
|
||||
"suspend": map[string]interface{}{
|
||||
"type": "boolean",
|
||||
"description": "suspend_cronjob: true=暂停, false=恢复",
|
||||
},
|
||||
},
|
||||
"required": []string{"action", "name"},
|
||||
}
|
||||
}
|
||||
|
||||
// RiskLevel 静态高风险 // Logan.Li
|
||||
func (t *KubernetesActionTool) RiskLevel() RiskLevel { return RiskHigh }
|
||||
|
||||
// Execute 执行变更 // Logan.Li
|
||||
func (t *KubernetesActionTool) Execute(ctx context.Context, args map[string]interface{}) (*Result, error) {
|
||||
action := strings.ToLower(strings.TrimSpace(StringParam(args, "action")))
|
||||
name := strings.TrimSpace(StringParam(args, "name"))
|
||||
ns := strings.TrimSpace(StringParam(args, "namespace"))
|
||||
if ns == "" {
|
||||
ns = t.namespace
|
||||
}
|
||||
if action == "" || name == "" {
|
||||
return &Result{Success: false, Error: "action 和 name 不能为空"}, nil
|
||||
}
|
||||
|
||||
var err error
|
||||
var msg string
|
||||
switch action {
|
||||
case "restart_deployment":
|
||||
err = t.client.RestartDeployment(ns, name)
|
||||
msg = fmt.Sprintf("已触发 Deployment %s/%s 滚动重启", ns, name)
|
||||
case "scale_deployment":
|
||||
replicas := IntParam(args, "replicas")
|
||||
if replicas < 0 {
|
||||
return &Result{Success: false, Error: "replicas 不能为负数"}, nil
|
||||
}
|
||||
err = t.client.ScaleDeployment(ns, name, replicas)
|
||||
msg = fmt.Sprintf("已将 Deployment %s/%s 扩缩容至 %d 副本", ns, name, replicas)
|
||||
case "update_image":
|
||||
image := strings.TrimSpace(StringParam(args, "image"))
|
||||
if image == "" {
|
||||
return &Result{Success: false, Error: "update_image 需要 image 参数"}, nil
|
||||
}
|
||||
container := strings.TrimSpace(StringParam(args, "container"))
|
||||
err = t.client.UpdateDeploymentImage(ns, name, container, image)
|
||||
msg = fmt.Sprintf("已更新 Deployment %s/%s 镜像为 %s", ns, name, image)
|
||||
case "restart_statefulset":
|
||||
err = t.client.RestartStatefulSet(ns, name)
|
||||
msg = fmt.Sprintf("已触发 StatefulSet %s/%s 滚动重启", ns, name)
|
||||
case "delete_pod":
|
||||
err = t.client.DeletePod(ns, name)
|
||||
msg = fmt.Sprintf("已删除 Pod %s/%s(控制器将视情况重建)", ns, name)
|
||||
case "suspend_cronjob":
|
||||
suspend := true
|
||||
if v, ok := args["suspend"].(bool); ok {
|
||||
suspend = v
|
||||
}
|
||||
err = t.client.SuspendCronJob(ns, name, suspend)
|
||||
if suspend {
|
||||
msg = fmt.Sprintf("已暂停 CronJob %s/%s", ns, name)
|
||||
} else {
|
||||
msg = fmt.Sprintf("已恢复 CronJob %s/%s", ns, name)
|
||||
}
|
||||
default:
|
||||
return &Result{Success: false, Error: fmt.Sprintf("不支持的 action: %s", action)}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return &Result{Success: false, Error: fmt.Sprintf("操作失败: %v", err)}, nil
|
||||
}
|
||||
return &Result{Success: true, Output: msg}, nil
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
// kubernetes_get.go - Kubernetes 集群/命名空间资源统一只读查询 // Logan.Li
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
k8sclient "ezops/pkg/kubernetesclient"
|
||||
)
|
||||
|
||||
// KubernetesGetTool 统一只读查询工具 // Logan.Li
|
||||
type KubernetesGetTool struct {
|
||||
client *k8sclient.Client
|
||||
namespace string // 会话默认命名空间
|
||||
}
|
||||
|
||||
// NewKubernetesGetTool 创建查询工具 // Logan.Li
|
||||
func NewKubernetesGetTool(client *k8sclient.Client, namespace string) *KubernetesGetTool {
|
||||
return &KubernetesGetTool{client: client, namespace: namespace}
|
||||
}
|
||||
|
||||
// Name 工具名 // Logan.Li
|
||||
func (t *KubernetesGetTool) Name() string { return "kubernetes_get" }
|
||||
|
||||
// Description 给 LLM 的说明 // Logan.Li
|
||||
func (t *KubernetesGetTool) Description() string {
|
||||
return "查询 Kubernetes 集群或命名空间资源状态(只读)。" +
|
||||
"resource 支持: nodes, pods, deployments, statefulsets, daemonsets, services, ingresses, events, namespaces, hpa, cronjobs。" +
|
||||
"用于盘点节点/Pod 健康、副本状态、事件告警、服务路由等。返回结构化摘要,非原始 JSON。"
|
||||
}
|
||||
|
||||
// Parameters JSON Schema // Logan.Li
|
||||
func (t *KubernetesGetTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"resource": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "资源类型: nodes|pods|deployments|statefulsets|daemonsets|services|ingresses|events|namespaces|hpa|cronjobs",
|
||||
},
|
||||
"namespace": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "命名空间;pods/deployments 等命名空间级资源需要;空则用会话绑定命名空间",
|
||||
},
|
||||
"name": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "可选,查询单个资源名",
|
||||
},
|
||||
"label_selector": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "可选,标签筛选,格式 app=nginx 或 app=nginx,env=prod",
|
||||
},
|
||||
"status_filter": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "可选,Pod 状态筛选,如 Running、Pending、CrashLoopBackOff、NotRunning",
|
||||
},
|
||||
},
|
||||
"required": []string{"resource"},
|
||||
}
|
||||
}
|
||||
|
||||
// RiskLevel 只读 // Logan.Li
|
||||
func (t *KubernetesGetTool) RiskLevel() RiskLevel { return RiskSafe }
|
||||
|
||||
// Execute 执行查询 // Logan.Li
|
||||
func (t *KubernetesGetTool) Execute(ctx context.Context, args map[string]interface{}) (*Result, error) {
|
||||
resource := strings.ToLower(strings.TrimSpace(StringParam(args, "resource")))
|
||||
if resource == "" {
|
||||
return &Result{Success: false, Error: "resource 不能为空"}, nil
|
||||
}
|
||||
ns := strings.TrimSpace(StringParam(args, "namespace"))
|
||||
if ns == "" {
|
||||
ns = t.namespace
|
||||
}
|
||||
name := strings.TrimSpace(StringParam(args, "name"))
|
||||
labelSel := parseLabelSelector(StringParam(args, "label_selector"))
|
||||
statusFilter := strings.TrimSpace(StringParam(args, "status_filter"))
|
||||
|
||||
var output string
|
||||
var err error
|
||||
switch resource {
|
||||
case "nodes", "node":
|
||||
output, err = t.formatNodes(name)
|
||||
case "pods", "pod":
|
||||
output, err = t.formatPods(ns, name, labelSel, statusFilter)
|
||||
case "deployments", "deployment", "deploy":
|
||||
output, err = t.formatDeployments(ns, name)
|
||||
case "statefulsets", "statefulset", "sts":
|
||||
output, err = t.formatStatefulSets(ns, name)
|
||||
case "daemonsets", "daemonset", "ds":
|
||||
output, err = t.formatDaemonSets(ns, name)
|
||||
case "services", "service", "svc":
|
||||
output, err = t.formatServices(ns, name)
|
||||
case "ingresses", "ingress", "ing":
|
||||
output, err = t.formatIngresses(ns, name)
|
||||
case "events", "event":
|
||||
output, err = t.formatEvents(ns, name)
|
||||
case "namespaces", "namespace", "ns":
|
||||
output, err = t.formatNamespaces(name)
|
||||
case "hpa", "hpas":
|
||||
output, err = t.formatHPAs(ns, name)
|
||||
case "cronjobs", "cronjob":
|
||||
output, err = t.formatCronJobs(ns, name)
|
||||
default:
|
||||
return &Result{Success: false, Error: fmt.Sprintf("不支持的 resource: %s", resource)}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return &Result{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
return &Result{Success: true, Output: output}, nil
|
||||
}
|
||||
|
||||
func parseLabelSelector(raw string) map[string]string {
|
||||
out := make(map[string]string)
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return out
|
||||
}
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if kv := strings.SplitN(part, "=", 2); len(kv) == 2 {
|
||||
out[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func labelsMatch(labels, selector map[string]string) bool {
|
||||
if len(selector) == 0 {
|
||||
return true
|
||||
}
|
||||
for k, v := range selector {
|
||||
if labels[k] != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func podDisplayStatus(p k8sclient.PodItem) string {
|
||||
phase := p.Status.Phase
|
||||
for _, cs := range p.Status.ContainerStatuses {
|
||||
if !cs.Ready && cs.RestartCount > 0 {
|
||||
return "CrashLoopBackOff"
|
||||
}
|
||||
}
|
||||
for _, c := range p.Status.Conditions {
|
||||
if c.Type == "Ready" && c.Status != "True" && c.Reason != "" {
|
||||
return c.Reason
|
||||
}
|
||||
}
|
||||
return phase
|
||||
}
|
||||
|
||||
func matchPodStatus(status, filter string) bool {
|
||||
if filter == "" {
|
||||
return true
|
||||
}
|
||||
f := strings.ToLower(filter)
|
||||
s := strings.ToLower(status)
|
||||
if f == "notrunning" {
|
||||
return s != "running" && s != "succeeded" && s != "completed"
|
||||
}
|
||||
return strings.Contains(s, f)
|
||||
}
|
||||
|
||||
func (t *KubernetesGetTool) formatNodes(name string) (string, error) {
|
||||
list, err := t.client.ListNodes()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Nodes (%d):\n", len(list.Items)))
|
||||
for _, n := range list.Items {
|
||||
if name != "" && n.Metadata.Name != name {
|
||||
continue
|
||||
}
|
||||
ready := "Unknown"
|
||||
for _, c := range n.Status.Conditions {
|
||||
if c.Type == "Ready" {
|
||||
ready = c.Status
|
||||
break
|
||||
}
|
||||
}
|
||||
ip := ""
|
||||
for _, a := range n.Status.Addresses {
|
||||
if a.Type == "InternalIP" {
|
||||
ip = a.Address
|
||||
break
|
||||
}
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("- %s Ready=%s IP=%s OS=%s kubelet=%s\n",
|
||||
n.Metadata.Name, ready, ip, n.Status.NodeInfo.OSImage, n.Status.NodeInfo.KubeletVersion))
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func (t *KubernetesGetTool) formatPods(ns, name string, labelSel map[string]string, statusFilter string) (string, error) {
|
||||
list, err := t.client.ListPods()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
type row struct{ line string }
|
||||
var rows []row
|
||||
for _, p := range list.Items {
|
||||
if ns != "" && p.Metadata.Namespace != ns {
|
||||
continue
|
||||
}
|
||||
if name != "" && p.Metadata.Name != name {
|
||||
continue
|
||||
}
|
||||
if !labelsMatch(p.Metadata.Labels, labelSel) {
|
||||
continue
|
||||
}
|
||||
st := podDisplayStatus(p)
|
||||
if !matchPodStatus(st, statusFilter) {
|
||||
continue
|
||||
}
|
||||
image := ""
|
||||
if len(p.Spec.Containers) > 0 {
|
||||
image = p.Spec.Containers[0].Image
|
||||
}
|
||||
restarts := 0
|
||||
if len(p.Status.ContainerStatuses) > 0 {
|
||||
restarts = p.Status.ContainerStatuses[0].RestartCount
|
||||
}
|
||||
rows = append(rows, row{line: fmt.Sprintf("- %s/%s %s restarts=%d node=%s ip=%s image=%s\n",
|
||||
p.Metadata.Namespace, p.Metadata.Name, st, restarts, p.Spec.NodeName, p.Status.PodIP, image)})
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].line < rows[j].line })
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Pods (%d):\n", len(rows)))
|
||||
for _, r := range rows {
|
||||
sb.WriteString(r.line)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
sb.WriteString("(无匹配 Pod)\n")
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func (t *KubernetesGetTool) formatDeployments(ns, name string) (string, error) {
|
||||
list, err := t.client.ListDeployments()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var sb strings.Builder
|
||||
count := 0
|
||||
sb.WriteString("Deployments:\n")
|
||||
for _, d := range list.Items {
|
||||
if ns != "" && d.Metadata.Namespace != ns {
|
||||
continue
|
||||
}
|
||||
if name != "" && d.Metadata.Name != name {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
sb.WriteString(fmt.Sprintf("- %s/%s replicas=%d/%d ready=%d available=%d paused=%v\n",
|
||||
d.Metadata.Namespace, d.Metadata.Name,
|
||||
d.Status.ReadyReplicas, d.Spec.Replicas,
|
||||
d.Status.ReadyReplicas, d.Status.AvailableReplicas, d.Spec.Paused))
|
||||
}
|
||||
if count == 0 {
|
||||
sb.WriteString("(无匹配 Deployment)\n")
|
||||
} else {
|
||||
sb.Reset()
|
||||
sb.WriteString(fmt.Sprintf("Deployments (%d):\n", count))
|
||||
for _, d := range list.Items {
|
||||
if ns != "" && d.Metadata.Namespace != ns {
|
||||
continue
|
||||
}
|
||||
if name != "" && d.Metadata.Name != name {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("- %s/%s replicas=%d/%d ready=%d available=%d paused=%v\n",
|
||||
d.Metadata.Namespace, d.Metadata.Name,
|
||||
d.Status.ReadyReplicas, d.Spec.Replicas,
|
||||
d.Status.ReadyReplicas, d.Status.AvailableReplicas, d.Spec.Paused))
|
||||
}
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func (t *KubernetesGetTool) formatStatefulSets(ns, name string) (string, error) {
|
||||
list, err := t.client.ListStatefulSets()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var lines []string
|
||||
for _, s := range list.Items {
|
||||
if ns != "" && s.Metadata.Namespace != ns {
|
||||
continue
|
||||
}
|
||||
if name != "" && s.Metadata.Name != name {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("- %s/%s replicas=%d/%d ready=%d\n",
|
||||
s.Metadata.Namespace, s.Metadata.Name, s.Status.ReadyReplicas, s.Spec.Replicas, s.Status.ReadyReplicas))
|
||||
}
|
||||
sort.Strings(lines)
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("StatefulSets (%d):\n", len(lines)))
|
||||
if len(lines) == 0 {
|
||||
sb.WriteString("(无匹配 StatefulSet)\n")
|
||||
} else {
|
||||
for _, l := range lines {
|
||||
sb.WriteString(l + "\n")
|
||||
}
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func (t *KubernetesGetTool) formatDaemonSets(ns, name string) (string, error) {
|
||||
list, err := t.client.ListDaemonSets()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var lines []string
|
||||
for _, d := range list.Items {
|
||||
if ns != "" && d.Metadata.Namespace != ns {
|
||||
continue
|
||||
}
|
||||
if name != "" && d.Metadata.Name != name {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("- %s/%s desired=%d ready=%d\n",
|
||||
d.Metadata.Namespace, d.Metadata.Name, d.Status.DesiredNumberScheduled,
|
||||
d.Status.NumberReady))
|
||||
}
|
||||
sort.Strings(lines)
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("DaemonSets (%d):\n", len(lines)))
|
||||
for _, l := range lines {
|
||||
sb.WriteString(l + "\n")
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func (t *KubernetesGetTool) formatServices(ns, name string) (string, error) {
|
||||
list, err := t.client.ListServices()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var lines []string
|
||||
for _, s := range list.Items {
|
||||
if ns != "" && s.Metadata.Namespace != ns {
|
||||
continue
|
||||
}
|
||||
if name != "" && s.Metadata.Name != name {
|
||||
continue
|
||||
}
|
||||
ports := ""
|
||||
for i, p := range s.Spec.Ports {
|
||||
if i > 0 {
|
||||
ports += ","
|
||||
}
|
||||
ports += fmt.Sprintf("%d", p.Port)
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("- %s/%s type=%s clusterIP=%s ports=[%s] selector=%v\n",
|
||||
s.Metadata.Namespace, s.Metadata.Name, s.Spec.Type, s.Spec.ClusterIP, ports, s.Spec.Selector))
|
||||
}
|
||||
sort.Strings(lines)
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Services (%d):\n", len(lines)))
|
||||
for _, l := range lines {
|
||||
sb.WriteString(l)
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func (t *KubernetesGetTool) formatIngresses(ns, name string) (string, error) {
|
||||
list, err := t.client.ListIngresses()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var lines []string
|
||||
for _, ing := range list.Items {
|
||||
if ns != "" && ing.Metadata.Namespace != ns {
|
||||
continue
|
||||
}
|
||||
if name != "" && ing.Metadata.Name != name {
|
||||
continue
|
||||
}
|
||||
hosts := []string{}
|
||||
for _, r := range ing.Spec.Rules {
|
||||
if r.Host != "" {
|
||||
hosts = append(hosts, r.Host)
|
||||
}
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("- %s/%s hosts=%v\n", ing.Metadata.Namespace, ing.Metadata.Name, hosts))
|
||||
}
|
||||
sort.Strings(lines)
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Ingresses (%d):\n", len(lines)))
|
||||
for _, l := range lines {
|
||||
sb.WriteString(l + "\n")
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func (t *KubernetesGetTool) formatEvents(ns, involvedName string) (string, error) {
|
||||
list, err := t.client.ListEvents(ns)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
type evRow struct {
|
||||
ts time.Time
|
||||
line string
|
||||
}
|
||||
var rows []evRow
|
||||
for _, e := range list.Items {
|
||||
if involvedName != "" && e.InvolvedObject.Name != involvedName {
|
||||
continue
|
||||
}
|
||||
ts := e.LastTimestamp
|
||||
if ts.IsZero() {
|
||||
ts = e.EventTime
|
||||
}
|
||||
rows = append(rows, evRow{
|
||||
ts: ts,
|
||||
line: fmt.Sprintf("[%s] %s %s/%s: %s — %s (x%d)\n",
|
||||
e.Type, e.Reason, e.InvolvedObject.Kind, e.InvolvedObject.Name, e.Message,
|
||||
e.InvolvedObject.Namespace, e.Count),
|
||||
})
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].ts.After(rows[j].ts) })
|
||||
if len(rows) > 30 {
|
||||
rows = rows[:30]
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Events (recent %d):\n", len(rows)))
|
||||
for _, r := range rows {
|
||||
sb.WriteString(r.line)
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func (t *KubernetesGetTool) formatNamespaces(name string) (string, error) {
|
||||
names, err := t.client.ListNamespaces()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sort.Strings(names)
|
||||
var sb strings.Builder
|
||||
count := 0
|
||||
sb.WriteString("Namespaces:\n")
|
||||
for _, n := range names {
|
||||
if name != "" && n != name {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
sb.WriteString(fmt.Sprintf("- %s\n", n))
|
||||
}
|
||||
if count == 0 {
|
||||
sb.WriteString("(无匹配命名空间)\n")
|
||||
} else {
|
||||
sb.Reset()
|
||||
sb.WriteString(fmt.Sprintf("Namespaces (%d):\n", count))
|
||||
for _, n := range names {
|
||||
if name != "" && n != name {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("- %s\n", n))
|
||||
}
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func (t *KubernetesGetTool) formatHPAs(ns, name string) (string, error) {
|
||||
list, err := t.client.ListHPAs()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var lines []string
|
||||
for _, h := range list.Items {
|
||||
if ns != "" && h.Metadata.Namespace != ns {
|
||||
continue
|
||||
}
|
||||
if name != "" && h.Metadata.Name != name {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("- %s/%s target=%s/%s min=%d max=%d current=%d\n",
|
||||
h.Metadata.Namespace, h.Metadata.Name,
|
||||
h.Spec.ScaleTargetRef.Kind, h.Spec.ScaleTargetRef.Name,
|
||||
h.Spec.MinReplicas, h.Spec.MaxReplicas, h.Status.CurrentReplicas))
|
||||
}
|
||||
sort.Strings(lines)
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("HPAs (%d):\n", len(lines)))
|
||||
for _, l := range lines {
|
||||
sb.WriteString(l)
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func (t *KubernetesGetTool) formatCronJobs(ns, name string) (string, error) {
|
||||
list, err := t.client.ListCronJobs()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var lines []string
|
||||
for _, cj := range list.Items {
|
||||
if ns != "" && cj.Metadata.Namespace != ns {
|
||||
continue
|
||||
}
|
||||
if name != "" && cj.Metadata.Name != name {
|
||||
continue
|
||||
}
|
||||
suspended := false
|
||||
if cj.Spec.Suspend != nil {
|
||||
suspended = *cj.Spec.Suspend
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("- %s/%s schedule=%s suspend=%v active=%d\n",
|
||||
cj.Metadata.Namespace, cj.Metadata.Name, cj.Spec.Schedule, suspended, len(cj.Status.Active)))
|
||||
}
|
||||
sort.Strings(lines)
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("CronJobs (%d):\n", len(lines)))
|
||||
for _, l := range lines {
|
||||
sb.WriteString(l)
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// pod_execute.go - 在目标 Pod 容器内执行 shell 命令 // Logan.Li
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
k8sclient "ezops/pkg/kubernetesclient"
|
||||
)
|
||||
|
||||
// PodExecuteTool 在 Pod 容器内执行命令(K8s exec WebSocket 协议) // Logan.Li
|
||||
type PodExecuteTool struct {
|
||||
client *k8sclient.Client
|
||||
namespace string
|
||||
podName string
|
||||
container string
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// NewPodExecuteTool 创建 Pod 执行工具 // Logan.Li
|
||||
func NewPodExecuteTool(client *k8sclient.Client, namespace, podName, container string, timeout time.Duration) *PodExecuteTool {
|
||||
if timeout <= 0 {
|
||||
timeout = 60 * time.Second
|
||||
}
|
||||
return &PodExecuteTool{
|
||||
client: client, namespace: namespace, podName: podName,
|
||||
container: container, timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// Name 工具名 // Logan.Li
|
||||
func (t *PodExecuteTool) Name() string { return "pod_execute" }
|
||||
|
||||
// Description 给 LLM 的工具说明 // Logan.Li
|
||||
func (t *PodExecuteTool) Description() string {
|
||||
return "在已选定的 Kubernetes Pod 容器内执行 shell 命令并返回 stdout+stderr。" +
|
||||
"用于容器内排障(查看进程/配置/连通性)、应用调试。" +
|
||||
"注意:容器内可能没有 systemctl 或宿主机路径;多容器 Pod 可通过 container 参数指定。" +
|
||||
"高危命令需用户确认。"
|
||||
}
|
||||
|
||||
// Parameters JSON Schema // Logan.Li
|
||||
func (t *PodExecuteTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"command": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "要执行的 shell 命令,例如 ps aux、cat /etc/nginx/nginx.conf、curl -s localhost:8080/health",
|
||||
},
|
||||
"container": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "可选,多容器 Pod 时指定容器名;默认使用会话绑定的容器",
|
||||
},
|
||||
"timeout": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "命令超时秒数(默认 60)",
|
||||
},
|
||||
},
|
||||
"required": []string{"command"},
|
||||
}
|
||||
}
|
||||
|
||||
// RiskLevel 静态风险 // Logan.Li
|
||||
func (t *PodExecuteTool) RiskLevel() RiskLevel { return RiskHigh }
|
||||
|
||||
// RiskLevelForArgs 依据命令动态评估风险 // Logan.Li
|
||||
func (t *PodExecuteTool) RiskLevelForArgs(args map[string]interface{}) RiskLevel {
|
||||
return AssessBashRisk(StringParam(args, "command"))
|
||||
}
|
||||
|
||||
// Execute 在 Pod 内执行命令 // Logan.Li
|
||||
func (t *PodExecuteTool) Execute(ctx context.Context, args map[string]interface{}) (*Result, error) {
|
||||
command := strings.TrimSpace(StringParam(args, "command"))
|
||||
if command == "" {
|
||||
return &Result{Success: false, Error: "参数 command 不能为空"}, nil
|
||||
}
|
||||
if t.podName == "" {
|
||||
return &Result{Success: false, Error: "未绑定目标 Pod,请在前端选择 Pod 或在命令前先 kubernetes_get 确认"}, nil
|
||||
}
|
||||
|
||||
container := strings.TrimSpace(StringParam(args, "container"))
|
||||
if container == "" {
|
||||
container = t.container
|
||||
}
|
||||
|
||||
timeout := t.timeout
|
||||
if sec := IntParam(args, "timeout"); sec > 0 {
|
||||
timeout = time.Duration(sec) * time.Second
|
||||
}
|
||||
|
||||
execCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
// 用 sh -c 保持 shell 语义,并嵌入 exit_code 与 ssh_execute 对齐 // Logan.Li
|
||||
wrapped := command + "; echo [exit_code=$?]"
|
||||
done := make(chan struct {
|
||||
res *k8sclient.ExecResult
|
||||
err error
|
||||
}, 1)
|
||||
go func() {
|
||||
r, err := t.client.ExecInPod(t.namespace, t.podName, container, []string{"sh", "-c", wrapped})
|
||||
done <- struct {
|
||||
res *k8sclient.ExecResult
|
||||
err error
|
||||
}{r, err}
|
||||
}()
|
||||
|
||||
var result *k8sclient.ExecResult
|
||||
var err error
|
||||
select {
|
||||
case <-execCtx.Done():
|
||||
return &Result{Success: false, Error: "命令执行超时"}, nil
|
||||
case out := <-done:
|
||||
result, err = out.res, out.err
|
||||
}
|
||||
if err != nil {
|
||||
return &Result{Success: false, Error: fmt.Sprintf("Pod exec 失败: %v", err)}, nil
|
||||
}
|
||||
|
||||
output := strings.TrimSpace(result.Stdout)
|
||||
if result.Stderr != "" {
|
||||
if output != "" {
|
||||
output += "\n"
|
||||
}
|
||||
output += strings.TrimSpace(result.Stderr)
|
||||
}
|
||||
success := strings.Contains(output, "[exit_code=0]")
|
||||
if !success && !strings.Contains(output, "[exit_code=") {
|
||||
// 无 exit_code 标记时,无 stderr 视为成功 // Logan.Li
|
||||
success = strings.TrimSpace(result.Stderr) == ""
|
||||
}
|
||||
|
||||
return &Result{Success: success, Output: output}, nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// pod_logs.go - 获取 Pod 容器日志(只读) // Logan.Li
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
k8sclient "ezops/pkg/kubernetesclient"
|
||||
)
|
||||
|
||||
// PodLogsTool 拉取 Pod 日志 // Logan.Li
|
||||
type PodLogsTool struct {
|
||||
client *k8sclient.Client
|
||||
namespace string
|
||||
podName string
|
||||
container string
|
||||
}
|
||||
|
||||
// NewPodLogsTool 创建日志工具 // Logan.Li
|
||||
func NewPodLogsTool(client *k8sclient.Client, namespace, podName, container string) *PodLogsTool {
|
||||
return &PodLogsTool{client: client, namespace: namespace, podName: podName, container: container}
|
||||
}
|
||||
|
||||
// Name 工具名 // Logan.Li
|
||||
func (t *PodLogsTool) Name() string { return "pod_logs" }
|
||||
|
||||
// Description 给 LLM 的说明 // Logan.Li
|
||||
func (t *PodLogsTool) Description() string {
|
||||
return "获取目标 Pod 容器的最近日志(只读)。排障时优先使用此工具,比 pod_execute tail 更高效。" +
|
||||
"未选 Pod 时需通过 name 参数指定 Pod 名。"
|
||||
}
|
||||
|
||||
// Parameters JSON Schema // Logan.Li
|
||||
func (t *PodLogsTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"pod": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Pod 名称;会话已绑定 Pod 时可省略",
|
||||
},
|
||||
"container": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "容器名;多容器时指定,默认会话绑定容器",
|
||||
},
|
||||
"tail_lines": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "返回最近行数,默认 200,最大 2000",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// RiskLevel 只读安全 // Logan.Li
|
||||
func (t *PodLogsTool) RiskLevel() RiskLevel { return RiskSafe }
|
||||
|
||||
// Execute 拉取日志 // Logan.Li
|
||||
func (t *PodLogsTool) Execute(ctx context.Context, args map[string]interface{}) (*Result, error) {
|
||||
pod := strings.TrimSpace(StringParam(args, "pod"))
|
||||
if pod == "" {
|
||||
pod = t.podName
|
||||
}
|
||||
if pod == "" {
|
||||
return &Result{Success: false, Error: "请指定 pod 参数或在前端选择目标 Pod"}, nil
|
||||
}
|
||||
|
||||
container := strings.TrimSpace(StringParam(args, "container"))
|
||||
if container == "" {
|
||||
container = t.container
|
||||
}
|
||||
|
||||
tail := IntParam(args, "tail_lines")
|
||||
if tail <= 0 {
|
||||
tail = 200
|
||||
}
|
||||
if tail > 2000 {
|
||||
tail = 2000
|
||||
}
|
||||
|
||||
logs, err := t.client.GetPodLogs(t.namespace, pod, container, tail)
|
||||
if err != nil {
|
||||
return &Result{Success: false, Error: fmt.Sprintf("获取日志失败: %v", err)}, nil
|
||||
}
|
||||
if strings.TrimSpace(logs) == "" {
|
||||
return &Result{Success: true, Output: "(日志为空)"}, nil
|
||||
}
|
||||
return &Result{Success: true, Output: logs}, nil
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// 云账单同步引擎 — 从云账号 AK 拉取日账单写入本地 // Logan.Li
|
||||
package billingsync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"ezops/cloud"
|
||||
"ezops/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Result 同步结果
|
||||
type Result struct {
|
||||
BillingDate string `json:"billing_date"`
|
||||
Accounts int `json:"accounts"`
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
Skipped int `json:"skipped"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
||||
// Yesterday 昨日日期 YYYY-MM-DD
|
||||
func Yesterday() string {
|
||||
return time.Now().AddDate(0, 0, -1).Format("2006-01-02")
|
||||
}
|
||||
|
||||
// SyncDate 同步指定日期账单(全部或单个活跃云账号)
|
||||
func SyncDate(db *gorm.DB, billingDate string, accountID *uint) Result {
|
||||
result := Result{
|
||||
BillingDate: billingDate,
|
||||
Errors: []string{},
|
||||
}
|
||||
|
||||
var accounts []model.CloudAccount
|
||||
q := db.Where("status = ?", "active")
|
||||
if accountID != nil {
|
||||
q = q.Where("id = ?", *accountID)
|
||||
}
|
||||
q.Find(&accounts)
|
||||
result.Accounts = len(accounts)
|
||||
|
||||
if len(accounts) == 0 {
|
||||
result.Errors = append(result.Errors, "没有可同步的活跃云账号")
|
||||
return result
|
||||
}
|
||||
|
||||
for _, account := range accounts {
|
||||
created, updated, skipped, err := syncAccount(db, account, billingDate)
|
||||
result.Created += created
|
||||
result.Updated += updated
|
||||
result.Skipped += skipped
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("%s(%s): %s", account.Name, account.Provider, err.Error()))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// SummaryMessage 生成执行摘要文案
|
||||
func SummaryMessage(r Result) string {
|
||||
msg := fmt.Sprintf("%s 新增 %d 条,更新 %d 条", r.BillingDate, r.Created, r.Updated)
|
||||
if len(r.Errors) > 0 {
|
||||
msg += fmt.Sprintf(";%d 个账号异常", len(r.Errors))
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func syncAccount(db *gorm.DB, account model.CloudAccount, billingDate string) (created, updated, skipped int, err error) {
|
||||
fetcher := cloud.NewBillingFetcher(account.Provider, account.AccessKey, account.SecretKey)
|
||||
if fetcher == nil {
|
||||
return 0, 0, 0, fmt.Errorf("暂不支持该云厂商账单同步")
|
||||
}
|
||||
|
||||
records, err := fetcher.FetchDailyBill(billingDate)
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return 0, 0, 1, fmt.Errorf("该日期暂无账单数据(可能尚未出账)")
|
||||
}
|
||||
|
||||
accountID := account.ID
|
||||
accountName := account.Name
|
||||
|
||||
for _, rec := range records {
|
||||
var existing model.CloudBilling
|
||||
findErr := db.Where(
|
||||
"cloud_account_id = ? AND billing_date = ? AND provider = ? AND service = ? AND region = ?",
|
||||
accountID, billingDate, rec.Provider, rec.Service, rec.Region,
|
||||
).Take(&existing).Error
|
||||
|
||||
if findErr == gorm.ErrRecordNotFound {
|
||||
row := model.CloudBilling{
|
||||
CloudAccountID: &accountID,
|
||||
AccountName: accountName,
|
||||
Provider: rec.Provider,
|
||||
BillingDate: billingDate,
|
||||
Cost: rec.Cost,
|
||||
Currency: rec.Currency,
|
||||
Service: rec.Service,
|
||||
Region: rec.Region,
|
||||
Description: rec.Description,
|
||||
}
|
||||
if row.Currency == "" {
|
||||
row.Currency = "CNY"
|
||||
}
|
||||
if db.Create(&row).Error == nil {
|
||||
created++
|
||||
} else {
|
||||
skipped++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if findErr != nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
if db.Model(&existing).Updates(map[string]interface{}{
|
||||
"cost": rec.Cost,
|
||||
"currency": rec.Currency,
|
||||
"description": rec.Description,
|
||||
"account_name": accountName,
|
||||
}).Error == nil {
|
||||
updated++
|
||||
} else {
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
|
||||
monthStart := billingDate[:7] + "-01"
|
||||
var monthCost float64
|
||||
db.Model(&model.CloudBilling{}).
|
||||
Where("cloud_account_id = ? AND billing_date >= ?", accountID, monthStart).
|
||||
Select("COALESCE(SUM(cost), 0)").Scan(&monthCost)
|
||||
db.Model(&account).Update("monthly_cost", monthCost)
|
||||
|
||||
return created, updated, skipped, nil
|
||||
}
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
"ezops/model"
|
||||
"ezops/pkg/appdir"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
@@ -91,8 +91,8 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
&model.KubernetesIngress{},
|
||||
&model.AlertRule{},
|
||||
&model.AlertEvent{},
|
||||
&model.AlertContact{}, // 报警联系人 // Logan.Li
|
||||
&model.AlertContactGroup{}, // 报警联系组 // Logan.Li
|
||||
&model.AlertContact{}, // 报警联系人 // Logan.Li
|
||||
&model.AlertContactGroup{}, // 报警联系组 // Logan.Li
|
||||
&model.AlertNotifyPolicy{}, // 告警集合通知策略 // Logan.Li
|
||||
&model.AlertNotifySettings{}, // 告警消息模版 // Logan.Li
|
||||
&model.NotifyChannel{},
|
||||
@@ -110,9 +110,9 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
&model.NavigationGroup{},
|
||||
&model.NavigationLink{},
|
||||
&model.AuditLog{},
|
||||
&model.AuditSettings{}, // 审计保留策略 // Logan.Li
|
||||
&model.LoginLog{}, // 登录日志 // Logan.Li
|
||||
&model.SystemTaskStatus{}, // 内置系统任务状态 // Logan.Li
|
||||
&model.AuditSettings{}, // 审计保留策略 // Logan.Li
|
||||
&model.LoginLog{}, // 登录日志 // Logan.Li
|
||||
&model.SystemTaskStatus{}, // 内置系统任务状态 // Logan.Li
|
||||
&model.LDAPConfig{},
|
||||
&model.ResourceModel{},
|
||||
&model.CloudBilling{},
|
||||
@@ -126,54 +126,55 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
&model.AnsibleInventory{},
|
||||
&model.LogSource{},
|
||||
&model.LogEntry{},
|
||||
&model.TopologyLayout{}, // 拓扑布局保存 // Logan.Li
|
||||
&model.TerminalSession{}, // 终端会话录制(终端审计) // Logan.Li
|
||||
&model.SessionCommand{}, // 终端会话命令审计 // Logan.Li
|
||||
&model.LLMConfig{}, // 大语言模型配置(AI 智能助手) // Logan.Li
|
||||
&model.ChatHistory{}, // 对话历史记录(按用户隔离) // Logan.Li
|
||||
&model.MetricsAggregate{}, // 指标时序聚合(Redis → MySQL)// Logan.Li
|
||||
&model.MonitorTarget{}, // 监控目标(主机/中间件/设备/站点) // Logan.Li
|
||||
&model.MonitorMetric{}, // 监控指标定义 // Logan.Li
|
||||
&model.MetricValue{}, // 指标当前值 // Logan.Li
|
||||
&model.MetricHistory{}, // 指标历史时序 // Logan.Li
|
||||
&model.HostGroup{}, // 主机组 // Logan.Li
|
||||
&model.SNMPDevice{}, // SNMP设备 // Logan.Li
|
||||
&model.SNMPTemplate{}, // SNMP模板 // Logan.Li
|
||||
&model.SNMPTemplateOID{}, // SNMP模板OID // Logan.Li
|
||||
&model.SNMPMetric{}, // SNMP采集指标 // Logan.Li
|
||||
&model.SNMPInterface{}, // SNMP接口信息 // Logan.Li
|
||||
&model.SiteTask{}, // 站点拨测任务 // Logan.Li
|
||||
&model.SiteResult{}, // 拨测结果 // Logan.Li
|
||||
&model.SiteAlert{}, // 拨测告警 // Logan.Li
|
||||
&model.MonitorAlertRule{}, // 中间件/站点拨测告警规则 // Logan.Li
|
||||
&model.TopologyLayout{}, // 拓扑布局保存 // Logan.Li
|
||||
&model.TerminalSession{}, // 终端会话录制(终端审计) // Logan.Li
|
||||
&model.RDPSession{}, // RDP 图形会话审计 // Logan.Li
|
||||
&model.SessionCommand{}, // 终端会话命令审计 // Logan.Li
|
||||
&model.LLMConfig{}, // 大语言模型配置(AI 智能助手) // Logan.Li
|
||||
&model.ChatHistory{}, // 对话历史记录(按用户隔离) // Logan.Li
|
||||
&model.MetricsAggregate{}, // 指标时序聚合(Redis → MySQL)// Logan.Li
|
||||
&model.MonitorTarget{}, // 监控目标(主机/中间件/设备/站点) // Logan.Li
|
||||
&model.MonitorMetric{}, // 监控指标定义 // Logan.Li
|
||||
&model.MetricValue{}, // 指标当前值 // Logan.Li
|
||||
&model.MetricHistory{}, // 指标历史时序 // Logan.Li
|
||||
&model.HostGroup{}, // 主机组 // Logan.Li
|
||||
&model.SNMPDevice{}, // SNMP设备 // Logan.Li
|
||||
&model.SNMPTemplate{}, // SNMP模板 // Logan.Li
|
||||
&model.SNMPTemplateOID{}, // SNMP模板OID // Logan.Li
|
||||
&model.SNMPMetric{}, // SNMP采集指标 // Logan.Li
|
||||
&model.SNMPInterface{}, // SNMP接口信息 // Logan.Li
|
||||
&model.SiteTask{}, // 站点拨测任务 // Logan.Li
|
||||
&model.SiteResult{}, // 拨测结果 // Logan.Li
|
||||
&model.SiteAlert{}, // 拨测告警 // Logan.Li
|
||||
&model.MonitorAlertRule{}, // 中间件/站点拨测告警规则 // Logan.Li
|
||||
&model.PrometheusDataSource{}, // Prometheus 数据源配置 // Logan.Li
|
||||
&model.ZabbixDataSource{}, // Zabbix 数据源配置 // Logan.Li
|
||||
&model.LogDataSource{}, // Loki / ES 日志数据源 // Logan.Li
|
||||
&model.Credential{}, // 凭据管理(SSH密钥/API Token) // Logan.Li
|
||||
&model.PipelineRepo{}, // Git 仓库认证 // Logan.Li
|
||||
&model.PipelineRegistry{}, // 镜像仓库认证 // Logan.Li
|
||||
&model.DeployEnvironment{}, // 发布环境分组 // Logan.Li
|
||||
&model.DeployPipelineMember{}, // 流水线成员 // Logan.Li
|
||||
&model.DeployUserGroup{}, // 用户组 // Logan.Li
|
||||
&model.Credential{}, // 凭据管理(SSH密钥/API Token) // Logan.Li
|
||||
&model.PipelineRepo{}, // Git 仓库认证 // Logan.Li
|
||||
&model.PipelineRegistry{}, // 镜像仓库认证 // Logan.Li
|
||||
&model.DeployEnvironment{}, // 发布环境分组 // Logan.Li
|
||||
&model.DeployPipelineMember{}, // 流水线成员 // Logan.Li
|
||||
&model.DeployUserGroup{}, // 用户组 // Logan.Li
|
||||
&model.DeployUserGroupMember{}, // 用户组成员 // Logan.Li
|
||||
&model.ItemRole{}, // Jenkins-style Item Role (regex pipeline matching) // Logan.Li
|
||||
&model.UserItemRole{}, // User ↔ ItemRole association // Logan.Li
|
||||
&model.LogScopeRole{}, // 日志查询范围权限(正则匹配索引/Label)// Logan.Li
|
||||
&model.UserLogScopeRole{}, // 用户 ↔ 日志范围权限 // Logan.Li
|
||||
&model.BuildMasterConfig{}, // Master 构建节点并发配置 // Logan.Li
|
||||
&model.DeployTool{}, // 工具配置(懒加载/预装/禁用) // Logan.Li
|
||||
&model.ItemRole{}, // Jenkins-style Item Role (regex pipeline matching) // Logan.Li
|
||||
&model.UserItemRole{}, // User ↔ ItemRole association // Logan.Li
|
||||
&model.LogScopeRole{}, // 日志查询范围权限(正则匹配索引/Label)// Logan.Li
|
||||
&model.UserLogScopeRole{}, // 用户 ↔ 日志范围权限 // Logan.Li
|
||||
&model.BuildMasterConfig{}, // Master 构建节点并发配置 // Logan.Li
|
||||
&model.DeployTool{}, // 工具配置(懒加载/预装/禁用) // Logan.Li
|
||||
&model.AlertCondition{}, // 告警条件 // Logan.Li
|
||||
&model.AlertNotifyTemplate{}, // 告警通知模板 // Logan.Li
|
||||
&model.AlertUpgrade{}, // 告警升级策略 // Logan.Li
|
||||
&model.AlertUpgradeRecord{}, // 告警升级记录 // Logan.Li
|
||||
&model.AlertSilence{}, // 告警静默 // Logan.Li
|
||||
&model.HealthProbe{}, // 健康探针配置 // Logan.Li
|
||||
&model.HealthProbe{}, // 健康探针配置 // Logan.Li
|
||||
&model.ApprovalWorkflow{}, // 审批流程定义 // Logan.Li
|
||||
&model.ApprovalWorkflowStep{}, // 审批流程步骤 // Logan.Li
|
||||
&model.AgentCLISession{}, // Agent CLI 任务会话 // Logan.Li
|
||||
&model.AgentCLIMessage{}, // Agent CLI 推理消息 // Logan.Li
|
||||
&model.CustomDashboard{}, // 自定义监控仪表盘 // Logan.Li
|
||||
&model.TraceDataSource{}, // 链路追踪数据源 Jaeger/Tempo // Logan.Li
|
||||
&model.ApprovalWorkflowStep{}, // 审批流程步骤 // Logan.Li
|
||||
&model.AgentCLISession{}, // Agent CLI 任务会话 // Logan.Li
|
||||
&model.AgentCLIMessage{}, // Agent CLI 推理消息 // Logan.Li
|
||||
&model.CustomDashboard{}, // 自定义监控仪表盘 // Logan.Li
|
||||
&model.TraceDataSource{}, // 链路追踪数据源 Jaeger/Tempo // Logan.Li
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
@@ -238,4 +239,26 @@ func applySchemaPatches(db *gorm.DB) {
|
||||
log.Printf("[DB] 补列 iam_ldap_configs.server_type 失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 服务器 RDP/协议字段(旧库 AutoMigrate 可能未加列)// Logan.Li
|
||||
patchServerColumn := func(field string, col string) {
|
||||
if !m.HasColumn(&model.Server{}, col) {
|
||||
if err := m.AddColumn(&model.Server{}, field); err != nil {
|
||||
log.Printf("[DB] 补列 asset_servers.%s 失败: %v", col, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
patchServerColumn("OSType", "os_type")
|
||||
patchServerColumn("Protocol", "protocol")
|
||||
patchServerColumn("RDPPort", "rdp_port")
|
||||
|
||||
// 将历史仅填 os=windows 的记录补齐协议字段 // Logan.Li
|
||||
if m.HasColumn(&model.Server{}, "os_type") && m.HasColumn(&model.Server{}, "protocol") {
|
||||
_ = db.Exec(`
|
||||
UPDATE asset_servers
|
||||
SET os_type = 'windows', protocol = 'rdp', rdp_port = IF(rdp_port IS NULL OR rdp_port = 0, 3389, rdp_port)
|
||||
WHERE (os_type IS NULL OR os_type = '' OR protocol IS NULL OR protocol = '')
|
||||
AND LOWER(os) LIKE '%windows%'
|
||||
`).Error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
# RDP 协议栈分层
|
||||
|
||||
`backend/pkg/rdp` 是 AIOps 资产远程桌面的协议层。当前固定使用自研 RDP 引擎,第三方 `grdp` 兜底和 `backend/third_party` 已移除。根包只保留兼容入口、引擎创建与临时会话编排,协议细节逐步迁入域目录。// Logan.Li
|
||||
|
||||
## 引擎选择
|
||||
|
||||
| 环境变量 | 行为 |
|
||||
|----------|------|
|
||||
| `RDP_ENGINE` | 保留兼容,不再参与引擎选择 |
|
||||
| `RDP_DEBUG=1` | 打开协议级 trace 日志,统一输出 `[RDP:TRACE]` |
|
||||
|
||||
## 目录职责
|
||||
|
||||
| 目录 | 职责 |
|
||||
|------|------|
|
||||
| `domain/` | 对外契约:连接配置、浏览器输入事件、服务端帧、引擎接口、诊断结果、录屏摘要 |
|
||||
| `transport/` | TCP 上的 TPKT、X.224、TLS 升级与安全协议协商 |
|
||||
| `credssp/` | NLA/CredSSP/NTLMv2 认证,自研 ASN.1 与 NTLM 消息处理 |
|
||||
| `rle/` | RDP 位图 RLE 解压算法 |
|
||||
| `trace/` | 协议级调试日志开关,避免高频 bitmap 默认刷屏 |
|
||||
| 根包 | 引擎选择、兼容入口、临时会话编排、MCS/GCC、Fast-Path 收发、键鼠输入、位图解码、探针入口 |
|
||||
|
||||
## 依赖方向
|
||||
|
||||
控制器只依赖根包 `ezops/pkg/rdp` 的 `Engine`、`Config`、`ClientEvent`、`ServerFrame`。根包向内组合 `domain`、`transport`、`credssp`、`rle`。底层域目录不能反向依赖控制器、数据库、WebSocket 或审计模型。
|
||||
|
||||
## 后续拆分目标
|
||||
|
||||
1. 协议基础帧、TLS、X.224 只放 `transport/`。
|
||||
2. 登录认证只放 `credssp/`,不得调用 Gin/GORM。
|
||||
3. 浏览器与控制器共享的 DTO 先放 `domain/`,根包用类型别名兼容旧调用。
|
||||
4. `pdu/` 承接 DemandActive、ConfirmActive、Synchronize、Control、FontList、BitmapUpdate 等状态机。
|
||||
5. `graphics/` 承接 bitmap/RLE/PNG 或后续 raw frame 编码,输入只消费标准化矩形。
|
||||
6. `input/` 承接鼠标、键盘、Unicode、组合键与自动执行。
|
||||
7. `channels/` 承接 cliprdr、drdynvc、rdpgfx,避免虚拟通道逻辑污染会话主流程。
|
||||
8. `recorder/` 承接 RDP 事件录制、回放和截图探针。
|
||||
9. 新增测试优先覆盖底层域目录,再用根包探针做资产联调。
|
||||
@@ -0,0 +1,297 @@
|
||||
package rdp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"image"
|
||||
"image/png"
|
||||
"io"
|
||||
|
||||
"ezops/pkg/rdp/rle"
|
||||
"ezops/pkg/rdp/trace"
|
||||
)
|
||||
|
||||
const (
|
||||
bitmapCompression = 0x0001
|
||||
noBitmapCompressionHdr = 0x0400
|
||||
bitmapNoProcessing = 0x8000
|
||||
fastPathUpdateBitmap = 0x1
|
||||
fastPathUpdateSurfaceCmds = 0x4
|
||||
pduType2Update = 0x02
|
||||
)
|
||||
|
||||
// BitmapRect 表示一块待绘制到 Canvas 的位图区域 // Logan.Li
|
||||
type BitmapRect struct {
|
||||
DestLeft int
|
||||
DestTop int
|
||||
Width int
|
||||
Height int
|
||||
BitsPerPixel int
|
||||
Data []byte
|
||||
}
|
||||
|
||||
func bppFromWire(v uint16) int {
|
||||
switch v {
|
||||
case 8:
|
||||
return 1
|
||||
case 15, 16:
|
||||
return 2
|
||||
case 24:
|
||||
return 3
|
||||
case 32:
|
||||
return 4
|
||||
default:
|
||||
if v <= 4 {
|
||||
return int(v)
|
||||
}
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
type wireBitmap struct {
|
||||
DestLeft uint16
|
||||
DestTop uint16
|
||||
DestRight uint16
|
||||
DestBottom uint16
|
||||
Width uint16
|
||||
Height uint16
|
||||
BitsPerPixel uint16
|
||||
Flags uint16
|
||||
BitmapLength uint16
|
||||
BitmapDataStream []byte
|
||||
}
|
||||
|
||||
func readWireBitmap(r *bytes.Reader) (wireBitmap, error) {
|
||||
var rect wireBitmap
|
||||
var err error
|
||||
rect.DestLeft, err = readLE16(r)
|
||||
if err != nil {
|
||||
return rect, err
|
||||
}
|
||||
rect.DestTop, err = readLE16(r)
|
||||
if err != nil {
|
||||
return rect, err
|
||||
}
|
||||
rect.DestRight, err = readLE16(r)
|
||||
if err != nil {
|
||||
return rect, err
|
||||
}
|
||||
rect.DestBottom, err = readLE16(r)
|
||||
if err != nil {
|
||||
return rect, err
|
||||
}
|
||||
rect.Width, err = readLE16(r)
|
||||
if err != nil {
|
||||
return rect, err
|
||||
}
|
||||
rect.Height, err = readLE16(r)
|
||||
if err != nil {
|
||||
return rect, err
|
||||
}
|
||||
rect.BitsPerPixel, err = readLE16(r)
|
||||
if err != nil {
|
||||
return rect, err
|
||||
}
|
||||
rect.Flags, err = readLE16(r)
|
||||
if err != nil {
|
||||
return rect, err
|
||||
}
|
||||
rect.BitmapLength, err = readLE16(r)
|
||||
if err != nil {
|
||||
return rect, err
|
||||
}
|
||||
ln := int(rect.BitmapLength)
|
||||
if rect.Flags&bitmapCompression != 0 && rect.Flags&noBitmapCompressionHdr == 0 {
|
||||
if _, err = readLE16(r); err != nil {
|
||||
return rect, err
|
||||
}
|
||||
mainBody, err := readLE16(r)
|
||||
if err != nil {
|
||||
return rect, err
|
||||
}
|
||||
if _, err = readLE16(r); err != nil {
|
||||
return rect, err
|
||||
}
|
||||
if _, err = readLE16(r); err != nil {
|
||||
return rect, err
|
||||
}
|
||||
ln = int(mainBody)
|
||||
}
|
||||
rect.BitmapDataStream = make([]byte, ln)
|
||||
_, err = io.ReadFull(r, rect.BitmapDataStream)
|
||||
return rect, err
|
||||
}
|
||||
|
||||
func parseFastPathBitmap(payload []byte) ([]BitmapRect, error) {
|
||||
r := bytes.NewReader(payload)
|
||||
if _, err := readLE16(r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
count, err := readLE16(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]BitmapRect, 0, count)
|
||||
for i := 0; i < int(count); i++ {
|
||||
wb, err := readWireBitmap(r)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if rect, ok := decodeWireBitmap(wb); ok {
|
||||
out = append(out, rect)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseSlowPathBitmap(payload []byte) ([]BitmapRect, error) {
|
||||
if len(payload) < 15 {
|
||||
return nil, nil
|
||||
}
|
||||
pduType := binaryLittleUint16(payload[2:4])
|
||||
if pduType != pduTypeData {
|
||||
return nil, nil
|
||||
}
|
||||
type2 := payload[14]
|
||||
if type2 != pduType2Update {
|
||||
return nil, nil
|
||||
}
|
||||
body := payload[18:]
|
||||
if len(body) < 2 {
|
||||
return nil, nil
|
||||
}
|
||||
updateType := binaryLittleUint16(body[0:2])
|
||||
if updateType != fastPathUpdateBitmap {
|
||||
return nil, nil
|
||||
}
|
||||
return parseFastPathBitmap(body[2:])
|
||||
}
|
||||
|
||||
func decodeWireBitmap(wb wireBitmap) (rect BitmapRect, ok bool) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
rect = BitmapRect{}
|
||||
ok = false
|
||||
}
|
||||
}()
|
||||
bpp := bppFromWire(wb.BitsPerPixel)
|
||||
data := wb.BitmapDataStream
|
||||
originalLen := len(data)
|
||||
if wb.Flags&bitmapNoProcessing != 0 {
|
||||
// Surface command 已是自上而下 BGRA // Logan.Li
|
||||
} else if wb.Flags&bitmapCompression != 0 {
|
||||
data = rle.DecompressInto(data, nil, int(wb.Width), int(wb.Height), bpp)
|
||||
if len(data) == 0 {
|
||||
trace.Logf("bitmap decode failed compressed rect=%dx%d@%d,%d wireBpp=%d bpp=%d flags=0x%04x original=%d", wb.Width, wb.Height, wb.DestLeft, wb.DestTop, wb.BitsPerPixel, bpp, wb.Flags, originalLen)
|
||||
return BitmapRect{}, false
|
||||
}
|
||||
} else {
|
||||
data = flipBitmapVertical(data, int(wb.Width), bpp, int(wb.Height))
|
||||
}
|
||||
if len(data) < int(wb.Width)*int(wb.Height)*bpp {
|
||||
trace.Logf("bitmap decode short rect=%dx%d@%d,%d wireBpp=%d bpp=%d flags=0x%04x original=%d decoded=%d", wb.Width, wb.Height, wb.DestLeft, wb.DestTop, wb.BitsPerPixel, bpp, wb.Flags, originalLen, len(data))
|
||||
return BitmapRect{}, false
|
||||
}
|
||||
if isMostlyBlack(data, bpp) && int(wb.Width) >= 128 && int(wb.Height) >= 128 {
|
||||
trace.Logf("bitmap decode mostly black rect=%dx%d@%d,%d wireBpp=%d bpp=%d flags=0x%04x original=%d decoded=%d", wb.Width, wb.Height, wb.DestLeft, wb.DestTop, wb.BitsPerPixel, bpp, wb.Flags, originalLen, len(data))
|
||||
}
|
||||
return BitmapRect{
|
||||
DestLeft: int(wb.DestLeft),
|
||||
DestTop: int(wb.DestTop),
|
||||
Width: int(wb.Width),
|
||||
Height: int(wb.Height),
|
||||
BitsPerPixel: bpp,
|
||||
Data: data,
|
||||
}, true
|
||||
}
|
||||
|
||||
func isMostlyBlack(data []byte, bpp int) bool {
|
||||
if len(data) == 0 || bpp <= 0 {
|
||||
return false
|
||||
}
|
||||
limit := len(data)
|
||||
if limit > 256*1024 {
|
||||
limit = 256 * 1024
|
||||
}
|
||||
nonBlack := 0
|
||||
for i := 0; i+bpp-1 < limit; i += bpp {
|
||||
switch bpp {
|
||||
case 1:
|
||||
if data[i] > 4 {
|
||||
nonBlack++
|
||||
}
|
||||
case 2:
|
||||
if data[i] > 0 || data[i+1] > 0 {
|
||||
nonBlack++
|
||||
}
|
||||
default:
|
||||
if data[i] > 4 || data[i+1] > 4 || data[i+2] > 4 {
|
||||
nonBlack++
|
||||
}
|
||||
}
|
||||
if nonBlack > 32 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func flipBitmapVertical(data []byte, width, bpp, height int) []byte {
|
||||
out := append([]byte(nil), data...)
|
||||
stride := width * bpp
|
||||
tmp := make([]byte, stride)
|
||||
for y := 0; y < height/2; y++ {
|
||||
top := y * stride
|
||||
bot := (height - 1 - y) * stride
|
||||
copy(tmp, out[top:top+stride])
|
||||
copy(out[top:top+stride], out[bot:bot+stride])
|
||||
copy(out[bot:bot+stride], tmp)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func bitmapRectPNG(rect BitmapRect) (string, error) {
|
||||
img := image.NewRGBA(image.Rect(0, 0, rect.Width, rect.Height))
|
||||
pix := img.Pix
|
||||
data := rect.Data
|
||||
switch rect.BitsPerPixel {
|
||||
case 1, 2:
|
||||
n := rect.Width * rect.Height
|
||||
for i := 0; i < n && i*2+1 < len(data); i++ {
|
||||
// 保持 PNG 兼容路径与 native display 的 16bpp 大端 RGB565 解析一致。 // Logan.Li
|
||||
c := binary.BigEndian.Uint16(data[i*2 : i*2+2])
|
||||
r := uint8((c >> 11) & 0x1F)
|
||||
g := uint8((c >> 5) & 0x3F)
|
||||
b := uint8(c & 0x1F)
|
||||
off := i * 4
|
||||
pix[off] = (r << 3) | (r >> 2)
|
||||
pix[off+1] = (g << 2) | (g >> 4)
|
||||
pix[off+2] = (b << 3) | (b >> 2)
|
||||
pix[off+3] = 0xFF
|
||||
}
|
||||
default:
|
||||
stride := rect.BitsPerPixel
|
||||
n := rect.Width * rect.Height
|
||||
for i := 0; i < n; i++ {
|
||||
s := i * stride
|
||||
if s+2 >= len(data) {
|
||||
break
|
||||
}
|
||||
off := i * 4
|
||||
pix[off+2] = data[s]
|
||||
pix[off+1] = data[s+1]
|
||||
pix[off] = data[s+2]
|
||||
pix[off+3] = 0xFF
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()), nil
|
||||
}
|
||||
|
||||
func binaryLittleUint16(b []byte) uint16 {
|
||||
return uint16(b[0]) | uint16(b[1])<<8
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package rdp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"ezops/pkg/rdp/credssp"
|
||||
"ezops/pkg/rdp/transport"
|
||||
)
|
||||
|
||||
// ProbeHandshake 执行 TCP + TPKT/X.224 + RDP 安全协议协商 // Logan.Li
|
||||
func ProbeHandshake(ctx context.Context, cfg Config) (HandshakeInfo, error) {
|
||||
port := cfg.Port
|
||||
if port == 0 {
|
||||
port = 3389
|
||||
}
|
||||
address := fmt.Sprintf("%s:%d", cfg.Host, port)
|
||||
start := time.Now()
|
||||
|
||||
var d net.Dialer
|
||||
conn, err := d.DialContext(ctx, "tcp", address)
|
||||
if err != nil {
|
||||
return HandshakeInfo{Address: address, Duration: time.Since(start)}, err
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(15 * time.Second))
|
||||
|
||||
if err := transport.WriteTPKT(conn, transport.BuildConnectionRequest()); err != nil {
|
||||
return HandshakeInfo{Address: address, Duration: time.Since(start)}, err
|
||||
}
|
||||
payload, err := transport.ReadTPKT(conn)
|
||||
if err != nil {
|
||||
return HandshakeInfo{Address: address, Duration: time.Since(start)}, err
|
||||
}
|
||||
result, err := transport.ParseConnectionConfirm(payload)
|
||||
if err != nil {
|
||||
return HandshakeInfo{Address: address, Duration: time.Since(start)}, err
|
||||
}
|
||||
return HandshakeInfo{
|
||||
Address: address,
|
||||
SelectedProtocol: result.ProtocolName(),
|
||||
Duration: time.Since(start),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ProbeTLSHandshake 执行 X.224 协商后升级 TLS,用于验证 Windows 10/Server NLA 前置链路 // Logan.Li
|
||||
func ProbeTLSHandshake(ctx context.Context, cfg Config) (HandshakeInfo, error) {
|
||||
port := cfg.Port
|
||||
if port == 0 {
|
||||
port = 3389
|
||||
}
|
||||
address := fmt.Sprintf("%s:%d", cfg.Host, port)
|
||||
start := time.Now()
|
||||
|
||||
var d net.Dialer
|
||||
conn, err := d.DialContext(ctx, "tcp", address)
|
||||
if err != nil {
|
||||
return HandshakeInfo{Address: address, Duration: time.Since(start)}, err
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(15 * time.Second))
|
||||
|
||||
if err := transport.WriteTPKT(conn, transport.BuildConnectionRequestWithCookie(cfg.Username)); err != nil {
|
||||
return HandshakeInfo{Address: address, Duration: time.Since(start)}, err
|
||||
}
|
||||
payload, err := transport.ReadTPKT(conn)
|
||||
if err != nil {
|
||||
return HandshakeInfo{Address: address, Duration: time.Since(start)}, err
|
||||
}
|
||||
result, err := transport.ParseConnectionConfirm(payload)
|
||||
if err != nil {
|
||||
return HandshakeInfo{Address: address, Duration: time.Since(start)}, err
|
||||
}
|
||||
if result.SelectedProtocol != transport.ProtocolTLS && result.SelectedProtocol != transport.ProtocolCredSSP {
|
||||
return HandshakeInfo{Address: address, SelectedProtocol: result.ProtocolName(), Duration: time.Since(start)}, nil
|
||||
}
|
||||
tlsConn, err := transport.UpgradeTLS(conn, cfg.Host, 15*time.Second)
|
||||
if err != nil {
|
||||
return HandshakeInfo{Address: address, SelectedProtocol: result.ProtocolName(), Duration: time.Since(start)}, err
|
||||
}
|
||||
state := tlsConn.ConnectionState()
|
||||
certSubject := ""
|
||||
if len(state.PeerCertificates) > 0 {
|
||||
certSubject = state.PeerCertificates[0].Subject.String()
|
||||
}
|
||||
_ = tlsConn.SetReadDeadline(time.Now().Add(300 * time.Millisecond))
|
||||
_, _ = io.ReadAll(tlsConn)
|
||||
return HandshakeInfo{
|
||||
Address: address,
|
||||
SelectedProtocol: result.ProtocolName(),
|
||||
Duration: time.Since(start),
|
||||
TLSCipher: transport.TLSCipherSuiteName(state.CipherSuite),
|
||||
CertSubject: certSubject,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ProbeCredSSP 执行到 CredSSP/NTLMv2 登录完成,用于验证 Windows 10/Server NLA 凭据 // Logan.Li
|
||||
func ProbeCredSSP(ctx context.Context, cfg Config) (HandshakeInfo, error) {
|
||||
port := cfg.Port
|
||||
if port == 0 {
|
||||
port = 3389
|
||||
}
|
||||
address := fmt.Sprintf("%s:%d", cfg.Host, port)
|
||||
start := time.Now()
|
||||
|
||||
strategies := []credssp.Options{
|
||||
{Version: 6, PubKeyStyle: "binding"},
|
||||
{Version: 6, PubKeyStyle: "binding", UseCBT: true},
|
||||
{Version: 6, PubKeyStyle: "binding", ForceMIC: true},
|
||||
{Version: 2, PubKeyStyle: "raw"},
|
||||
}
|
||||
var lastErr error
|
||||
for _, opts := range strategies {
|
||||
info, err := probeCredSSPOnce(ctx, cfg, address, opts)
|
||||
if err == nil {
|
||||
info.Duration = time.Since(start)
|
||||
return info, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("CredSSP 认证失败")
|
||||
}
|
||||
return HandshakeInfo{Address: address, Duration: time.Since(start)}, lastErr
|
||||
}
|
||||
|
||||
func probeCredSSPOnce(ctx context.Context, cfg Config, address string, opts credssp.Options) (HandshakeInfo, error) {
|
||||
tlsConn, result, err := dialCredSSPOnce(ctx, cfg, address, opts)
|
||||
if err != nil {
|
||||
return HandshakeInfo{Address: address}, err
|
||||
}
|
||||
defer tlsConn.Close()
|
||||
state := tlsConn.ConnectionState()
|
||||
certSubject := ""
|
||||
if len(state.PeerCertificates) > 0 {
|
||||
certSubject = state.PeerCertificates[0].Subject.String()
|
||||
}
|
||||
return HandshakeInfo{
|
||||
Address: address,
|
||||
SelectedProtocol: result.ProtocolName(),
|
||||
TLSCipher: transport.TLSCipherSuiteName(state.CipherSuite),
|
||||
CertSubject: certSubject,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func dialCredSSPOnce(ctx context.Context, cfg Config, address string, opts credssp.Options) (*tls.Conn, transport.NegotiationResult, error) {
|
||||
var d net.Dialer
|
||||
conn, err := d.DialContext(ctx, "tcp", address)
|
||||
if err != nil {
|
||||
return nil, transport.NegotiationResult{}, err
|
||||
}
|
||||
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
|
||||
|
||||
if err := transport.WriteTPKT(conn, transport.BuildConnectionRequestWithCookie(cfg.Username)); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, transport.NegotiationResult{}, err
|
||||
}
|
||||
payload, err := transport.ReadTPKT(conn)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, transport.NegotiationResult{}, err
|
||||
}
|
||||
result, err := transport.ParseConnectionConfirm(payload)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, transport.NegotiationResult{}, err
|
||||
}
|
||||
if result.SelectedProtocol != transport.ProtocolCredSSP {
|
||||
_ = conn.Close()
|
||||
return nil, result, fmt.Errorf("目标未选择 CredSSP/NLA: %s", result.ProtocolName())
|
||||
}
|
||||
tlsConn, err := transport.UpgradeTLS(conn, cfg.Host, 30*time.Second)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, result, err
|
||||
}
|
||||
if err := credssp.AuthenticateWithOptions(tlsConn, cfg.Domain, cfg.Username, cfg.Password, opts); err != nil {
|
||||
_ = tlsConn.Close()
|
||||
return nil, result, err
|
||||
}
|
||||
_ = tlsConn.SetDeadline(time.Time{})
|
||||
return tlsConn, result, nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package credssp
|
||||
|
||||
import "encoding/asn1"
|
||||
|
||||
const defaultCredSSPVersion = 6
|
||||
|
||||
// negoToken 是 CredSSP TSRequest 中的单个协商令牌 // Logan.Li
|
||||
type negoToken struct {
|
||||
Data []byte `asn1:"explicit,tag:0"`
|
||||
}
|
||||
|
||||
// tsRequest 是 MS-CSSP 2.2.1.2 TSRequest 的最小实现 // Logan.Li
|
||||
type tsRequest struct {
|
||||
Version int `asn1:"explicit,tag:0"`
|
||||
NegoTokens []negoToken `asn1:"optional,explicit,tag:1"`
|
||||
AuthInfo []byte `asn1:"optional,explicit,tag:2"`
|
||||
PubKeyAuth []byte `asn1:"optional,explicit,tag:3"`
|
||||
ErrorCode int `asn1:"optional,explicit,tag:4"`
|
||||
ClientNonce []byte `asn1:"optional,explicit,tag:5"`
|
||||
}
|
||||
|
||||
type tsCredentials struct {
|
||||
CredType int `asn1:"explicit,tag:0"`
|
||||
Credentials []byte `asn1:"explicit,tag:1"`
|
||||
}
|
||||
|
||||
type tsPasswordCreds struct {
|
||||
DomainName []byte `asn1:"explicit,tag:0"`
|
||||
UserName []byte `asn1:"explicit,tag:1"`
|
||||
Password []byte `asn1:"explicit,tag:2"`
|
||||
}
|
||||
|
||||
func encodeTSRequest(tokens [][]byte, authInfo, pubKeyAuth []byte) ([]byte, error) {
|
||||
return encodeTSRequestWithNonce(tokens, authInfo, pubKeyAuth, nil)
|
||||
}
|
||||
|
||||
func encodeTSRequestWithNonce(tokens [][]byte, authInfo, pubKeyAuth, clientNonce []byte) ([]byte, error) {
|
||||
return encodeTSRequestVersion(defaultCredSSPVersion, tokens, authInfo, pubKeyAuth, clientNonce)
|
||||
}
|
||||
|
||||
func encodeTSRequestVersion(version int, tokens [][]byte, authInfo, pubKeyAuth, clientNonce []byte) ([]byte, error) {
|
||||
req := tsRequest{Version: version}
|
||||
for _, token := range tokens {
|
||||
req.NegoTokens = append(req.NegoTokens, negoToken{Data: token})
|
||||
}
|
||||
if len(authInfo) > 0 {
|
||||
req.AuthInfo = authInfo
|
||||
}
|
||||
if len(pubKeyAuth) > 0 {
|
||||
req.PubKeyAuth = pubKeyAuth
|
||||
}
|
||||
if len(clientNonce) > 0 {
|
||||
req.ClientNonce = clientNonce
|
||||
}
|
||||
return asn1.Marshal(req)
|
||||
}
|
||||
|
||||
func decodeTSRequest(data []byte) (*tsRequest, error) {
|
||||
var req tsRequest
|
||||
_, err := asn1.Unmarshal(data, &req)
|
||||
return &req, err
|
||||
}
|
||||
|
||||
func encodePasswordCredentials(domain, user, password string) ([]byte, error) {
|
||||
pass, err := asn1.Marshal(tsPasswordCreds{
|
||||
DomainName: utf16le(domain),
|
||||
UserName: utf16le(user),
|
||||
Password: utf16le(password),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return asn1.Marshal(tsCredentials{CredType: 1, Credentials: pass})
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package credssp
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/asn1"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Options 控制 CredSSP 兼容策略,仅调试和特殊 Windows 版本需要覆盖 // Logan.Li
|
||||
type Options struct {
|
||||
Version int
|
||||
PubKeyStyle string
|
||||
ForceMIC bool
|
||||
NoKeyExch bool
|
||||
UseCBT bool
|
||||
OmitVersion bool
|
||||
}
|
||||
|
||||
// ChallengeInfo 暴露 NTLM challenge 诊断信息,不包含凭据 // Logan.Li
|
||||
type ChallengeInfo struct {
|
||||
Version int
|
||||
TargetName string
|
||||
Flags uint32
|
||||
TargetInfoBytes int
|
||||
RequiresMIC bool
|
||||
DomainName string
|
||||
ComputerName string
|
||||
}
|
||||
|
||||
// ReadChallengeInfo 只读取 CredSSP/NTLM challenge,不发送认证数据 // Logan.Li
|
||||
func ReadChallengeInfo(conn *tls.Conn, version int) (ChallengeInfo, error) {
|
||||
if version == 0 {
|
||||
version = defaultCredSSPVersion
|
||||
}
|
||||
negotiate := buildNegotiate()
|
||||
req, err := encodeTSRequestVersion(version, [][]byte{negotiate}, nil, nil, nil)
|
||||
if err != nil {
|
||||
return ChallengeInfo{}, err
|
||||
}
|
||||
if _, err := conn.Write(req); err != nil {
|
||||
return ChallengeInfo{}, err
|
||||
}
|
||||
challengeReq, err := readTSRequest(conn)
|
||||
if err != nil {
|
||||
return ChallengeInfo{}, err
|
||||
}
|
||||
if len(challengeReq.NegoTokens) == 0 {
|
||||
return ChallengeInfo{}, fmt.Errorf("CredSSP challenge 缺少 nego token")
|
||||
}
|
||||
challenge, err := parseChallenge(challengeReq.NegoTokens[0].Data)
|
||||
if err != nil {
|
||||
return ChallengeInfo{}, err
|
||||
}
|
||||
return ChallengeInfo{
|
||||
Version: challengeReq.Version,
|
||||
TargetName: challenge.TargetName,
|
||||
Flags: challenge.Flags,
|
||||
TargetInfoBytes: len(challenge.TargetInfo),
|
||||
RequiresMIC: requiresMIC(challenge.TargetInfo),
|
||||
DomainName: utf16leString(findAVPair(challenge.TargetInfo, msvAvNbDomainName)),
|
||||
ComputerName: utf16leString(findAVPair(challenge.TargetInfo, msvAvNbComputerName)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Authenticate 在已升级 TLS 的 RDP 连接上执行最小 CredSSP/NTLMv2 认证 // Logan.Li
|
||||
func Authenticate(conn *tls.Conn, domain, user, password string) error {
|
||||
return AuthenticateWithOptions(conn, domain, user, password, Options{Version: defaultCredSSPVersion, PubKeyStyle: "binding"})
|
||||
}
|
||||
|
||||
// AuthenticateWithOptions 使用指定 CredSSP 版本和 pubKeyAuth 算法执行认证 // Logan.Li
|
||||
func AuthenticateWithOptions(conn *tls.Conn, domain, user, password string, opts Options) error {
|
||||
if opts.Version == 0 {
|
||||
opts.Version = defaultCredSSPVersion
|
||||
}
|
||||
if opts.PubKeyStyle == "" {
|
||||
opts.PubKeyStyle = "binding"
|
||||
}
|
||||
domain, user = splitDomainUser(domain, user)
|
||||
negotiate := buildNegotiate()
|
||||
req, err := encodeTSRequestVersion(opts.Version, [][]byte{negotiate}, nil, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := conn.Write(req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
challengeReq, err := readTSRequest(conn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取 NTLM challenge 失败: %w", err)
|
||||
}
|
||||
if len(challengeReq.NegoTokens) == 0 {
|
||||
return fmt.Errorf("CredSSP challenge 缺少 nego token")
|
||||
}
|
||||
challenge, err := parseChallenge(challengeReq.NegoTokens[0].Data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ntlmOpts := ntlmOptions{
|
||||
ForceMIC: opts.ForceMIC,
|
||||
DisableKeyExch: opts.NoKeyExch,
|
||||
OmitVersion: opts.OmitVersion,
|
||||
}
|
||||
if opts.UseCBT {
|
||||
ntlmOpts.ChannelBindings = channelBindingsHash(conn)
|
||||
}
|
||||
auth, sec, err := buildAuthenticate(challenge, domain, user, password, "AIOPS", negotiate, ntlmOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pubKey, err := peerPublicKey(conn, opts.PubKeyStyle)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
clientNonce := randomBytes(32)
|
||||
pubKeyAuthPlain := buildPubKeyAuth(pubKey, clientNonce, opts.PubKeyStyle)
|
||||
pubKeyAuth := sec.encrypt(pubKeyAuthPlain)
|
||||
var nonce []byte
|
||||
if opts.PubKeyStyle == "binding" {
|
||||
nonce = clientNonce
|
||||
}
|
||||
req, err = encodeTSRequestVersion(opts.Version, [][]byte{auth}, nil, pubKeyAuth, nonce)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := conn.Write(req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
serverReq, err := readTSRequest(conn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取服务端公钥校验失败(flags=0x%08x mic=%v targetInfo=%d): %w", challenge.Flags, requiresMIC(challenge.TargetInfo), len(challenge.TargetInfo), err)
|
||||
}
|
||||
if serverReq.ErrorCode != 0 {
|
||||
return fmt.Errorf("CredSSP 返回错误码: %s", ntStatusMessage(serverReq.ErrorCode))
|
||||
}
|
||||
|
||||
creds, err := encodePasswordCredentials(domain, user, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encryptedCreds := sec.encrypt(creds)
|
||||
req, err = encodeTSRequestVersion(opts.Version, nil, encryptedCreds, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = conn.Write(req)
|
||||
return err
|
||||
}
|
||||
|
||||
func buildPubKeyAuth(pubKey, clientNonce []byte, style string) []byte {
|
||||
switch style {
|
||||
case "raw-plus-one":
|
||||
out := append([]byte(nil), pubKey...)
|
||||
if len(out) > 0 {
|
||||
out[0]++
|
||||
}
|
||||
return out
|
||||
case "binding":
|
||||
return credSSPBindingHash(clientNonce, pubKey)
|
||||
default:
|
||||
return pubKey
|
||||
}
|
||||
}
|
||||
|
||||
func ntStatusMessage(code int) string {
|
||||
u := uint32(code)
|
||||
switch u {
|
||||
case 0xC0000064:
|
||||
return "0xC0000064 用户不存在"
|
||||
case 0xC000006A:
|
||||
return "0xC000006A 密码错误"
|
||||
case 0xC000006D:
|
||||
return "0xC000006D 用户名或认证信息无效"
|
||||
case 0xC000006E:
|
||||
return "0xC000006E 账号登录限制"
|
||||
case 0xC0000070:
|
||||
return "0xC0000070 工作站限制"
|
||||
case 0xC0000071:
|
||||
return "0xC0000071 密码已过期"
|
||||
case 0xC0000072:
|
||||
return "0xC0000072 账号已禁用"
|
||||
case 0xC000015B:
|
||||
return "0xC000015B 当前用户未被授予远程登录权限"
|
||||
case 0xC0000193:
|
||||
return "0xC0000193 账号已过期"
|
||||
case 0xC0000234:
|
||||
return "0xC0000234 账号已锁定,请先在 Windows/域控侧解锁后再连接"
|
||||
default:
|
||||
return fmt.Sprintf("0x%08X", u)
|
||||
}
|
||||
}
|
||||
|
||||
func credSSPBindingHash(clientNonce, pubKey []byte) []byte {
|
||||
h := sha256.New()
|
||||
h.Write([]byte("CredSSP Client-To-Server Binding Hash\x00"))
|
||||
h.Write(clientNonce)
|
||||
h.Write(pubKey)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func channelBindingsHash(conn *tls.Conn) []byte {
|
||||
state := conn.ConnectionState()
|
||||
if len(state.PeerCertificates) == 0 {
|
||||
return nil
|
||||
}
|
||||
cert := state.PeerCertificates[0]
|
||||
alg := cert.SignatureAlgorithm.String()
|
||||
var digest []byte
|
||||
switch {
|
||||
case strings.Contains(alg, "MD5"):
|
||||
sum := md5.Sum(cert.Raw)
|
||||
digest = sum[:]
|
||||
case strings.Contains(alg, "SHA1"):
|
||||
sum := sha1.Sum(cert.Raw)
|
||||
digest = sum[:]
|
||||
default:
|
||||
sum := sha256.Sum256(cert.Raw)
|
||||
digest = sum[:]
|
||||
}
|
||||
appData := append([]byte("tls-server-end-point:"), digest...)
|
||||
gss := make([]byte, 16+len(appData))
|
||||
copy(gss[16:], appData)
|
||||
gss[12] = byte(len(appData))
|
||||
gss[13] = byte(len(appData) >> 8)
|
||||
gss[14] = byte(len(appData) >> 16)
|
||||
gss[15] = byte(len(appData) >> 24)
|
||||
sum := md5.Sum(gss)
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
func readTSRequest(r io.Reader) (*tsRequest, error) {
|
||||
der, err := readDER(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeTSRequest(der)
|
||||
}
|
||||
|
||||
func readDER(r io.Reader) ([]byte, error) {
|
||||
header := make([]byte, 2)
|
||||
if _, err := io.ReadFull(r, header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if header[0] != 0x30 {
|
||||
return nil, fmt.Errorf("unexpected DER sequence tag: 0x%x", header[0])
|
||||
}
|
||||
length := int(header[1])
|
||||
lenBytes := 0
|
||||
if header[1]&0x80 != 0 {
|
||||
lenBytes = int(header[1] & 0x7f)
|
||||
if lenBytes == 0 || lenBytes > 4 {
|
||||
return nil, fmt.Errorf("invalid DER length bytes: %d", lenBytes)
|
||||
}
|
||||
raw := make([]byte, lenBytes)
|
||||
if _, err := io.ReadFull(r, raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
length = 0
|
||||
for _, b := range raw {
|
||||
length = length<<8 | int(b)
|
||||
}
|
||||
header = append(header, raw...)
|
||||
}
|
||||
body := make([]byte, length)
|
||||
if _, err := io.ReadFull(r, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(header, body...), nil
|
||||
}
|
||||
|
||||
func peerPublicKey(conn *tls.Conn, style string) ([]byte, error) {
|
||||
state := conn.ConnectionState()
|
||||
if len(state.PeerCertificates) == 0 {
|
||||
return nil, fmt.Errorf("RDP TLS 证书为空")
|
||||
}
|
||||
cert := state.PeerCertificates[0]
|
||||
if style == "rsa-compat" {
|
||||
if pub, ok := cert.PublicKey.(*rsa.PublicKey); ok {
|
||||
return asn1.Marshal(rsaPublicKeyCompat{N: pub.N, E: pub.E})
|
||||
}
|
||||
}
|
||||
// MS-CSSP v5/v6 绑定哈希必须使用 X.509 SubjectPublicKey,而非完整 SPKI // Logan.Li
|
||||
return subjectPublicKey(cert)
|
||||
}
|
||||
|
||||
func subjectPublicKey(cert *x509.Certificate) ([]byte, error) {
|
||||
if pub, ok := cert.PublicKey.(*rsa.PublicKey); ok {
|
||||
return asn1.Marshal(*pub)
|
||||
}
|
||||
var spki struct {
|
||||
Algorithm pkix.AlgorithmIdentifier
|
||||
PublicKey asn1.BitString
|
||||
}
|
||||
if _, err := asn1.Unmarshal(cert.RawSubjectPublicKeyInfo, &spki); err != nil {
|
||||
return nil, fmt.Errorf("解析 TLS SubjectPublicKey 失败: %w", err)
|
||||
}
|
||||
if len(spki.PublicKey.Bytes) == 0 {
|
||||
return nil, fmt.Errorf("TLS SubjectPublicKey 为空")
|
||||
}
|
||||
return spki.PublicKey.Bytes, nil
|
||||
}
|
||||
|
||||
type rsaPublicKeyCompat struct {
|
||||
N *big.Int `asn1:"explicit,tag:0"`
|
||||
E int `asn1:"explicit,tag:1"`
|
||||
}
|
||||
|
||||
func splitDomainUser(domain, user string) (string, string) {
|
||||
if strings.Contains(user, "\\") {
|
||||
parts := strings.Split(user, "\\")
|
||||
if len(parts) >= 2 {
|
||||
return parts[0], parts[len(parts)-1]
|
||||
}
|
||||
}
|
||||
if strings.Contains(user, "/") {
|
||||
parts := strings.Split(user, "/")
|
||||
if len(parts) >= 2 {
|
||||
return parts[0], parts[len(parts)-1]
|
||||
}
|
||||
}
|
||||
return domain, user
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package credssp
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEncodeDecodeTSRequest(t *testing.T) {
|
||||
nego := buildNegotiate()
|
||||
req, err := encodeTSRequest([][]byte{nego}, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoded, err := decodeTSRequest(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded.Version != defaultCredSSPVersion {
|
||||
t.Fatalf("version=%d", decoded.Version)
|
||||
}
|
||||
if len(decoded.NegoTokens) != 1 || string(decoded.NegoTokens[0].Data[:8]) != "NTLMSSP\x00" {
|
||||
t.Fatalf("invalid nego token")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
package credssp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"crypto/rc4"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/md4"
|
||||
)
|
||||
|
||||
const (
|
||||
ntlmNegotiateUnicode uint32 = 0x00000001
|
||||
ntlmRequestTarget uint32 = 0x00000004
|
||||
ntlmNegotiateSign uint32 = 0x00000010
|
||||
ntlmNegotiateSeal uint32 = 0x00000020
|
||||
ntlmNegotiateNTLM uint32 = 0x00000200
|
||||
ntlmNegotiateAlwaysSign uint32 = 0x00008000
|
||||
ntlmNegotiateExtendedSessionSecurity uint32 = 0x00080000
|
||||
ntlmNegotiateTargetInfo uint32 = 0x00800000
|
||||
ntlmNegotiateVersion uint32 = 0x02000000
|
||||
ntlmNegotiate128 uint32 = 0x20000000
|
||||
ntlmNegotiateKeyExch uint32 = 0x40000000
|
||||
ntlmNegotiate56 uint32 = 0x80000000
|
||||
|
||||
msvAvEOL uint16 = 0x0000
|
||||
msvAvNbComputerName uint16 = 0x0001
|
||||
msvAvNbDomainName uint16 = 0x0002
|
||||
msvAvFlags uint16 = 0x0006
|
||||
msvAvTimestamp uint16 = 0x0007
|
||||
msvChannelBindings uint16 = 0x000A
|
||||
)
|
||||
|
||||
const ntlmClientFlags = ntlmNegotiateUnicode |
|
||||
ntlmRequestTarget |
|
||||
ntlmNegotiateSign |
|
||||
ntlmNegotiateSeal |
|
||||
ntlmNegotiateNTLM |
|
||||
ntlmNegotiateAlwaysSign |
|
||||
ntlmNegotiateExtendedSessionSecurity |
|
||||
ntlmNegotiateTargetInfo |
|
||||
ntlmNegotiateVersion |
|
||||
ntlmNegotiate128 |
|
||||
ntlmNegotiateKeyExch
|
||||
|
||||
type ntlmChallenge struct {
|
||||
Raw []byte
|
||||
Flags uint32
|
||||
TargetName string
|
||||
ServerChallenge []byte
|
||||
TargetInfo []byte
|
||||
Timestamp []byte
|
||||
}
|
||||
|
||||
type ntlmOptions struct {
|
||||
ForceMIC bool
|
||||
DisableKeyExch bool
|
||||
ChannelBindings []byte
|
||||
OmitVersion bool
|
||||
}
|
||||
|
||||
type ntlmSecurity struct {
|
||||
clientSigningKey []byte
|
||||
serverSigningKey []byte
|
||||
clientSealing *rc4.Cipher
|
||||
serverSealing *rc4.Cipher
|
||||
seq uint32
|
||||
}
|
||||
|
||||
func buildNegotiate() []byte {
|
||||
buf := bytes.NewBuffer(nil)
|
||||
buf.Write([]byte("NTLMSSP\x00"))
|
||||
writeU32(buf, 1)
|
||||
writeU32(buf, ntlmClientFlags)
|
||||
writeSecBuffer(buf, 0, 0)
|
||||
writeSecBuffer(buf, 0, 0)
|
||||
buf.Write(ntlmVersion())
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func parseChallenge(data []byte) (*ntlmChallenge, error) {
|
||||
if len(data) < 48 || string(data[:8]) != "NTLMSSP\x00" || binary.LittleEndian.Uint32(data[8:12]) != 2 {
|
||||
return nil, fmt.Errorf("invalid NTLM challenge")
|
||||
}
|
||||
targetNameLen := int(binary.LittleEndian.Uint16(data[12:14]))
|
||||
targetNameOffset := int(binary.LittleEndian.Uint32(data[16:20]))
|
||||
targetInfoLen := int(binary.LittleEndian.Uint16(data[40:42]))
|
||||
targetInfoOffset := int(binary.LittleEndian.Uint32(data[44:48]))
|
||||
if targetInfoOffset < 0 || targetInfoOffset+targetInfoLen > len(data) {
|
||||
return nil, fmt.Errorf("invalid NTLM target info")
|
||||
}
|
||||
targetName := ""
|
||||
if targetNameOffset >= 0 && targetNameOffset+targetNameLen <= len(data) {
|
||||
targetName = utf16leString(data[targetNameOffset : targetNameOffset+targetNameLen])
|
||||
}
|
||||
targetInfo := data[targetInfoOffset : targetInfoOffset+targetInfoLen]
|
||||
return &ntlmChallenge{
|
||||
Raw: append([]byte(nil), data...),
|
||||
Flags: binary.LittleEndian.Uint32(data[20:24]),
|
||||
TargetName: targetName,
|
||||
ServerChallenge: append([]byte(nil), data[24:32]...),
|
||||
TargetInfo: append([]byte(nil), targetInfo...),
|
||||
Timestamp: findAVPair(targetInfo, msvAvTimestamp),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildAuthenticate(ch *ntlmChallenge, domain, user, password, workstation string, negotiate []byte, opts ntlmOptions) ([]byte, *ntlmSecurity, error) {
|
||||
if domain == "" {
|
||||
domain = ch.TargetName
|
||||
}
|
||||
if domain == "" {
|
||||
domain = utf16leString(findAVPair(ch.TargetInfo, msvAvNbDomainName))
|
||||
}
|
||||
respKey := ntowfv2(password, user, domain)
|
||||
clientChallenge := randomBytes(8)
|
||||
timestamp := ch.Timestamp
|
||||
if len(timestamp) == 0 {
|
||||
timestamp = windowsFileTime(time.Now())
|
||||
}
|
||||
|
||||
blob := bytes.NewBuffer(nil)
|
||||
blob.Write([]byte{0x01, 0x01, 0, 0, 0, 0, 0, 0})
|
||||
blob.Write(timestamp)
|
||||
blob.Write(clientChallenge)
|
||||
blob.Write([]byte{0, 0, 0, 0})
|
||||
targetInfo := ch.TargetInfo
|
||||
if len(opts.ChannelBindings) == 16 {
|
||||
targetInfo = withChannelBindings(ch.TargetInfo, opts.ChannelBindings)
|
||||
}
|
||||
blob.Write(targetInfo)
|
||||
blob.Write([]byte{0, 0, 0, 0})
|
||||
|
||||
ntProof := hmacMD5(respKey, append(append([]byte(nil), ch.ServerChallenge...), blob.Bytes()...))
|
||||
ntResp := append(ntProof, blob.Bytes()...)
|
||||
lmResp := append(hmacMD5(respKey, append(append([]byte(nil), ch.ServerChallenge...), clientChallenge...)), clientChallenge...)
|
||||
sessionBaseKey := hmacMD5(respKey, ntProof)
|
||||
exportedSessionKey := randomBytes(16)
|
||||
encryptedSessionKey := rc4k(sessionBaseKey, exportedSessionKey)
|
||||
|
||||
domainBytes := utf16le(domain)
|
||||
userBytes := utf16le(user)
|
||||
workstationBytes := utf16le(workstation)
|
||||
flags := ch.Flags & ntlmClientFlags
|
||||
flags |= ntlmNegotiateUnicode | ntlmNegotiateExtendedSessionSecurity | ntlmNegotiateNTLM
|
||||
if opts.DisableKeyExch {
|
||||
flags &^= ntlmNegotiateKeyExch
|
||||
exportedSessionKey = sessionBaseKey
|
||||
encryptedSessionKey = nil
|
||||
}
|
||||
|
||||
includeMIC := opts.ForceMIC || requiresMIC(ch.TargetInfo)
|
||||
baseLen := 72
|
||||
if opts.OmitVersion {
|
||||
baseLen = 64
|
||||
}
|
||||
if includeMIC {
|
||||
baseLen += 16
|
||||
}
|
||||
payload := bytes.NewBuffer(nil)
|
||||
offset := uint32(baseLen)
|
||||
lmOffset := offset
|
||||
payload.Write(lmResp)
|
||||
offset += uint32(len(lmResp))
|
||||
ntOffset := offset
|
||||
payload.Write(ntResp)
|
||||
offset += uint32(len(ntResp))
|
||||
domainOffset := offset
|
||||
payload.Write(domainBytes)
|
||||
offset += uint32(len(domainBytes))
|
||||
userOffset := offset
|
||||
payload.Write(userBytes)
|
||||
offset += uint32(len(userBytes))
|
||||
workstationOffset := offset
|
||||
payload.Write(workstationBytes)
|
||||
offset += uint32(len(workstationBytes))
|
||||
sessionOffset := offset
|
||||
if len(encryptedSessionKey) > 0 {
|
||||
payload.Write(encryptedSessionKey)
|
||||
}
|
||||
|
||||
msg := bytes.NewBuffer(nil)
|
||||
msg.Write([]byte("NTLMSSP\x00"))
|
||||
writeU32(msg, 3)
|
||||
writeSecBuffer(msg, uint16(len(lmResp)), lmOffset)
|
||||
writeSecBuffer(msg, uint16(len(ntResp)), ntOffset)
|
||||
writeSecBuffer(msg, uint16(len(domainBytes)), domainOffset)
|
||||
writeSecBuffer(msg, uint16(len(userBytes)), userOffset)
|
||||
writeSecBuffer(msg, uint16(len(workstationBytes)), workstationOffset)
|
||||
writeSecBuffer(msg, uint16(len(encryptedSessionKey)), sessionOffset)
|
||||
writeU32(msg, flags)
|
||||
if !opts.OmitVersion {
|
||||
msg.Write(ntlmVersion())
|
||||
}
|
||||
micPos := -1
|
||||
if includeMIC {
|
||||
micPos = msg.Len()
|
||||
msg.Write(make([]byte, 16))
|
||||
}
|
||||
msg.Write(payload.Bytes())
|
||||
|
||||
if includeMIC {
|
||||
micInput := bytes.NewBuffer(nil)
|
||||
micInput.Write(negotiate)
|
||||
micInput.Write(ch.Raw)
|
||||
micInput.Write(msg.Bytes())
|
||||
mic := hmacMD5(exportedSessionKey, micInput.Bytes())
|
||||
copy(msg.Bytes()[micPos:micPos+16], mic)
|
||||
}
|
||||
|
||||
clientSeal, _ := rc4.NewCipher(md5sum(append(exportedSessionKey, []byte("session key to client-to-server sealing key magic constant\x00")...)))
|
||||
serverSeal, _ := rc4.NewCipher(md5sum(append(exportedSessionKey, []byte("session key to server-to-client sealing key magic constant\x00")...)))
|
||||
sec := &ntlmSecurity{
|
||||
clientSigningKey: md5sum(append(exportedSessionKey, []byte("session key to client-to-server signing key magic constant\x00")...)),
|
||||
serverSigningKey: md5sum(append(exportedSessionKey, []byte("session key to server-to-client signing key magic constant\x00")...)),
|
||||
clientSealing: clientSeal,
|
||||
serverSealing: serverSeal,
|
||||
}
|
||||
return msg.Bytes(), sec, nil
|
||||
}
|
||||
|
||||
func (s *ntlmSecurity) encrypt(data []byte) []byte {
|
||||
sealed := make([]byte, len(data))
|
||||
s.clientSealing.XORKeyStream(sealed, data)
|
||||
macInput := bytes.NewBuffer(nil)
|
||||
writeU32(macInput, s.seq)
|
||||
macInput.Write(data)
|
||||
checksum := hmacMD5(s.clientSigningKey, macInput.Bytes())[:8]
|
||||
encryptedChecksum := make([]byte, 8)
|
||||
s.clientSealing.XORKeyStream(encryptedChecksum, checksum)
|
||||
out := bytes.NewBuffer(nil)
|
||||
writeU32(out, 1)
|
||||
out.Write(encryptedChecksum)
|
||||
writeU32(out, s.seq)
|
||||
out.Write(sealed)
|
||||
s.seq++
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func ntowfv2(password, user, domain string) []byte {
|
||||
h := md4.New()
|
||||
h.Write(utf16le(password))
|
||||
return hmacMD5(h.Sum(nil), utf16le(strings.ToUpper(user)+domain))
|
||||
}
|
||||
|
||||
func hmacMD5(key, data []byte) []byte {
|
||||
h := hmac.New(md5.New, key)
|
||||
h.Write(data)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func md5sum(data []byte) []byte {
|
||||
sum := md5.Sum(data)
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
func rc4k(key, data []byte) []byte {
|
||||
c, _ := rc4.NewCipher(key)
|
||||
out := make([]byte, len(data))
|
||||
c.XORKeyStream(out, data)
|
||||
return out
|
||||
}
|
||||
|
||||
func randomBytes(n int) []byte {
|
||||
buf := make([]byte, n)
|
||||
_, _ = rand.Read(buf)
|
||||
return buf
|
||||
}
|
||||
|
||||
func windowsFileTime(t time.Time) []byte {
|
||||
v := uint64(t.UnixNano()/100) + 116444736000000000
|
||||
out := make([]byte, 8)
|
||||
binary.LittleEndian.PutUint64(out, v)
|
||||
return out
|
||||
}
|
||||
|
||||
func findAVPair(data []byte, id uint16) []byte {
|
||||
for len(data) >= 4 {
|
||||
avID := binary.LittleEndian.Uint16(data[0:2])
|
||||
avLen := int(binary.LittleEndian.Uint16(data[2:4]))
|
||||
data = data[4:]
|
||||
if avLen > len(data) {
|
||||
return nil
|
||||
}
|
||||
if avID == msvAvEOL {
|
||||
return nil
|
||||
}
|
||||
if avID == id {
|
||||
return append([]byte(nil), data[:avLen]...)
|
||||
}
|
||||
data = data[avLen:]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requiresMIC(targetInfo []byte) bool {
|
||||
flags := findAVPair(targetInfo, msvAvFlags)
|
||||
if len(flags) < 4 {
|
||||
return false
|
||||
}
|
||||
return binary.LittleEndian.Uint32(flags)&0x00000002 != 0
|
||||
}
|
||||
|
||||
func withChannelBindings(targetInfo, hash []byte) []byte {
|
||||
out := bytes.NewBuffer(nil)
|
||||
inserted := false
|
||||
for len(targetInfo) >= 4 {
|
||||
avID := binary.LittleEndian.Uint16(targetInfo[0:2])
|
||||
avLen := int(binary.LittleEndian.Uint16(targetInfo[2:4]))
|
||||
if 4+avLen > len(targetInfo) {
|
||||
return targetInfo
|
||||
}
|
||||
if avID == msvAvEOL {
|
||||
if !inserted {
|
||||
writeU16(out, msvChannelBindings)
|
||||
writeU16(out, 16)
|
||||
out.Write(hash[:16])
|
||||
}
|
||||
out.Write(targetInfo[:4])
|
||||
return out.Bytes()
|
||||
}
|
||||
if avID == msvChannelBindings {
|
||||
writeU16(out, msvChannelBindings)
|
||||
writeU16(out, 16)
|
||||
out.Write(hash[:16])
|
||||
inserted = true
|
||||
} else {
|
||||
out.Write(targetInfo[:4+avLen])
|
||||
}
|
||||
targetInfo = targetInfo[4+avLen:]
|
||||
}
|
||||
if !inserted {
|
||||
writeU16(out, msvChannelBindings)
|
||||
writeU16(out, 16)
|
||||
out.Write(hash[:16])
|
||||
}
|
||||
out.Write([]byte{0, 0, 0, 0})
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func writeSecBuffer(buf *bytes.Buffer, length uint16, offset uint32) {
|
||||
writeU16(buf, length)
|
||||
writeU16(buf, length)
|
||||
writeU32(buf, offset)
|
||||
}
|
||||
|
||||
func writeU16(buf *bytes.Buffer, v uint16) {
|
||||
_ = binary.Write(buf, binary.LittleEndian, v)
|
||||
}
|
||||
|
||||
func writeU32(buf *bytes.Buffer, v uint32) {
|
||||
_ = binary.Write(buf, binary.LittleEndian, v)
|
||||
}
|
||||
|
||||
func ntlmVersion() []byte {
|
||||
out := []byte{10, 0, 0, 0, 0, 0, 0, 15}
|
||||
binary.LittleEndian.PutUint16(out[2:4], 19045)
|
||||
return out
|
||||
}
|
||||
|
||||
func utf16le(s string) []byte {
|
||||
out := bytes.NewBuffer(nil)
|
||||
for _, r := range s {
|
||||
if r > 0xffff {
|
||||
r = '?'
|
||||
}
|
||||
writeU16(out, uint16(r))
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func utf16leString(data []byte) string {
|
||||
runes := make([]rune, 0, len(data)/2)
|
||||
for i := 0; i+1 < len(data); i += 2 {
|
||||
v := binary.LittleEndian.Uint16(data[i : i+2])
|
||||
if v == 0 {
|
||||
continue
|
||||
}
|
||||
runes = append(runes, rune(v))
|
||||
}
|
||||
return string(runes)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package credssp
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/asn1"
|
||||
"math/big"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSubjectPublicKeyDiffersFromPKIX(t *testing.T) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "rdp-test"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
}, &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "rdp-test"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
}, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(certDER)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subjectKey, err := subjectPublicKey(cert)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pkixKey, err := x509.MarshalPKIXPublicKey(cert.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(subjectKey) == string(pkixKey) {
|
||||
t.Fatal("SubjectPublicKey 不应等于完整 SPKI")
|
||||
}
|
||||
rsaDER, err := asn1.Marshal(*cert.PublicKey.(*rsa.PublicKey))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(subjectKey) != string(rsaDER) {
|
||||
t.Fatalf("SubjectPublicKey 应与 RSA PKCS#1 DER 一致,len=%d/%d", len(subjectKey), len(rsaDER))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
)
|
||||
|
||||
// Bitmap 表示协议层解码后的 BGRA/RGB565 小块,display 只负责合成和输出 // Logan.Li
|
||||
type Bitmap struct {
|
||||
X int
|
||||
Y int
|
||||
Width int
|
||||
Height int
|
||||
BitsPerPixel int
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// Frame 表示一次合成后的脏区域输出 // Logan.Li
|
||||
type Frame struct {
|
||||
X int
|
||||
Y int
|
||||
Width int
|
||||
Height int
|
||||
Image string
|
||||
RGBA []byte
|
||||
}
|
||||
|
||||
// Framebuffer 借鉴 Guacamole/bring 的 display/layer 思路,在后端维护完整画布 // Logan.Li
|
||||
type Framebuffer struct {
|
||||
width int
|
||||
height int
|
||||
img *image.RGBA
|
||||
dirty image.Rectangle
|
||||
}
|
||||
|
||||
// NewFramebuffer 创建固定尺寸远端画布,所有 RDP 更新先合成到这里 // Logan.Li
|
||||
func NewFramebuffer(width, height int) *Framebuffer {
|
||||
if width <= 0 {
|
||||
width = 1280
|
||||
}
|
||||
if height <= 0 {
|
||||
height = 800
|
||||
}
|
||||
return &Framebuffer{
|
||||
width: width,
|
||||
height: height,
|
||||
img: image.NewRGBA(image.Rect(0, 0, width, height)),
|
||||
}
|
||||
}
|
||||
|
||||
// Draw 合成一个 bitmap 小块,并扩大脏矩形 // Logan.Li
|
||||
func (f *Framebuffer) Draw(b Bitmap) {
|
||||
if b.Width <= 0 || b.Height <= 0 || len(b.Data) == 0 {
|
||||
return
|
||||
}
|
||||
dstRect := image.Rect(b.X, b.Y, b.X+b.Width, b.Y+b.Height).Intersect(f.img.Bounds())
|
||||
if dstRect.Empty() {
|
||||
return
|
||||
}
|
||||
src := bitmapToRGBA(b)
|
||||
srcPoint := image.Pt(dstRect.Min.X-b.X, dstRect.Min.Y-b.Y)
|
||||
draw.Draw(f.img, dstRect, src, srcPoint, draw.Src)
|
||||
if f.dirty.Empty() {
|
||||
f.dirty = dstRect
|
||||
} else {
|
||||
f.dirty = f.dirty.Union(dstRect)
|
||||
}
|
||||
}
|
||||
|
||||
// Flush 输出当前脏区域 PNG,并清空脏矩形 // Logan.Li
|
||||
func (f *Framebuffer) Flush() (Frame, bool) {
|
||||
frame, ok := f.FlushRGBA()
|
||||
if !ok {
|
||||
return Frame{}, false
|
||||
}
|
||||
img := image.NewRGBA(image.Rect(0, 0, frame.Width, frame.Height))
|
||||
copy(img.Pix, frame.RGBA)
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return Frame{}, false
|
||||
}
|
||||
frame.Image = "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||
return frame, true
|
||||
}
|
||||
|
||||
// FlushRGBA 输出当前脏区域原始 RGBA,避免实时 RDP 路径重复 PNG/base64 编解码。 // Logan.Li
|
||||
func (f *Framebuffer) FlushRGBA() (Frame, bool) {
|
||||
if f.dirty.Empty() {
|
||||
return Frame{}, false
|
||||
}
|
||||
rect := f.dirty.Intersect(f.img.Bounds())
|
||||
f.dirty = image.Rectangle{}
|
||||
if rect.Empty() {
|
||||
return Frame{}, false
|
||||
}
|
||||
crop := image.NewRGBA(image.Rect(0, 0, rect.Dx(), rect.Dy()))
|
||||
draw.Draw(crop, crop.Bounds(), f.img, rect.Min, draw.Src)
|
||||
return Frame{
|
||||
X: rect.Min.X,
|
||||
Y: rect.Min.Y,
|
||||
Width: rect.Dx(),
|
||||
Height: rect.Dy(),
|
||||
RGBA: append([]byte(nil), crop.Pix...),
|
||||
}, true
|
||||
}
|
||||
|
||||
func bitmapToRGBA(b Bitmap) *image.RGBA {
|
||||
img := image.NewRGBA(image.Rect(0, 0, b.Width, b.Height))
|
||||
pix := img.Pix
|
||||
switch b.BitsPerPixel {
|
||||
case 1, 2:
|
||||
n := b.Width * b.Height
|
||||
for i := 0; i < n && i*2+1 < len(b.Data); i++ {
|
||||
// grdp 的 RLE 输出 16bpp 使用大端 RGB565,按小端会导致蓝紫/绿色偏色。 // Logan.Li
|
||||
c := binary.BigEndian.Uint16(b.Data[i*2 : i*2+2])
|
||||
r := uint8((c >> 11) & 0x1F)
|
||||
g := uint8((c >> 5) & 0x3F)
|
||||
bl := uint8(c & 0x1F)
|
||||
off := i * 4
|
||||
pix[off] = (r << 3) | (r >> 2)
|
||||
pix[off+1] = (g << 2) | (g >> 4)
|
||||
pix[off+2] = (bl << 3) | (bl >> 2)
|
||||
pix[off+3] = 0xFF
|
||||
}
|
||||
default:
|
||||
stride := b.BitsPerPixel
|
||||
n := b.Width * b.Height
|
||||
for i := 0; i < n; i++ {
|
||||
s := i * stride
|
||||
if s+2 >= len(b.Data) {
|
||||
break
|
||||
}
|
||||
off := i * 4
|
||||
pix[off] = b.Data[s+2]
|
||||
pix[off+1] = b.Data[s+1]
|
||||
pix[off+2] = b.Data[s]
|
||||
pix[off+3] = 0xFF
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config 是后端 RDP 渲染引擎的连接配置,凭据只在服务端使用 // Logan.Li
|
||||
type Config struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
Domain string
|
||||
Security string
|
||||
Width int
|
||||
Height int
|
||||
MCSMode string
|
||||
Debug bool
|
||||
}
|
||||
|
||||
// ClientEvent 是浏览器发回后端的输入事件 // Logan.Li
|
||||
type ClientEvent struct {
|
||||
Type string `json:"type"`
|
||||
X int `json:"x,omitempty"`
|
||||
Y int `json:"y,omitempty"`
|
||||
Button int `json:"button,omitempty"`
|
||||
DeltaY int `json:"delta_y,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Down bool `json:"down,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// ServerFrame 是后端推给浏览器 Canvas 的统一帧协议 // Logan.Li
|
||||
type ServerFrame struct {
|
||||
Type string `json:"type"`
|
||||
X int `json:"x,omitempty"`
|
||||
Y int `json:"y,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
RGBA []byte `json:"-"`
|
||||
}
|
||||
|
||||
// FrameSink 接收 RDP 引擎输出,控制器负责写入 WebSocket // Logan.Li
|
||||
type FrameSink interface {
|
||||
SendFrame(ServerFrame) error
|
||||
}
|
||||
|
||||
// Engine 隔离自研 RDP 协议实现,避免控制器关心握手和图形帧细节 // Logan.Li
|
||||
type Engine interface {
|
||||
Run(ctx context.Context, cfg Config, sink FrameSink, events <-chan ClientEvent) error
|
||||
}
|
||||
|
||||
// HandshakeInfo 记录轻量 RDP 第一阶段握手结果,用于前端诊断 // Logan.Li
|
||||
type HandshakeInfo struct {
|
||||
Address string
|
||||
SelectedProtocol string
|
||||
Duration time.Duration
|
||||
TLSCipher string
|
||||
CertSubject string
|
||||
}
|
||||
|
||||
// MemoryRecorder 保存图形帧摘要,后续可替换为 PNG/JPEG 序列录屏 // Logan.Li
|
||||
type MemoryRecorder struct {
|
||||
mu sync.Mutex
|
||||
events []ServerFrame
|
||||
limit int
|
||||
}
|
||||
|
||||
// NewMemoryRecorder 创建内存帧记录器,限制录屏摘要常驻内存大小 // Logan.Li
|
||||
func NewMemoryRecorder(limit int) *MemoryRecorder {
|
||||
if limit <= 0 {
|
||||
limit = 500
|
||||
}
|
||||
return &MemoryRecorder{limit: limit}
|
||||
}
|
||||
|
||||
// Append 记录一帧 RDP 输出,超过上限时丢弃最早帧 // Logan.Li
|
||||
func (r *MemoryRecorder) Append(frame ServerFrame) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
// 实时帧只用于 WebSocket 推送,审计内存索引保留元数据避免拖动窗口时触发大对象 GC。 // Logan.Li
|
||||
frame.RGBA = nil
|
||||
if frame.Type == "bitmap" {
|
||||
frame.Image = ""
|
||||
}
|
||||
if len(r.events) >= r.limit {
|
||||
copy(r.events, r.events[1:])
|
||||
r.events[len(r.events)-1] = frame
|
||||
return
|
||||
}
|
||||
r.events = append(r.events, frame)
|
||||
}
|
||||
|
||||
// AppendSnapshot 记录节流后的图形快照,用于 RDP 历史回放。 // Logan.Li
|
||||
func (r *MemoryRecorder) AppendSnapshot(frame ServerFrame) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
frame.RGBA = nil
|
||||
if len(r.events) >= r.limit {
|
||||
copy(r.events, r.events[1:])
|
||||
r.events[len(r.events)-1] = frame
|
||||
return
|
||||
}
|
||||
r.events = append(r.events, frame)
|
||||
}
|
||||
|
||||
// JSON 输出前端回放需要的轻量帧序列 // Logan.Li
|
||||
func (r *MemoryRecorder) JSON() string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
b, _ := json.Marshal(struct {
|
||||
Version int `json:"version"`
|
||||
Time time.Time `json:"time"`
|
||||
Frames []ServerFrame `json:"frames"`
|
||||
}{Version: 1, Time: time.Now(), Frames: r.events})
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package rdp
|
||||
|
||||
import (
|
||||
"ezops/pkg/rdp/domain"
|
||||
"log"
|
||||
)
|
||||
|
||||
// Config 是后端 RDP 渲染引擎的连接配置,根包保留别名兼容控制器 // Logan.Li
|
||||
type Config = domain.Config
|
||||
|
||||
// ClientEvent 是浏览器发回后端的输入事件,根包保留别名兼容控制器 // Logan.Li
|
||||
type ClientEvent = domain.ClientEvent
|
||||
|
||||
// ServerFrame 是后端推给浏览器 Canvas 的统一帧协议,根包保留别名兼容控制器 // Logan.Li
|
||||
type ServerFrame = domain.ServerFrame
|
||||
|
||||
// FrameSink 接收 RDP 引擎输出,根包保留别名兼容控制器 // Logan.Li
|
||||
type FrameSink = domain.FrameSink
|
||||
|
||||
// Engine 隔离自研 RDP 协议实现,根包保留别名兼容控制器 // Logan.Li
|
||||
type Engine = domain.Engine
|
||||
|
||||
// HandshakeInfo 记录轻量 RDP 第一阶段握手结果,根包保留别名兼容诊断入口 // Logan.Li
|
||||
type HandshakeInfo = domain.HandshakeInfo
|
||||
|
||||
// MemoryRecorder 保存图形帧摘要,根包保留别名兼容审计回放 // Logan.Li
|
||||
type MemoryRecorder = domain.MemoryRecorder
|
||||
|
||||
const EngineRDP = "rdp"
|
||||
|
||||
// SelectedEngineName 返回当前 RDP 引擎名称,固定使用 AIOps 自研 RDP 引擎。 // Logan.Li
|
||||
func SelectedEngineName() string {
|
||||
return EngineRDP
|
||||
}
|
||||
|
||||
// NewEngine 创建自研 RDP 图形引擎,避免依赖第三方协议栈。 // Logan.Li
|
||||
func NewEngine() Engine {
|
||||
name := SelectedEngineName()
|
||||
log.Printf("[RDP] selected engine=%s", name)
|
||||
return &SessionEngine{}
|
||||
}
|
||||
|
||||
// NewMemoryRecorder 创建内存帧记录器,根包保留构造函数兼容审计回放 // Logan.Li
|
||||
func NewMemoryRecorder(limit int) *MemoryRecorder {
|
||||
return domain.NewMemoryRecorder(limit)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package rdp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
|
||||
"ezops/pkg/rdp/trace"
|
||||
)
|
||||
|
||||
const (
|
||||
fastPathOutputCompressionUsed = 0xC0
|
||||
fastPathFragmentSingle = 0x00
|
||||
fastPathFragmentLast = 0x10
|
||||
fastPathFragmentFirst = 0x20
|
||||
fastPathFragmentNext = 0x30
|
||||
)
|
||||
|
||||
// FastPathReceiver 解析服务端 Fast-Path 更新并产出位图块 // Logan.Li
|
||||
type FastPathReceiver struct {
|
||||
mppc *MppcDecompressor
|
||||
fragments bytes.Buffer
|
||||
frameIDs []uint32
|
||||
updateStats map[byte]int
|
||||
}
|
||||
|
||||
func NewFastPathReceiver() *FastPathReceiver {
|
||||
return &FastPathReceiver{mppc: NewMppcDecompressor(), updateStats: make(map[byte]int)}
|
||||
}
|
||||
|
||||
func (f *FastPathReceiver) Handle(secFlag byte, payload []byte) ([]BitmapRect, error) {
|
||||
_ = secFlag
|
||||
r := bytes.NewReader(payload)
|
||||
var rects []BitmapRect
|
||||
for r.Len() > 0 {
|
||||
updateHeader, err := readU8(r)
|
||||
if err != nil {
|
||||
return rects, err
|
||||
}
|
||||
updateCode := updateHeader & 0x0f
|
||||
fragmentation := updateHeader & 0x30
|
||||
compression := updateHeader & 0xC0
|
||||
f.updateStats[updateCode]++
|
||||
|
||||
var compressionFlags byte
|
||||
if compression == fastPathOutputCompressionUsed {
|
||||
compressionFlags, err = readU8(r)
|
||||
if err != nil {
|
||||
return rects, err
|
||||
}
|
||||
}
|
||||
size, err := readLE16(r)
|
||||
if err != nil {
|
||||
return rects, err
|
||||
}
|
||||
if f.updateStats[updateCode] <= 8 || f.updateStats[updateCode]%100 == 0 {
|
||||
trace.Logf("fastpath update code=%s(0x%x) count=%d size=%d frag=0x%02x compression=0x%02x flags=0x%02x", fastPathUpdateName(updateCode), updateCode, f.updateStats[updateCode], size, fragmentation, compression, compressionFlags)
|
||||
}
|
||||
block := make([]byte, int(size))
|
||||
if _, err = r.Read(block); err != nil {
|
||||
return rects, err
|
||||
}
|
||||
|
||||
if fragmentation != fastPathFragmentSingle {
|
||||
if fragmentation == fastPathFragmentFirst {
|
||||
f.fragments.Reset()
|
||||
}
|
||||
f.fragments.Write(block)
|
||||
if fragmentation != fastPathFragmentLast {
|
||||
continue
|
||||
}
|
||||
block = append([]byte(nil), f.fragments.Bytes()...)
|
||||
}
|
||||
if compressionFlags != 0 {
|
||||
decompressed, err := f.mppc.Decompress(compressionFlags, block)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
block = decompressed
|
||||
}
|
||||
|
||||
switch updateCode {
|
||||
case fastPathUpdateBitmap:
|
||||
bs, err := parseFastPathBitmap(block)
|
||||
if err != nil {
|
||||
return rects, err
|
||||
}
|
||||
rects = append(rects, bs...)
|
||||
case fastPathUpdateSurfaceCmds:
|
||||
bs, frameIDs, err := parseSurfaceCommands(block)
|
||||
if err != nil {
|
||||
return rects, err
|
||||
}
|
||||
f.frameIDs = append(f.frameIDs, frameIDs...)
|
||||
rects = append(rects, bs...)
|
||||
default:
|
||||
trace.Logf("fastpath skip update code=%s(0x%x) size=%d", fastPathUpdateName(updateCode), updateCode, len(block))
|
||||
}
|
||||
}
|
||||
return rects, nil
|
||||
}
|
||||
|
||||
func fastPathUpdateName(code byte) string {
|
||||
switch code {
|
||||
case 0x0:
|
||||
return "orders"
|
||||
case fastPathUpdateBitmap:
|
||||
return "bitmap"
|
||||
case 0x2:
|
||||
return "palette"
|
||||
case 0x3:
|
||||
return "synchronize"
|
||||
case fastPathUpdateSurfaceCmds:
|
||||
return "surface"
|
||||
case 0x5:
|
||||
return "ptr_null"
|
||||
case 0x6:
|
||||
return "ptr_default"
|
||||
case 0x8:
|
||||
return "ptr_position"
|
||||
case 0x9:
|
||||
return "color"
|
||||
case 0x0a:
|
||||
return "ptr_cached"
|
||||
case 0x0b:
|
||||
return "pointer"
|
||||
case 0x0c:
|
||||
return "large_pointer"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// TakeFrameIDs 取出需要确认的 Surface frame,避免服务端因队列满停止推帧 // Logan.Li
|
||||
func (f *FastPathReceiver) TakeFrameIDs() []uint32 {
|
||||
if len(f.frameIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := append([]uint32(nil), f.frameIDs...)
|
||||
f.frameIDs = f.frameIDs[:0]
|
||||
return out
|
||||
}
|
||||
|
||||
func (f *FastPathReceiver) HandleSlowPath(payload []byte) ([]BitmapRect, error) {
|
||||
ind, err := parseMCSDataIndication(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ind.payload) < 6 {
|
||||
return nil, nil
|
||||
}
|
||||
return parseSlowPathBitmap(ind.payload[4:])
|
||||
}
|
||||
|
||||
func parseSurfaceCommands(data []byte) ([]BitmapRect, []uint32, error) {
|
||||
r := bytes.NewReader(data)
|
||||
var out []BitmapRect
|
||||
var frameIDs []uint32
|
||||
for r.Len() >= 2 {
|
||||
cmdType, err := readLE16(r)
|
||||
if err != nil {
|
||||
return out, frameIDs, err
|
||||
}
|
||||
switch cmdType {
|
||||
case 0x0001, 0x0006:
|
||||
wb, err := readSurfaceBits(r)
|
||||
if err != nil {
|
||||
return out, frameIDs, err
|
||||
}
|
||||
if rect, ok := decodeWireBitmap(wb); ok {
|
||||
out = append(out, rect)
|
||||
}
|
||||
case 0x0004:
|
||||
frameAction, err := readLE16(r)
|
||||
if err != nil {
|
||||
return out, frameIDs, err
|
||||
}
|
||||
frameID, err := readLE32(r)
|
||||
if err != nil {
|
||||
return out, frameIDs, err
|
||||
}
|
||||
trace.Logf("surface frame marker action=%d frameID=%d", frameAction, frameID)
|
||||
if frameAction == 0x0001 {
|
||||
frameIDs = append(frameIDs, frameID)
|
||||
}
|
||||
default:
|
||||
return out, frameIDs, fmt.Errorf("未知 SurfaceCmd: 0x%04x", cmdType)
|
||||
}
|
||||
}
|
||||
return out, frameIDs, nil
|
||||
}
|
||||
|
||||
func readSurfaceBits(r *bytes.Reader) (wireBitmap, error) {
|
||||
destLeft, err := readLE16(r)
|
||||
if err != nil {
|
||||
return wireBitmap{}, err
|
||||
}
|
||||
destTop, err := readLE16(r)
|
||||
if err != nil {
|
||||
return wireBitmap{}, err
|
||||
}
|
||||
destRight, err := readLE16(r)
|
||||
if err != nil {
|
||||
return wireBitmap{}, err
|
||||
}
|
||||
destBottom, err := readLE16(r)
|
||||
if err != nil {
|
||||
return wireBitmap{}, err
|
||||
}
|
||||
bpp, err := readU8(r)
|
||||
if err != nil {
|
||||
return wireBitmap{}, err
|
||||
}
|
||||
flags, err := readU8(r)
|
||||
if err != nil {
|
||||
return wireBitmap{}, err
|
||||
}
|
||||
if _, err = readU8(r); err != nil {
|
||||
return wireBitmap{}, err
|
||||
}
|
||||
codecID, err := readU8(r)
|
||||
if err != nil {
|
||||
return wireBitmap{}, err
|
||||
}
|
||||
width, err := readLE16(r)
|
||||
if err != nil {
|
||||
return wireBitmap{}, err
|
||||
}
|
||||
height, err := readLE16(r)
|
||||
if err != nil {
|
||||
return wireBitmap{}, err
|
||||
}
|
||||
dataLen, err := readLE32(r)
|
||||
if err != nil {
|
||||
return wireBitmap{}, err
|
||||
}
|
||||
if flags&0x01 != 0 {
|
||||
if dataLen < 24 {
|
||||
return wireBitmap{}, fmt.Errorf("SurfaceBits 扩展头长度非法: %d", dataLen)
|
||||
}
|
||||
if _, err = r.Seek(24, 1); err != nil {
|
||||
return wireBitmap{}, err
|
||||
}
|
||||
dataLen -= 24
|
||||
}
|
||||
data := make([]byte, int(dataLen))
|
||||
if _, err = r.Read(data); err != nil {
|
||||
return wireBitmap{}, err
|
||||
}
|
||||
if codecID != 0 {
|
||||
trace.Logf("surface skip unsupported codec=%d rect=%dx%d@%d,%d data=%d flags=0x%02x", codecID, width, height, destLeft, destTop, dataLen, flags)
|
||||
return wireBitmap{}, nil
|
||||
}
|
||||
trace.Logf("surface bits codec=%d rect=%dx%d@%d,%d data=%d flags=0x%02x", codecID, width, height, destLeft, destTop, dataLen, flags)
|
||||
return wireBitmap{
|
||||
DestLeft: destLeft, DestTop: destTop, DestRight: destRight, DestBottom: destBottom,
|
||||
Width: width, Height: height, BitsPerPixel: uint16(bpp), Flags: uint16(flags) | bitmapNoProcessing,
|
||||
BitmapDataStream: data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func readU8(r *bytes.Reader) (byte, error) {
|
||||
b, err := r.ReadByte()
|
||||
return b, err
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package rdp
|
||||
|
||||
import (
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf16"
|
||||
)
|
||||
|
||||
const (
|
||||
fastPathInputEventScancode = 0
|
||||
fastPathInputEventMouse = 1
|
||||
fastPathInputEventUnicode = 4
|
||||
|
||||
fastPathInputKBRelease = 0x01
|
||||
fastPathInputKBExtended = 0x02
|
||||
fastPathInputKBExtended1 = 0x04
|
||||
|
||||
ptrFlagsMove = 0x0800
|
||||
ptrFlagsDown = 0x8000
|
||||
ptrFlagsBtn1 = 0x1000
|
||||
ptrFlagsBtn2 = 0x2000
|
||||
ptrFlagsBtn3 = 0x4000
|
||||
ptrFlagsWheel = 0x0200
|
||||
)
|
||||
|
||||
// InputSender 通过 Fast-Path Input 向远端发送键鼠事件 // Logan.Li
|
||||
type InputSender struct {
|
||||
w io.Writer
|
||||
mu *sync.Mutex
|
||||
}
|
||||
|
||||
func NewInputSender(w io.Writer) *InputSender {
|
||||
return &InputSender{w: w}
|
||||
}
|
||||
|
||||
func NewInputSenderWithLock(w io.Writer, mu *sync.Mutex) *InputSender {
|
||||
return &InputSender{w: w, mu: mu}
|
||||
}
|
||||
|
||||
func (s *InputSender) Send(events []byte) error {
|
||||
if s.mu != nil {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
}
|
||||
return WriteFastPathInput(s.w, events)
|
||||
}
|
||||
|
||||
func (s *InputSender) Key(scancode int, down bool) error {
|
||||
flags := byte(0)
|
||||
if !down {
|
||||
flags |= fastPathInputKBRelease
|
||||
}
|
||||
code := uint16(scancode)
|
||||
if scancode&0xFF00 == 0xE000 {
|
||||
flags |= fastPathInputKBExtended
|
||||
code = uint16(scancode & 0xFF)
|
||||
}
|
||||
buf := []byte{byte(fastPathInputEventScancode<<5) | flags}
|
||||
buf = append(buf, byte(code))
|
||||
return s.Send(buf)
|
||||
}
|
||||
|
||||
func (s *InputSender) MouseMove(x, y int) error {
|
||||
return s.mouseEvent(ptrFlagsMove, x, y)
|
||||
}
|
||||
|
||||
func (s *InputSender) MouseButton(x, y, button int, down bool) error {
|
||||
flags := ptrFlagsDown
|
||||
// 浏览器 MouseEvent.button 使用 0/1/2 表示左/中/右键,RDP BTN2 才是右键。 // Logan.Li
|
||||
switch button {
|
||||
case 2:
|
||||
flags |= ptrFlagsBtn2
|
||||
case 1:
|
||||
flags |= ptrFlagsBtn3
|
||||
default:
|
||||
flags |= ptrFlagsBtn1
|
||||
}
|
||||
if !down {
|
||||
flags &^= ptrFlagsDown
|
||||
}
|
||||
return s.mouseEvent(flags, x, y)
|
||||
}
|
||||
|
||||
func (s *InputSender) MouseWheel(x, y, deltaY int) error {
|
||||
scroll := -deltaY * 120 / 100
|
||||
if scroll == 0 && deltaY != 0 {
|
||||
if deltaY > 0 {
|
||||
scroll = -120
|
||||
} else {
|
||||
scroll = 120
|
||||
}
|
||||
}
|
||||
flags := uint16(ptrFlagsWheel | 0x0001)
|
||||
if scroll < 0 {
|
||||
flags |= 0x0100
|
||||
scroll = -scroll
|
||||
}
|
||||
buf := []byte{byte(fastPathInputEventMouse << 5)}
|
||||
buf = append(buf, byte(flags), byte(flags>>8))
|
||||
buf = append(buf, byte(x), byte(x>>8), byte(y), byte(y>>8))
|
||||
buf = append(buf, byte(scroll), byte(scroll>>8))
|
||||
return s.Send(buf)
|
||||
}
|
||||
|
||||
func (s *InputSender) mouseEvent(flags int, x, y int) error {
|
||||
buf := []byte{byte(fastPathInputEventMouse << 5)}
|
||||
buf = append(buf, byte(flags), byte(flags>>8))
|
||||
buf = append(buf, byte(x), byte(x>>8), byte(y), byte(y>>8))
|
||||
return s.Send(buf)
|
||||
}
|
||||
|
||||
func (s *InputSender) TypeText(text string) error {
|
||||
for _, r := range utf16.Encode([]rune(text)) {
|
||||
if err := s.unicodeKey(uint16(r), true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.unicodeKey(uint16(r), false); err != nil {
|
||||
return err
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *InputSender) unicodeKey(code uint16, down bool) error {
|
||||
flags := byte(0)
|
||||
if !down {
|
||||
flags |= fastPathInputKBRelease
|
||||
}
|
||||
buf := []byte{byte(fastPathInputEventUnicode<<5) | flags, byte(code), byte(code >> 8)}
|
||||
return s.Send(buf)
|
||||
}
|
||||
|
||||
func (s *InputSender) AutoExec(command string) error {
|
||||
if err := s.Key(0xE05B, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.Key(0xE05B, false); err != nil {
|
||||
return err
|
||||
}
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
if err := s.Key(0xE05B, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.Key(0x0015, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.Key(0x0015, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.Key(0xE05B, false); err != nil {
|
||||
return err
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if err := s.TypeText(command); err != nil {
|
||||
return err
|
||||
}
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
return s.Key(0x001C, true)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package rdp
|
||||
|
||||
// ScanCodeFromJS 将浏览器 KeyboardEvent.code 转为 RDP 扫描码 // Logan.Li
|
||||
func ScanCodeFromJS(code string) (int, bool) {
|
||||
sc, ok := jsScanCodes[code]
|
||||
return sc, ok
|
||||
}
|
||||
|
||||
// jsScanCodes 将浏览器 KeyboardEvent.code 映射为 RDP 扫描码;扩展键使用 0xE0xx 约定 // Logan.Li
|
||||
var jsScanCodes = map[string]int{
|
||||
"Escape": 0x0001,
|
||||
"Digit1": 0x0002,
|
||||
"Digit2": 0x0003,
|
||||
"Digit3": 0x0004,
|
||||
"Digit4": 0x0005,
|
||||
"Digit5": 0x0006,
|
||||
"Digit6": 0x0007,
|
||||
"Digit7": 0x0008,
|
||||
"Digit8": 0x0009,
|
||||
"Digit9": 0x000A,
|
||||
"Digit0": 0x000B,
|
||||
"Minus": 0x000C,
|
||||
"Equal": 0x000D,
|
||||
"Backspace": 0x000E,
|
||||
"Tab": 0x000F,
|
||||
"KeyQ": 0x0010,
|
||||
"KeyW": 0x0011,
|
||||
"KeyE": 0x0012,
|
||||
"KeyR": 0x0013,
|
||||
"KeyT": 0x0014,
|
||||
"KeyY": 0x0015,
|
||||
"KeyU": 0x0016,
|
||||
"KeyI": 0x0017,
|
||||
"KeyO": 0x0018,
|
||||
"KeyP": 0x0019,
|
||||
"BracketLeft": 0x001A,
|
||||
"BracketRight": 0x001B,
|
||||
"Enter": 0x001C,
|
||||
"ControlLeft": 0x001D,
|
||||
"KeyA": 0x001E,
|
||||
"KeyS": 0x001F,
|
||||
"KeyD": 0x0020,
|
||||
"KeyF": 0x0021,
|
||||
"KeyG": 0x0022,
|
||||
"KeyH": 0x0023,
|
||||
"KeyJ": 0x0024,
|
||||
"KeyK": 0x0025,
|
||||
"KeyL": 0x0026,
|
||||
"Semicolon": 0x0027,
|
||||
"Quote": 0x0028,
|
||||
"Backquote": 0x0029,
|
||||
"ShiftLeft": 0x002A,
|
||||
"Backslash": 0x002B,
|
||||
"KeyZ": 0x002C,
|
||||
"KeyX": 0x002D,
|
||||
"KeyC": 0x002E,
|
||||
"KeyV": 0x002F,
|
||||
"KeyB": 0x0030,
|
||||
"KeyN": 0x0031,
|
||||
"KeyM": 0x0032,
|
||||
"Comma": 0x0033,
|
||||
"Period": 0x0034,
|
||||
"Slash": 0x0035,
|
||||
"ShiftRight": 0x0036,
|
||||
"NumpadMultiply": 0x0037,
|
||||
"AltLeft": 0x0038,
|
||||
"Space": 0x0039,
|
||||
"CapsLock": 0x003A,
|
||||
"F1": 0x003B,
|
||||
"F2": 0x003C,
|
||||
"F3": 0x003D,
|
||||
"F4": 0x003E,
|
||||
"F5": 0x003F,
|
||||
"F6": 0x0040,
|
||||
"F7": 0x0041,
|
||||
"F8": 0x0042,
|
||||
"F9": 0x0043,
|
||||
"F10": 0x0044,
|
||||
"ScrollLock": 0x0046,
|
||||
"Numpad7": 0x0047,
|
||||
"Numpad8": 0x0048,
|
||||
"Numpad9": 0x0049,
|
||||
"NumpadSubtract": 0x004A,
|
||||
"Numpad4": 0x004B,
|
||||
"Numpad5": 0x004C,
|
||||
"Numpad6": 0x004D,
|
||||
"NumpadAdd": 0x004E,
|
||||
"Numpad1": 0x004F,
|
||||
"Numpad2": 0x0050,
|
||||
"Numpad3": 0x0051,
|
||||
"Numpad0": 0x0052,
|
||||
"NumpadDecimal": 0x0053,
|
||||
"F11": 0x0057,
|
||||
"F12": 0x0058,
|
||||
"NumpadEqual": 0x0059,
|
||||
"NumpadEnter": 0xE01C,
|
||||
"ControlRight": 0xE01D,
|
||||
"NumpadDivide": 0xE035,
|
||||
"PrintScreen": 0xE037,
|
||||
"AltRight": 0xE038,
|
||||
"NumLock": 0xE045,
|
||||
"Pause": 0xE046,
|
||||
"Home": 0xE047,
|
||||
"ArrowUp": 0xE048,
|
||||
"PageUp": 0xE049,
|
||||
"ArrowLeft": 0xE04B,
|
||||
"ArrowRight": 0xE04D,
|
||||
"End": 0xE04F,
|
||||
"ArrowDown": 0xE050,
|
||||
"PageDown": 0xE051,
|
||||
"Insert": 0xE052,
|
||||
"Delete": 0xE053,
|
||||
"ContextMenu": 0xE05D,
|
||||
"MetaLeft": 0xE05B,
|
||||
"MetaRight": 0xE05C,
|
||||
"IntlRo": 0x0073,
|
||||
"KanaMode": 0x0070,
|
||||
"IntlYen": 0x007D,
|
||||
"Convert": 0x0079,
|
||||
"NonConvert": 0x007B,
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,125 @@
|
||||
package rdp
|
||||
|
||||
// MPPC 解压器实现 RDP 64K 字典压缩(MS-RDPBCGR §3.1.8.4.1)// Logan.Li
|
||||
|
||||
const mppcHistorySize = 65536
|
||||
|
||||
const (
|
||||
mppcBig = 0x01
|
||||
mppcCompressed = 0x20
|
||||
mppcReset = 0x40
|
||||
mppcFlushed = 0x80
|
||||
)
|
||||
|
||||
// MppcDecompressor 维护单连接 MPPC 历史窗口 // Logan.Li
|
||||
type MppcDecompressor struct {
|
||||
history [mppcHistorySize]byte
|
||||
offset int
|
||||
}
|
||||
|
||||
func NewMppcDecompressor() *MppcDecompressor {
|
||||
return &MppcDecompressor{}
|
||||
}
|
||||
|
||||
func (d *MppcDecompressor) Decompress(flags byte, data []byte) ([]byte, error) {
|
||||
if flags&mppcFlushed != 0 {
|
||||
d.history = [mppcHistorySize]byte{}
|
||||
d.offset = 0
|
||||
} else if flags&mppcReset != 0 {
|
||||
d.offset = 0
|
||||
}
|
||||
if flags&mppcCompressed == 0 {
|
||||
for _, b := range data {
|
||||
d.history[d.offset] = b
|
||||
d.offset = (d.offset + 1) & (mppcHistorySize - 1)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
output := make([]byte, 0, len(data)*3)
|
||||
br := newMppcBitReader(data)
|
||||
outer:
|
||||
for br.bitsLeft >= 9 {
|
||||
if br.readBit() == 0 {
|
||||
b := byte(br.readBits(8))
|
||||
output = append(output, b)
|
||||
d.history[d.offset] = b
|
||||
d.offset = (d.offset + 1) & (mppcHistorySize - 1)
|
||||
} else {
|
||||
sel := (br.readBit() << 1) | br.readBit()
|
||||
var copyOffset int
|
||||
switch sel {
|
||||
case 0:
|
||||
if br.bitsLeft < 6 {
|
||||
break outer
|
||||
}
|
||||
copyOffset = br.readBits(6)
|
||||
case 1:
|
||||
if br.bitsLeft < 8 {
|
||||
break outer
|
||||
}
|
||||
copyOffset = 64 + br.readBits(8)
|
||||
case 2:
|
||||
if br.bitsLeft < 13 {
|
||||
break outer
|
||||
}
|
||||
copyOffset = 320 + br.readBits(13)
|
||||
case 3:
|
||||
if br.bitsLeft < 16 {
|
||||
break outer
|
||||
}
|
||||
copyOffset = 8192 + br.readBits(16)
|
||||
}
|
||||
copyLength := 3
|
||||
for br.bitsLeft > 0 {
|
||||
if br.readBit() == 0 {
|
||||
break
|
||||
}
|
||||
copyLength++
|
||||
}
|
||||
src := (d.offset - copyOffset - 1 + mppcHistorySize) & (mppcHistorySize - 1)
|
||||
for i := 0; i < copyLength; i++ {
|
||||
b := d.history[(src+i)&(mppcHistorySize-1)]
|
||||
output = append(output, b)
|
||||
d.history[d.offset] = b
|
||||
d.offset = (d.offset + 1) & (mppcHistorySize - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
type mppcBitReader struct {
|
||||
data []byte
|
||||
byteIdx int
|
||||
mask byte
|
||||
bitsLeft int
|
||||
}
|
||||
|
||||
func newMppcBitReader(data []byte) *mppcBitReader {
|
||||
return &mppcBitReader{data: data, mask: 0x80, bitsLeft: len(data) * 8}
|
||||
}
|
||||
|
||||
func (r *mppcBitReader) readBit() int {
|
||||
if r.bitsLeft == 0 {
|
||||
return 0
|
||||
}
|
||||
r.bitsLeft--
|
||||
bit := 0
|
||||
if r.data[r.byteIdx]&r.mask != 0 {
|
||||
bit = 1
|
||||
}
|
||||
r.mask >>= 1
|
||||
if r.mask == 0 {
|
||||
r.mask = 0x80
|
||||
r.byteIdx++
|
||||
}
|
||||
return bit
|
||||
}
|
||||
|
||||
func (r *mppcBitReader) readBits(n int) int {
|
||||
result := 0
|
||||
for i := 0; i < n; i++ {
|
||||
result = (result << 1) | r.readBit()
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package rdp
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"ezops/pkg/rdp/transport"
|
||||
)
|
||||
|
||||
const (
|
||||
fastPathActionFastPath = 0x0
|
||||
fastPathActionX224 = 0x3
|
||||
)
|
||||
|
||||
// WirePacket 表示从 RDP 连接读取到的一层帧 // Logan.Li
|
||||
type WirePacket struct {
|
||||
FastPath bool
|
||||
SecFlag byte
|
||||
Payload []byte
|
||||
}
|
||||
|
||||
// ReadWirePacket 读取 TPKT/X.224 或 FastPath 输出帧 // Logan.Li
|
||||
func ReadWirePacket(r io.Reader) (WirePacket, error) {
|
||||
var hdr [2]byte
|
||||
if _, err := io.ReadFull(r, hdr[:]); err != nil {
|
||||
return WirePacket{}, err
|
||||
}
|
||||
version := hdr[0]
|
||||
if version == fastPathActionX224 {
|
||||
var extHdr [2]byte
|
||||
if _, err := io.ReadFull(r, extHdr[:]); err != nil {
|
||||
return WirePacket{}, err
|
||||
}
|
||||
size := binary.BigEndian.Uint16(extHdr[:])
|
||||
if size < 4 {
|
||||
return WirePacket{}, fmt.Errorf("TPKT 长度非法: %d", size)
|
||||
}
|
||||
body := make([]byte, int(size)-4)
|
||||
if _, err := io.ReadFull(r, body); err != nil {
|
||||
return WirePacket{}, err
|
||||
}
|
||||
if len(body) < 3 || body[1] != 0xF0 {
|
||||
return WirePacket{}, fmt.Errorf("非 X.224 Data PDU")
|
||||
}
|
||||
return WirePacket{Payload: body[3:]}, nil
|
||||
}
|
||||
secFlag := (version >> 6) & 0x3
|
||||
length := int(hdr[1])
|
||||
packetSize := 0
|
||||
if length&0x80 != 0 {
|
||||
var extByte [1]byte
|
||||
if _, err := io.ReadFull(r, extByte[:]); err != nil {
|
||||
return WirePacket{}, err
|
||||
}
|
||||
packetSize = ((length &^ 0x80) << 8) + int(extByte[0]) - 3
|
||||
} else {
|
||||
packetSize = length - 2
|
||||
}
|
||||
if packetSize < 0 {
|
||||
return WirePacket{FastPath: true, SecFlag: secFlag}, nil
|
||||
}
|
||||
body := make([]byte, packetSize)
|
||||
if _, err := io.ReadFull(r, body); err != nil {
|
||||
return WirePacket{}, err
|
||||
}
|
||||
return WirePacket{FastPath: true, SecFlag: secFlag, Payload: body}, nil
|
||||
}
|
||||
|
||||
// WriteFastPathInput 发送 Fast-Path Input 事件批次 // Logan.Li
|
||||
func WriteFastPathInput(w io.Writer, events []byte) error {
|
||||
hdr := uint16(len(events)+3) | 0x8000
|
||||
frame := make([]byte, 0, len(events)+3)
|
||||
frame = append(frame, fastPathActionFastPath|0x04, byte(hdr>>8), byte(hdr))
|
||||
frame = append(frame, events...)
|
||||
return transport.WriteRaw(w, frame)
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package rle
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func decompress1(output *[]uint8, width, height int, input []uint8, size int) bool {
|
||||
var (
|
||||
prevline, line, count int
|
||||
offset, code int
|
||||
x int = width
|
||||
opcode int
|
||||
lastopcode int8 = -1
|
||||
insertmix, bicolour, isfillormix bool
|
||||
mixmask, mask uint8
|
||||
colour1, colour2 uint8
|
||||
mix uint8 = 0xff
|
||||
fom_mask uint8
|
||||
)
|
||||
out := *output
|
||||
for len(input) != 0 {
|
||||
fom_mask = 0
|
||||
code = CVAL(&input)
|
||||
opcode = code >> 4
|
||||
/* Handle different opcode forms */
|
||||
switch opcode {
|
||||
case 0xc, 0xd, 0xe:
|
||||
opcode -= 6
|
||||
count = int(code & 0xf)
|
||||
offset = 16
|
||||
break
|
||||
case 0xf:
|
||||
opcode = code & 0xf
|
||||
if opcode < 9 {
|
||||
count = int(CVAL(&input))
|
||||
count |= int(CVAL(&input) << 8)
|
||||
} else {
|
||||
count = 1
|
||||
if opcode < 0xb {
|
||||
count = 8
|
||||
}
|
||||
}
|
||||
offset = 0
|
||||
break
|
||||
default:
|
||||
opcode >>= 1
|
||||
count = int(code & 0x1f)
|
||||
offset = 32
|
||||
break
|
||||
}
|
||||
/* Handle strange cases for counts */
|
||||
if offset != 0 {
|
||||
isfillormix = ((opcode == 2) || (opcode == 7))
|
||||
if count == 0 {
|
||||
if isfillormix {
|
||||
count = int(CVAL(&input)) + 1
|
||||
} else {
|
||||
count = int(CVAL(&input) + offset)
|
||||
}
|
||||
} else if isfillormix {
|
||||
count <<= 3
|
||||
}
|
||||
}
|
||||
/* Read preliminary data */
|
||||
switch opcode {
|
||||
case 0: /* Fill */
|
||||
if (lastopcode == int8(opcode)) && !((x == width) && (prevline == 0)) {
|
||||
insertmix = true
|
||||
}
|
||||
break
|
||||
case 8: /* Bicolour */
|
||||
colour1 = uint8(CVAL(&input))
|
||||
colour2 = uint8(CVAL(&input))
|
||||
break
|
||||
case 3: /* Colour */
|
||||
colour2 = uint8(CVAL(&input))
|
||||
break
|
||||
case 6: /* SetMix/Mix */
|
||||
fallthrough
|
||||
case 7: /* SetMix/FillOrMix */
|
||||
mix = uint8(CVAL(&input))
|
||||
opcode -= 5
|
||||
break
|
||||
case 9: /* FillOrMix_1 */
|
||||
mask = 0x03
|
||||
opcode = 0x02
|
||||
fom_mask = 3
|
||||
break
|
||||
case 0x0a: /* FillOrMix_2 */
|
||||
mask = 0x05
|
||||
opcode = 0x02
|
||||
fom_mask = 5
|
||||
break
|
||||
}
|
||||
lastopcode = int8(opcode)
|
||||
mixmask = 0
|
||||
/* Output body */
|
||||
for count > 0 {
|
||||
if x >= width {
|
||||
if height <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
x = 0
|
||||
height--
|
||||
prevline = line
|
||||
line = height * width
|
||||
}
|
||||
switch opcode {
|
||||
case 0: /* Fill */
|
||||
if insertmix {
|
||||
if prevline == 0 {
|
||||
out[x+line] = mix
|
||||
} else {
|
||||
out[x+line] = out[prevline+x] ^ mix
|
||||
}
|
||||
insertmix = false
|
||||
count--
|
||||
x++
|
||||
}
|
||||
n := min(count, width-x)
|
||||
if prevline == 0 {
|
||||
clear(out[x+line : x+line+n])
|
||||
} else {
|
||||
copy(out[x+line:x+line+n], out[prevline+x:prevline+x+n])
|
||||
}
|
||||
count -= n
|
||||
x += n
|
||||
break
|
||||
case 1: /* Mix */
|
||||
n := min(count, width-x)
|
||||
if prevline == 0 {
|
||||
seg := out[x+line : x+line+n]
|
||||
for i := range seg {
|
||||
seg[i] = mix
|
||||
}
|
||||
} else {
|
||||
src := out[prevline+x : prevline+x+n]
|
||||
dst := out[x+line : x+line+n]
|
||||
for i := range dst {
|
||||
dst[i] = src[i] ^ mix
|
||||
}
|
||||
}
|
||||
count -= n
|
||||
x += n
|
||||
break
|
||||
case 2: /* Fill or Mix */
|
||||
if prevline == 0 {
|
||||
for count > 0 && x < width {
|
||||
mixmask <<= 1
|
||||
if mixmask == 0 {
|
||||
mask = fom_mask
|
||||
if fom_mask == 0 {
|
||||
mask = uint8(CVAL(&input))
|
||||
mixmask = 1
|
||||
}
|
||||
}
|
||||
if mask&mixmask != 0 {
|
||||
out[x+line] = mix
|
||||
} else {
|
||||
out[x+line] = 0
|
||||
}
|
||||
|
||||
count--
|
||||
x++
|
||||
}
|
||||
} else {
|
||||
for count > 0 && x < width {
|
||||
mixmask = mixmask << 1
|
||||
if mixmask == 0 {
|
||||
mask = fom_mask
|
||||
if fom_mask == 0 {
|
||||
mask = uint8(CVAL(&input))
|
||||
mixmask = 1
|
||||
}
|
||||
}
|
||||
if mask&mixmask != 0 {
|
||||
out[x+line] = out[prevline+x] ^ mix
|
||||
} else {
|
||||
out[x+line] = out[prevline+x]
|
||||
}
|
||||
|
||||
count--
|
||||
x++
|
||||
}
|
||||
}
|
||||
break
|
||||
case 3: /* Colour */
|
||||
n := min(count, width-x)
|
||||
seg := out[x+line : x+line+n]
|
||||
for i := range seg {
|
||||
seg[i] = colour2
|
||||
}
|
||||
count -= n
|
||||
x += n
|
||||
break
|
||||
case 4: /* Copy */
|
||||
for count > 0 && x < width {
|
||||
n := min(count, width-x)
|
||||
if len(input) < n {
|
||||
return false
|
||||
}
|
||||
copy(out[x+line:x+line+n], input[:n])
|
||||
input = input[n:]
|
||||
count -= n
|
||||
x += n
|
||||
}
|
||||
break
|
||||
case 8: /* Bicolour */
|
||||
for count > 0 && x < width {
|
||||
if bicolour {
|
||||
out[x+line] = colour2
|
||||
bicolour = false
|
||||
} else {
|
||||
out[x+line] = colour1
|
||||
bicolour = true
|
||||
count++
|
||||
}
|
||||
|
||||
count--
|
||||
x++
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
case 0xd: /* White */
|
||||
n := min(count, width-x)
|
||||
seg := out[x+line : x+line+n]
|
||||
for i := range seg {
|
||||
seg[i] = 0xff
|
||||
}
|
||||
count -= n
|
||||
x += n
|
||||
break
|
||||
case 0xe: /* Black */
|
||||
n := min(count, width-x)
|
||||
clear(out[x+line : x+line+n])
|
||||
count -= n
|
||||
x += n
|
||||
break
|
||||
default:
|
||||
fmt.Printf("bitmap opcode 0x%x\n", opcode)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// decompress2Pool reuses the intermediate []uint16 buffer across calls to
|
||||
// decompress2, avoiding a large per-frame allocation.
|
||||
var decompress2Pool sync.Pool
|
||||
|
||||
/* 2 byte bitmap decompress */
|
||||
@@ -0,0 +1,272 @@
|
||||
package rle
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func decompress2(output *[]uint8, width, height int, input []uint8, size int) bool {
|
||||
needed := width * height
|
||||
|
||||
var out []uint16
|
||||
if v := decompress2Pool.Get(); v != nil {
|
||||
out = v.([]uint16)
|
||||
if cap(out) < needed {
|
||||
out = make([]uint16, needed)
|
||||
} else {
|
||||
out = out[:needed]
|
||||
}
|
||||
} else {
|
||||
out = make([]uint16, needed)
|
||||
}
|
||||
defer func() { decompress2Pool.Put(out[:cap(out)]) }()
|
||||
|
||||
var (
|
||||
prevline, line, count int
|
||||
offset, code int
|
||||
x int = width
|
||||
opcode int
|
||||
lastopcode int = -1
|
||||
insertmix, bicolour, isfillormix bool
|
||||
mixmask, mask uint8
|
||||
colour1, colour2 uint16
|
||||
mix uint16 = 0xffff
|
||||
fom_mask uint8
|
||||
)
|
||||
|
||||
for len(input) != 0 {
|
||||
fom_mask = 0
|
||||
code = CVAL(&input)
|
||||
opcode = code >> 4
|
||||
/* Handle different opcode forms */
|
||||
switch opcode {
|
||||
case 0xc, 0xd, 0xe:
|
||||
opcode -= 6
|
||||
count = code & 0xf
|
||||
offset = 16
|
||||
break
|
||||
case 0xf:
|
||||
opcode = code & 0xf
|
||||
if opcode < 9 {
|
||||
count = CVAL(&input)
|
||||
count |= CVAL(&input) << 8
|
||||
} else {
|
||||
count = 1
|
||||
if opcode < 0xb {
|
||||
count = 8
|
||||
}
|
||||
}
|
||||
offset = 0
|
||||
break
|
||||
default:
|
||||
opcode >>= 1
|
||||
count = code & 0x1f
|
||||
offset = 32
|
||||
break
|
||||
}
|
||||
|
||||
/* Handle strange cases for counts */
|
||||
if offset != 0 {
|
||||
isfillormix = ((opcode == 2) || (opcode == 7))
|
||||
if count == 0 {
|
||||
if isfillormix {
|
||||
count = CVAL(&input) + 1
|
||||
} else {
|
||||
count = CVAL(&input) + offset
|
||||
}
|
||||
} else if isfillormix {
|
||||
count <<= 3
|
||||
}
|
||||
}
|
||||
/* Read preliminary data */
|
||||
switch opcode {
|
||||
case 0: /* Fill */
|
||||
if (lastopcode == opcode) && !((x == width) && (prevline == 0)) {
|
||||
insertmix = true
|
||||
}
|
||||
break
|
||||
case 8: /* Bicolour */
|
||||
CVAL2(&input, &colour1)
|
||||
CVAL2(&input, &colour2)
|
||||
break
|
||||
case 3: /* Colour */
|
||||
CVAL2(&input, &colour2)
|
||||
break
|
||||
case 6: /* SetMix/Mix */
|
||||
fallthrough
|
||||
case 7: /* SetMix/FillOrMix */
|
||||
CVAL2(&input, &mix)
|
||||
opcode -= 5
|
||||
break
|
||||
case 9: /* FillOrMix_1 */
|
||||
mask = 0x03
|
||||
opcode = 0x02
|
||||
fom_mask = 3
|
||||
break
|
||||
case 0x0a: /* FillOrMix_2 */
|
||||
mask = 0x05
|
||||
opcode = 0x02
|
||||
fom_mask = 5
|
||||
break
|
||||
}
|
||||
lastopcode = opcode
|
||||
mixmask = 0
|
||||
/* Output body */
|
||||
for count > 0 {
|
||||
if x >= width {
|
||||
if height <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
x = 0
|
||||
height--
|
||||
prevline = line
|
||||
line = height * width
|
||||
}
|
||||
switch opcode {
|
||||
case 0: /* Fill */
|
||||
if insertmix {
|
||||
if prevline == 0 {
|
||||
out[x+line] = mix
|
||||
} else {
|
||||
out[x+line] = out[prevline+x] ^ mix
|
||||
}
|
||||
insertmix = false
|
||||
count--
|
||||
x++
|
||||
}
|
||||
n := min(count, width-x)
|
||||
if prevline == 0 {
|
||||
clear(out[x+line : x+line+n])
|
||||
} else {
|
||||
copy(out[x+line:x+line+n], out[prevline+x:prevline+x+n])
|
||||
}
|
||||
count -= n
|
||||
x += n
|
||||
break
|
||||
case 1: /* Mix */
|
||||
n := min(count, width-x)
|
||||
if prevline == 0 {
|
||||
seg := out[x+line : x+line+n]
|
||||
for i := range seg {
|
||||
seg[i] = mix
|
||||
}
|
||||
} else {
|
||||
src := out[prevline+x : prevline+x+n]
|
||||
dst := out[x+line : x+line+n]
|
||||
for i := range dst {
|
||||
dst[i] = src[i] ^ mix
|
||||
}
|
||||
}
|
||||
count -= n
|
||||
x += n
|
||||
break
|
||||
case 2: /* Fill or Mix */
|
||||
if prevline == 0 {
|
||||
for count > 0 && x < width {
|
||||
mixmask <<= 1
|
||||
if mixmask == 0 {
|
||||
mask = fom_mask
|
||||
if fom_mask == 0 {
|
||||
mask = uint8(CVAL(&input))
|
||||
mixmask = 1
|
||||
}
|
||||
}
|
||||
if mask&mixmask != 0 {
|
||||
out[x+line] = mix
|
||||
} else {
|
||||
out[x+line] = 0
|
||||
}
|
||||
|
||||
count--
|
||||
x++
|
||||
}
|
||||
} else {
|
||||
for count > 0 && x < width {
|
||||
mixmask = mixmask << 1
|
||||
if mixmask == 0 {
|
||||
mask = fom_mask
|
||||
if fom_mask == 0 {
|
||||
mask = uint8(CVAL(&input))
|
||||
mixmask = 1
|
||||
}
|
||||
}
|
||||
if mask&mixmask != 0 {
|
||||
out[x+line] = out[prevline+x] ^ mix
|
||||
} else {
|
||||
out[x+line] = out[prevline+x]
|
||||
}
|
||||
|
||||
count--
|
||||
x++
|
||||
}
|
||||
}
|
||||
break
|
||||
case 3: /* Colour */
|
||||
n := min(count, width-x)
|
||||
seg := out[x+line : x+line+n]
|
||||
for i := range seg {
|
||||
seg[i] = colour2
|
||||
}
|
||||
count -= n
|
||||
x += n
|
||||
break
|
||||
case 4: /* Copy */
|
||||
for count > 0 && x < width {
|
||||
n := min(count, width-x)
|
||||
if len(input) < n*2 {
|
||||
return false
|
||||
}
|
||||
copy(out[x+line:x+line+n], unsafe.Slice((*uint16)(unsafe.Pointer(&input[0])), n))
|
||||
input = input[n*2:]
|
||||
count -= n
|
||||
x += n
|
||||
}
|
||||
|
||||
break
|
||||
case 8: /* Bicolour */
|
||||
for count > 0 && x < width {
|
||||
if bicolour {
|
||||
out[x+line] = colour2
|
||||
bicolour = false
|
||||
} else {
|
||||
out[x+line] = colour1
|
||||
bicolour = true
|
||||
count++
|
||||
}
|
||||
|
||||
count--
|
||||
x++
|
||||
}
|
||||
|
||||
break
|
||||
case 0xd: /* White */
|
||||
n2 := min(count, width-x)
|
||||
seg2 := out[x+line : x+line+n2]
|
||||
for i := range seg2 {
|
||||
seg2[i] = 0xffff
|
||||
}
|
||||
count -= n2
|
||||
x += n2
|
||||
break
|
||||
case 0xe: /* Black */
|
||||
n3 := min(count, width-x)
|
||||
clear(out[x+line : x+line+n3])
|
||||
count -= n3
|
||||
x += n3
|
||||
break
|
||||
default:
|
||||
fmt.Printf("bitmap opcode 0x%x\n", opcode)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
outBytes := *output
|
||||
for i, v := range out {
|
||||
outBytes[i*2] = byte(v >> 8)
|
||||
outBytes[i*2+1] = byte(v)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// /* 3 byte bitmap decompress */
|
||||
@@ -0,0 +1,430 @@
|
||||
package rle
|
||||
|
||||
import "fmt"
|
||||
|
||||
func decompress3(output *[]uint8, width, height int, input []uint8, size int) bool {
|
||||
var (
|
||||
prevline, line, count int
|
||||
opcode, offset, code int
|
||||
x int = width
|
||||
lastopcode int = -1
|
||||
insertmix, bicolour, isfillormix bool
|
||||
mixmask, mask uint8
|
||||
colour1 = [3]uint8{0, 0, 0}
|
||||
colour2 = [3]uint8{0, 0, 0}
|
||||
mix = [3]uint8{0xff, 0xff, 0xff}
|
||||
fom_mask uint8
|
||||
)
|
||||
out := *output
|
||||
for len(input) != 0 {
|
||||
fom_mask = 0
|
||||
code = CVAL(&input)
|
||||
opcode = code >> 4
|
||||
/* Handle different opcode forms */
|
||||
switch opcode {
|
||||
case 0xc, 0xd, 0xe:
|
||||
opcode -= 6
|
||||
count = code & 0xf
|
||||
offset = 16
|
||||
break
|
||||
case 0xf:
|
||||
opcode = code & 0xf
|
||||
if opcode < 9 {
|
||||
count = CVAL(&input)
|
||||
count |= CVAL(&input) << 8
|
||||
} else {
|
||||
count = 1
|
||||
if opcode < 0xb {
|
||||
count = 8
|
||||
}
|
||||
}
|
||||
offset = 0
|
||||
break
|
||||
default:
|
||||
opcode >>= 1
|
||||
count = code & 0x1f
|
||||
offset = 32
|
||||
break
|
||||
}
|
||||
|
||||
/* Handle strange cases for counts */
|
||||
if offset != 0 {
|
||||
isfillormix = ((opcode == 2) || (opcode == 7))
|
||||
if count == 0 {
|
||||
if isfillormix {
|
||||
count = CVAL(&input) + 1
|
||||
} else {
|
||||
count = CVAL(&input) + offset
|
||||
}
|
||||
} else if isfillormix {
|
||||
count <<= 3
|
||||
}
|
||||
}
|
||||
/* Read preliminary data */
|
||||
switch opcode {
|
||||
case 0: /* Fill */
|
||||
if (lastopcode == opcode) && !((x == width) && (prevline == 0)) {
|
||||
insertmix = true
|
||||
}
|
||||
break
|
||||
case 8: /* Bicolour */
|
||||
CVAL3(&input, &colour1)
|
||||
CVAL3(&input, &colour2)
|
||||
break
|
||||
case 3: /* Colour */
|
||||
CVAL3(&input, &colour2)
|
||||
break
|
||||
case 6: /* SetMix/Mix */
|
||||
fallthrough
|
||||
case 7: /* SetMix/FillOrMix */
|
||||
CVAL3(&input, &mix)
|
||||
opcode -= 5
|
||||
break
|
||||
case 9: /* FillOrMix_1 */
|
||||
mask = 0x03
|
||||
opcode = 0x02
|
||||
fom_mask = 3
|
||||
break
|
||||
case 0x0a: /* FillOrMix_2 */
|
||||
mask = 0x05
|
||||
opcode = 0x02
|
||||
fom_mask = 5
|
||||
break
|
||||
}
|
||||
|
||||
lastopcode = opcode
|
||||
mixmask = 0
|
||||
/* Output body */
|
||||
for count > 0 {
|
||||
if x >= width {
|
||||
if height <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
x = 0
|
||||
height--
|
||||
prevline = line
|
||||
line = height * width * 3
|
||||
}
|
||||
switch opcode {
|
||||
case 0: /* Fill */
|
||||
if insertmix {
|
||||
if prevline == 0 {
|
||||
out[3*x+line] = mix[0]
|
||||
out[3*x+line+1] = mix[1]
|
||||
out[3*x+line+2] = mix[2]
|
||||
} else {
|
||||
out[3*x+line] = out[prevline+3*x] ^ mix[0]
|
||||
out[3*x+line+1] = out[prevline+3*x+1] ^ mix[1]
|
||||
out[3*x+line+2] = out[prevline+3*x+2] ^ mix[2]
|
||||
}
|
||||
insertmix = false
|
||||
count--
|
||||
x++
|
||||
}
|
||||
n := min(count, width-x)
|
||||
if prevline == 0 {
|
||||
clear(out[3*x+line : 3*x+line+3*n])
|
||||
} else {
|
||||
dstBase := 3*x + line
|
||||
srcBase := prevline + 3*x
|
||||
copy(out[dstBase:dstBase+3*n], out[srcBase:srcBase+3*n])
|
||||
}
|
||||
count -= n
|
||||
x += n
|
||||
break
|
||||
case 1: /* Mix */
|
||||
n := min(count, width-x)
|
||||
dst1 := out[3*x+line : 3*x+line+3*n]
|
||||
if prevline == 0 {
|
||||
// Exponential-doubling copy: O(log n) memcpy calls
|
||||
dst1[0], dst1[1], dst1[2] = mix[0], mix[1], mix[2]
|
||||
for wrote := 3; wrote < len(dst1); {
|
||||
wrote += copy(dst1[wrote:], dst1[:wrote])
|
||||
}
|
||||
} else {
|
||||
src1 := out[prevline+3*x : prevline+3*x+3*n]
|
||||
for i := 0; i+2 < len(dst1); i += 3 {
|
||||
dst1[i] = src1[i] ^ mix[0]
|
||||
dst1[i+1] = src1[i+1] ^ mix[1]
|
||||
dst1[i+2] = src1[i+2] ^ mix[2]
|
||||
}
|
||||
}
|
||||
count -= n
|
||||
x += n
|
||||
break
|
||||
case 2: /* Fill or Mix */
|
||||
if prevline == 0 {
|
||||
base := 3*x + line
|
||||
for count > 0 && x < width {
|
||||
mixmask = mixmask << 1
|
||||
if mixmask == 0 {
|
||||
mask = fom_mask
|
||||
if fom_mask == 0 {
|
||||
mask = uint8(CVAL(&input))
|
||||
mixmask = 1
|
||||
}
|
||||
}
|
||||
if mask&mixmask != 0 {
|
||||
out[base] = mix[0]
|
||||
out[base+1] = mix[1]
|
||||
out[base+2] = mix[2]
|
||||
} else {
|
||||
out[base] = 0
|
||||
out[base+1] = 0
|
||||
out[base+2] = 0
|
||||
}
|
||||
base += 3
|
||||
count--
|
||||
x++
|
||||
}
|
||||
} else {
|
||||
base := 3*x + line
|
||||
prev := prevline + 3*x
|
||||
for count > 0 && x < width {
|
||||
mixmask = mixmask << 1
|
||||
if mixmask == 0 {
|
||||
mask = fom_mask
|
||||
if fom_mask == 0 {
|
||||
mask = uint8(CVAL(&input))
|
||||
mixmask = 1
|
||||
}
|
||||
}
|
||||
if mask&mixmask != 0 {
|
||||
out[base] = out[prev] ^ mix[0]
|
||||
out[base+1] = out[prev+1] ^ mix[1]
|
||||
out[base+2] = out[prev+2] ^ mix[2]
|
||||
} else {
|
||||
out[base] = out[prev]
|
||||
out[base+1] = out[prev+1]
|
||||
out[base+2] = out[prev+2]
|
||||
}
|
||||
base += 3
|
||||
prev += 3
|
||||
count--
|
||||
x++
|
||||
}
|
||||
}
|
||||
break
|
||||
case 3: /* Colour */
|
||||
n := min(count, width-x)
|
||||
seg3 := out[3*x+line : 3*x+line+3*n]
|
||||
seg3[0], seg3[1], seg3[2] = colour2[0], colour2[1], colour2[2]
|
||||
for wrote := 3; wrote < len(seg3); {
|
||||
wrote += copy(seg3[wrote:], seg3[:wrote])
|
||||
}
|
||||
count -= n
|
||||
x += n
|
||||
break
|
||||
case 4: /* Copy */
|
||||
for count > 0 && x < width {
|
||||
n := min(count, width-x)
|
||||
if len(input) < n*3 {
|
||||
return false
|
||||
}
|
||||
copy(out[3*x+line:3*x+line+n*3], input[:n*3])
|
||||
input = input[n*3:]
|
||||
count -= n
|
||||
x += n
|
||||
}
|
||||
break
|
||||
case 8: /* Bicolour */
|
||||
base8 := 3*x + line
|
||||
for count > 0 && x < width {
|
||||
if bicolour {
|
||||
out[base8] = colour2[0]
|
||||
out[base8+1] = colour2[1]
|
||||
out[base8+2] = colour2[2]
|
||||
bicolour = false
|
||||
} else {
|
||||
out[base8] = colour1[0]
|
||||
out[base8+1] = colour1[1]
|
||||
out[base8+2] = colour1[2]
|
||||
bicolour = true
|
||||
count++
|
||||
}
|
||||
base8 += 3
|
||||
count--
|
||||
x++
|
||||
}
|
||||
break
|
||||
case 0xd: /* White */
|
||||
n3 := min(count, width-x)
|
||||
seg3 := out[3*x+line : 3*x+line+3*n3]
|
||||
for i := range seg3 {
|
||||
seg3[i] = 0xff
|
||||
}
|
||||
count -= n3
|
||||
x += n3
|
||||
break
|
||||
case 0xe: /* Black */
|
||||
n2 := min(count, width-x)
|
||||
clear(out[3*x+line : 3*x+line+3*n2])
|
||||
count -= n2
|
||||
x += n2
|
||||
break
|
||||
default:
|
||||
fmt.Printf("bitmap opcode 0x%x\n", opcode)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/* decompress a colour plane */
|
||||
func processPlane(in *[]uint8, width, height int, output *[]uint8, j int) (int, bool) {
|
||||
var (
|
||||
indexw int
|
||||
indexh int
|
||||
code int
|
||||
collen int
|
||||
replen int
|
||||
color uint8
|
||||
x uint8
|
||||
revcode int
|
||||
lastline int
|
||||
thisline int
|
||||
)
|
||||
ln := len(*in)
|
||||
out := *output // hoist pointer dereference; writes to out[i] affect the underlying array
|
||||
|
||||
readByte := func() (uint8, bool) {
|
||||
if len(*in) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
v := (*in)[0]
|
||||
*in = (*in)[1:]
|
||||
return v, true
|
||||
}
|
||||
writePixel := func(pos int, value uint8) bool {
|
||||
if pos < 0 || pos >= len(out) {
|
||||
return false
|
||||
}
|
||||
out[pos] = value
|
||||
return true
|
||||
}
|
||||
|
||||
lastline = 0
|
||||
indexh = 0
|
||||
i := 0
|
||||
for indexh < height {
|
||||
thisline = j + (width * height * 4) - ((indexh + 1) * width * 4)
|
||||
color = 0
|
||||
indexw = 0
|
||||
i = thisline
|
||||
|
||||
if lastline == 0 {
|
||||
for indexw < width {
|
||||
b, ok := readByte()
|
||||
if !ok {
|
||||
return ln - len(*in), false
|
||||
}
|
||||
code = int(b)
|
||||
replen = int(code & 0xf)
|
||||
collen = int((code >> 4) & 0xf)
|
||||
revcode = (replen << 4) | collen
|
||||
if (revcode <= 47) && (revcode >= 16) {
|
||||
replen = revcode
|
||||
collen = 0
|
||||
}
|
||||
for collen > 0 {
|
||||
if indexw >= width {
|
||||
return ln - len(*in), false
|
||||
}
|
||||
v, ok := readByte()
|
||||
if !ok {
|
||||
return ln - len(*in), false
|
||||
}
|
||||
color = v
|
||||
if !writePixel(i, color) {
|
||||
return ln - len(*in), false
|
||||
}
|
||||
i += 4
|
||||
|
||||
indexw++
|
||||
collen--
|
||||
}
|
||||
for replen > 0 {
|
||||
if indexw >= width {
|
||||
break
|
||||
}
|
||||
if !writePixel(i, color) {
|
||||
return ln - len(*in), false
|
||||
}
|
||||
i += 4
|
||||
indexw++
|
||||
replen--
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// prevOffset is constant per row: indexw*4+lastline == i+(lastline-thisline)
|
||||
// because i == thisline+indexw*4. Pre-computing it eliminates a multiply
|
||||
// per pixel in both inner loops.
|
||||
prevOffset := lastline - thisline
|
||||
for indexw < width {
|
||||
b, ok := readByte()
|
||||
if !ok {
|
||||
return ln - len(*in), false
|
||||
}
|
||||
code = int(b)
|
||||
replen = int(code & 0xf)
|
||||
collen = int((code >> 4) & 0xf)
|
||||
revcode = (replen << 4) | collen
|
||||
if (revcode <= 47) && (revcode >= 16) {
|
||||
replen = revcode
|
||||
collen = 0
|
||||
}
|
||||
for collen > 0 {
|
||||
if indexw >= width {
|
||||
return ln - len(*in), false
|
||||
}
|
||||
v, ok := readByte()
|
||||
if !ok {
|
||||
return ln - len(*in), false
|
||||
}
|
||||
x = v
|
||||
if x&1 != 0 {
|
||||
x = x >> 1
|
||||
x = x + 1
|
||||
color = -x
|
||||
} else {
|
||||
x = x >> 1
|
||||
color = x
|
||||
}
|
||||
if i+prevOffset < 0 || i+prevOffset >= len(out) {
|
||||
return ln - len(*in), false
|
||||
}
|
||||
x = out[i+prevOffset] + color
|
||||
if !writePixel(i, x) {
|
||||
return ln - len(*in), false
|
||||
}
|
||||
i += 4
|
||||
indexw++
|
||||
collen--
|
||||
}
|
||||
for replen > 0 {
|
||||
if indexw >= width {
|
||||
break
|
||||
}
|
||||
if i+prevOffset < 0 || i+prevOffset >= len(out) {
|
||||
return ln - len(*in), false
|
||||
}
|
||||
x = out[i+prevOffset] + color
|
||||
if !writePixel(i, x) {
|
||||
return ln - len(*in), false
|
||||
}
|
||||
i += 4
|
||||
indexw++
|
||||
replen--
|
||||
}
|
||||
}
|
||||
}
|
||||
indexh++
|
||||
lastline = thisline
|
||||
}
|
||||
return ln - len(*in), true
|
||||
}
|
||||
|
||||
/* 4 byte bitmap decompress */
|
||||
@@ -0,0 +1,58 @@
|
||||
package rle
|
||||
|
||||
func decompress4(output *[]uint8, width, height int, input []uint8, size int) bool {
|
||||
var (
|
||||
code int
|
||||
onceBytes, total int
|
||||
)
|
||||
|
||||
code = CVAL(&input)
|
||||
rle := code&0x10 != 0
|
||||
noAlpha := code&0x20 != 0
|
||||
|
||||
if !rle {
|
||||
return false
|
||||
}
|
||||
|
||||
total = 1
|
||||
out := *output
|
||||
|
||||
if noAlpha {
|
||||
// No alpha plane in the stream; fill alpha channel with 0xFF.
|
||||
for i := 3; i < len(out); i += 4 {
|
||||
out[i] = 0xFF
|
||||
}
|
||||
} else {
|
||||
var ok bool
|
||||
onceBytes, ok = processPlane(&input, width, height, output, 3)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
total += onceBytes
|
||||
}
|
||||
|
||||
var ok bool
|
||||
onceBytes, ok = processPlane(&input, width, height, output, 2)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
total += onceBytes
|
||||
|
||||
onceBytes, ok = processPlane(&input, width, height, output, 1)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
total += onceBytes
|
||||
|
||||
onceBytes, ok = processPlane(&input, width, height, output, 0)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
total += onceBytes
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// DecompressInto decompresses bitmap data into dst, reusing dst if it has
|
||||
// sufficient capacity (size = width*height*bpp). If dst is nil or too small
|
||||
// a new slice is allocated. Returns the (re)used output slice.
|
||||
@@ -0,0 +1,30 @@
|
||||
package rle
|
||||
|
||||
import "fmt"
|
||||
|
||||
func DecompressInto(input []uint8, dst []uint8, width, height int, bpp int) []uint8 {
|
||||
size := width * height * bpp
|
||||
if cap(dst) >= size {
|
||||
dst = dst[:size]
|
||||
} else {
|
||||
dst = make([]uint8, size)
|
||||
}
|
||||
ok := true
|
||||
switch bpp {
|
||||
case 1:
|
||||
ok = decompress1(&dst, width, height, input, size)
|
||||
case 2:
|
||||
ok = decompress2(&dst, width, height, input, size)
|
||||
case 3:
|
||||
ok = decompress3(&dst, width, height, input, size)
|
||||
case 4:
|
||||
ok = decompress4(&dst, width, height, input, size)
|
||||
default:
|
||||
fmt.Printf("bpp %d\n", bpp)
|
||||
ok = false
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return dst
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package rle
|
||||
|
||||
import "unsafe"
|
||||
|
||||
func CVAL(p *[]uint8) int {
|
||||
a := int((*p)[0])
|
||||
*p = (*p)[1:]
|
||||
return a
|
||||
}
|
||||
|
||||
func CVAL2(p *[]uint8, v *uint16) {
|
||||
*v = *((*uint16)(unsafe.Pointer(&(*p)[0])))
|
||||
*p = (*p)[2:]
|
||||
}
|
||||
|
||||
func CVAL3(p *[]uint8, v *[3]uint8) {
|
||||
(*v)[0] = (*p)[0]
|
||||
(*v)[1] = (*p)[1]
|
||||
(*v)[2] = (*p)[2]
|
||||
*p = (*p)[3:]
|
||||
}
|
||||
|
||||
func REPEAT(f func(), count *int, x *int, width int) {
|
||||
for *count > 0 && *x < width {
|
||||
f()
|
||||
*count--
|
||||
*x++
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package rdp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ezops/pkg/rdp/credssp"
|
||||
"ezops/pkg/rdp/transport"
|
||||
)
|
||||
|
||||
// Session 保存已建立的 RDP 图形会话状态 // Logan.Li
|
||||
type Session struct {
|
||||
Conn io.ReadWriter
|
||||
UserID uint16
|
||||
SharedID uint32
|
||||
Width int
|
||||
Height int
|
||||
}
|
||||
|
||||
// OpenSession 完成 CredSSP + MCS/GCC 握手并进入可收帧状态 // Logan.Li
|
||||
func OpenSession(ctx context.Context, cfg Config) (*Session, error) {
|
||||
port := cfg.Port
|
||||
if port == 0 {
|
||||
port = 3389
|
||||
}
|
||||
if cfg.MCSMode == "" {
|
||||
cfg.MCSMode = strings.TrimSpace(os.Getenv("RDP_MCS_MODE"))
|
||||
}
|
||||
if cfg.MCSMode == "" {
|
||||
cfg.MCSMode = "grdp-modern"
|
||||
}
|
||||
address := fmt.Sprintf("%s:%d", cfg.Host, port)
|
||||
strategies := []credssp.Options{
|
||||
{Version: 6, PubKeyStyle: "binding"},
|
||||
{Version: 6, PubKeyStyle: "binding", UseCBT: true},
|
||||
{Version: 6, PubKeyStyle: "binding", ForceMIC: true},
|
||||
{Version: 2, PubKeyStyle: "raw"},
|
||||
}
|
||||
var lastErr error
|
||||
for _, opts := range strategies {
|
||||
sess, err := openSessionOnce(ctx, cfg, address, opts)
|
||||
if err == nil {
|
||||
return sess, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("RDP 会话建立失败")
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func openSessionOnce(ctx context.Context, cfg Config, address string, opts credssp.Options) (*Session, error) {
|
||||
tlsConn, _, err := dialCredSSPOnce(ctx, cfg, address, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = tlsConn.SetDeadline(time.Now().Add(45 * time.Second))
|
||||
if err := transport.WriteX224Data(tlsConn, buildMCSConnectInitial(cfg)); err != nil {
|
||||
_ = tlsConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
payload, err := transport.ReadX224Data(tlsConn)
|
||||
if err != nil {
|
||||
_ = tlsConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if _, err := parseMCSConnectResponse(payload); err != nil {
|
||||
_ = tlsConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
userID, err := attachMCSUser(tlsConn)
|
||||
if err != nil {
|
||||
_ = tlsConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
mcsInfo, _ := parseMCSConnectResponse(payload)
|
||||
joinIDs := append([]uint16{mcsGlobalChannelID, userID}, mcsInfo.ServerChannelIDs...)
|
||||
for _, channelID := range joinIDs {
|
||||
if _, err := joinMCSChannel(tlsConn, userID, channelID); err != nil {
|
||||
_ = tlsConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
_, demand, err := sendClientInfoAndReadDemand(tlsConn, cfg, userID)
|
||||
if err != nil {
|
||||
_ = tlsConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if _, err := sendConfirmActiveAndFinalize(tlsConn, cfg, userID, demand); err != nil {
|
||||
_ = tlsConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := requestDisplayUpdates(tlsConn, cfg, userID, demand.sharedID); err != nil {
|
||||
_ = tlsConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
width := cfg.Width
|
||||
if width <= 0 {
|
||||
width = 1280
|
||||
}
|
||||
height := cfg.Height
|
||||
if height <= 0 {
|
||||
height = 800
|
||||
}
|
||||
_ = tlsConn.SetDeadline(time.Time{})
|
||||
return &Session{
|
||||
Conn: tlsConn,
|
||||
UserID: userID,
|
||||
SharedID: demand.sharedID,
|
||||
Width: width,
|
||||
Height: height,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func requestDisplayUpdates(rw io.ReadWriter, cfg Config, userID uint16, sharedID uint32) error {
|
||||
if conn, ok := rw.(interface{ SetReadDeadline(time.Time) error }); ok {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
pkt, err := ReadWirePacket(rw)
|
||||
_ = conn.SetReadDeadline(time.Time{})
|
||||
if err == nil && !pkt.FastPath {
|
||||
_, _ = parseMCSDataIndication(pkt.Payload)
|
||||
}
|
||||
}
|
||||
body := buildSuppressOutputBody(cfg, true)
|
||||
pdu := buildDataPDU(userID, sharedID, pduType2SuppressOutput, body)
|
||||
if err := transport.WriteX224Data(rw, buildMCSSendDataRequest(userID, mcsGlobalChannelID, pdu)); err != nil {
|
||||
return fmt.Errorf("发送 SuppressOutput 失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildSuppressOutputBody(cfg Config, allow bool) []byte {
|
||||
width := cfg.Width
|
||||
if width <= 0 {
|
||||
width = 1280
|
||||
}
|
||||
height := cfg.Height
|
||||
if height <= 0 {
|
||||
height = 800
|
||||
}
|
||||
out := make([]byte, 0, 12)
|
||||
if allow {
|
||||
out = append(out, 1, 0, 0, 0)
|
||||
} else {
|
||||
out = append(out, 0, 0, 0, 0)
|
||||
}
|
||||
out = append(out, 0, 0, 0, 0, byte(width-1), byte((width-1)>>8), byte(height-1), byte((height-1)>>8))
|
||||
return out
|
||||
}
|
||||
|
||||
func buildRefreshRectBody(cfg Config) []byte {
|
||||
width := cfg.Width
|
||||
if width <= 0 {
|
||||
width = 1280
|
||||
}
|
||||
height := cfg.Height
|
||||
if height <= 0 {
|
||||
height = 800
|
||||
}
|
||||
out := make([]byte, 0, 10)
|
||||
out = append(out, 1, 0)
|
||||
out = append(out, 0, 0, 0, 0, byte(width-1), byte((width-1)>>8), byte(height-1), byte((height-1)>>8))
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package rdp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"ezops/pkg/rdp/display"
|
||||
"ezops/pkg/rdp/trace"
|
||||
"ezops/pkg/rdp/transport"
|
||||
)
|
||||
|
||||
// SessionEngine 是自研完整 RDP 图形会话引擎 // Logan.Li
|
||||
type SessionEngine struct{}
|
||||
|
||||
func (e *SessionEngine) Run(ctx context.Context, cfg Config, sink FrameSink, events <-chan ClientEvent) error {
|
||||
if cfg.Port == 0 {
|
||||
cfg.Port = 3389
|
||||
}
|
||||
trace.Logf("engine=rdp connect target=%s:%d size=%dx%d security=%s", cfg.Host, cfg.Port, cfg.Width, cfg.Height, cfg.Security)
|
||||
_ = sink.SendFrame(ServerFrame{
|
||||
Type: "status",
|
||||
Message: fmt.Sprintf("正在连接 RDP %s:%d", cfg.Host, cfg.Port),
|
||||
})
|
||||
|
||||
sess, err := OpenSession(ctx, cfg)
|
||||
if err != nil {
|
||||
trace.Logf("engine=rdp open failed target=%s:%d err=%v", cfg.Host, cfg.Port, err)
|
||||
_ = sink.SendFrame(ServerFrame{Type: "error", Message: "RDP 会话建立失败:" + err.Error()})
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if c, ok := sess.Conn.(io.Closer); ok {
|
||||
_ = c.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
_ = sink.SendFrame(ServerFrame{Type: "status", Message: "RDP 图形会话已建立,等待桌面画面..."})
|
||||
trace.Logf("engine=rdp open ok target=%s:%d", cfg.Host, cfg.Port)
|
||||
writeMu := &sync.Mutex{}
|
||||
input := NewInputSenderWithLock(sess.Conn, writeMu)
|
||||
receiver := NewFastPathReceiver()
|
||||
|
||||
readErr := make(chan error, 1)
|
||||
go func() {
|
||||
readErr <- e.readLoop(ctx, sess, receiver, sink, writeMu)
|
||||
}()
|
||||
go e.inputLoop(ctx, input, sink, events)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-readErr:
|
||||
if err != nil {
|
||||
_ = sink.SendFrame(ServerFrame{Type: "error", Message: err.Error()})
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *SessionEngine) inputLoop(ctx context.Context, input *InputSender, sink FrameSink, events <-chan ClientEvent) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case event, ok := <-events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if event.Type != "mousemove" && event.Type != "mouse_move" {
|
||||
trace.Logf("engine=rdp input type=%s x=%d y=%d button=%d code=%s down=%t", event.Type, event.X, event.Y, event.Button, event.Code, event.Down)
|
||||
}
|
||||
if err := e.handleEvent(input, sink, event); err != nil {
|
||||
_ = sink.SendFrame(ServerFrame{Type: "status", Message: "输入事件失败:" + err.Error()})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *SessionEngine) readLoop(ctx context.Context, sess *Session, receiver *FastPathReceiver, sink FrameSink, writeMu *sync.Mutex) error {
|
||||
framebuffer := display.NewFramebuffer(sess.Width, sess.Height)
|
||||
ticker := time.NewTicker(16 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
refreshRequested := false
|
||||
flushFrame := func() {
|
||||
frame, ok := framebuffer.FlushRGBA()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_ = sink.SendFrame(ServerFrame{
|
||||
Type: "bitmap",
|
||||
X: frame.X,
|
||||
Y: frame.Y,
|
||||
Width: frame.Width,
|
||||
Height: frame.Height,
|
||||
RGBA: frame.RGBA,
|
||||
})
|
||||
}
|
||||
sendBitmaps := func(rects []BitmapRect) {
|
||||
for _, rect := range rects {
|
||||
if rect.Width <= 0 || rect.Height <= 0 {
|
||||
continue
|
||||
}
|
||||
if !refreshRequested && isBlackFullScreen(rect, sess.Width, sess.Height) {
|
||||
refreshRequested = true
|
||||
go func() {
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
if err := e.requestRefresh(sess, writeMu); err != nil {
|
||||
trace.Logf("engine=rdp refresh request failed err=%v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
framebuffer.Draw(display.Bitmap{
|
||||
X: rect.DestLeft,
|
||||
Y: rect.DestTop,
|
||||
Width: rect.Width,
|
||||
Height: rect.Height,
|
||||
BitsPerPixel: rect.BitsPerPixel,
|
||||
Data: rect.Data,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type packetResult struct {
|
||||
packet WirePacket
|
||||
err error
|
||||
}
|
||||
packetCh := make(chan packetResult, 1)
|
||||
go func() {
|
||||
for {
|
||||
pkt, err := ReadWirePacket(sess.Conn)
|
||||
select {
|
||||
case packetCh <- packetResult{packet: pkt, err: err}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
flushFrame()
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
flushFrame()
|
||||
case got := <-packetCh:
|
||||
if got.err != nil {
|
||||
flushFrame()
|
||||
return got.err
|
||||
}
|
||||
var rects []BitmapRect
|
||||
var err error
|
||||
if got.packet.FastPath {
|
||||
rects, err = receiver.Handle(got.packet.SecFlag, got.packet.Payload)
|
||||
} else {
|
||||
rects, err = receiver.HandleSlowPath(got.packet.Payload)
|
||||
}
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := e.ackSurfaceFrames(sess, receiver.TakeFrameIDs(), writeMu); err != nil {
|
||||
trace.Logf("engine=rdp frame ack failed err=%v", err)
|
||||
}
|
||||
if len(rects) > 0 {
|
||||
trace.Logf("engine=rdp bitmap rects=%d fastpath=%t", len(rects), got.packet.FastPath)
|
||||
sendBitmaps(rects)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isBlackFullScreen(rect BitmapRect, width, height int) bool {
|
||||
if rect.DestLeft != 0 || rect.DestTop != 0 || rect.Width < width || rect.Height < height {
|
||||
return false
|
||||
}
|
||||
limit := len(rect.Data)
|
||||
if limit > 256*1024 {
|
||||
limit = 256 * 1024
|
||||
}
|
||||
for i := 0; i < limit; i++ {
|
||||
if rect.Data[i] > 4 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return limit > 0
|
||||
}
|
||||
|
||||
func (e *SessionEngine) requestRefresh(sess *Session, writeMu *sync.Mutex) error {
|
||||
body := buildRefreshRectBody(Config{Width: sess.Width, Height: sess.Height})
|
||||
pdu := buildDataPDU(sess.UserID, sess.SharedID, pduType2RefreshRect, body)
|
||||
trace.Logf("engine=rdp refresh rect %dx%d", sess.Width, sess.Height)
|
||||
writeMu.Lock()
|
||||
err := transport.WriteX224Data(sess.Conn, buildMCSSendDataRequest(sess.UserID, mcsGlobalChannelID, pdu))
|
||||
writeMu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *SessionEngine) ackSurfaceFrames(sess *Session, frameIDs []uint32, writeMu *sync.Mutex) error {
|
||||
for _, frameID := range frameIDs {
|
||||
body := []byte{byte(frameID), byte(frameID >> 8), byte(frameID >> 16), byte(frameID >> 24)}
|
||||
pdu := buildDataPDU(sess.UserID, sess.SharedID, pduType2FrameAcknowledge, body)
|
||||
trace.Logf("engine=rdp frame ack frameID=%d", frameID)
|
||||
writeMu.Lock()
|
||||
err := transport.WriteX224Data(sess.Conn, buildMCSSendDataRequest(sess.UserID, mcsGlobalChannelID, pdu))
|
||||
writeMu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *SessionEngine) handleEvent(input *InputSender, sink FrameSink, event ClientEvent) error {
|
||||
switch event.Type {
|
||||
case "mousemove", "mouse_move":
|
||||
return input.MouseMove(event.X, event.Y)
|
||||
case "mousedown", "mouse_down":
|
||||
return input.MouseButton(event.X, event.Y, event.Button, true)
|
||||
case "mouseup", "mouse_up":
|
||||
return input.MouseButton(event.X, event.Y, event.Button, false)
|
||||
case "wheel":
|
||||
return input.MouseWheel(event.X, event.Y, event.DeltaY)
|
||||
case "key":
|
||||
sc, ok := ScanCodeFromJS(event.Code)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return input.Key(sc, event.Down)
|
||||
case "clipboard":
|
||||
if event.Text != "" {
|
||||
return input.TypeText(event.Text)
|
||||
}
|
||||
return nil
|
||||
case "auto_exec":
|
||||
if event.Text == "" {
|
||||
return nil
|
||||
}
|
||||
_ = sink.SendFrame(ServerFrame{Type: "status", Message: "正在远端执行:" + event.Text})
|
||||
return input.AutoExec(event.Text)
|
||||
case "resize":
|
||||
_ = sink.SendFrame(ServerFrame{Type: "status", Message: "分辨率变更请求已记录,重连后生效"})
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ProbeSessionFrames 用于诊断:建立会话后读取若干秒位图帧数量 // Logan.Li
|
||||
func ProbeSessionFrames(ctx context.Context, cfg Config, seconds int) (int, error) {
|
||||
sess, err := OpenSession(ctx, cfg)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() {
|
||||
if c, ok := sess.Conn.(io.Closer); ok {
|
||||
_ = c.Close()
|
||||
}
|
||||
}()
|
||||
receiver := NewFastPathReceiver()
|
||||
deadline := time.Now().Add(time.Duration(seconds) * time.Second)
|
||||
count := 0
|
||||
for time.Now().Before(deadline) {
|
||||
if c, ok := sess.Conn.(interface{ SetReadDeadline(time.Time) error }); ok {
|
||||
_ = c.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
||||
}
|
||||
pkt, err := ReadWirePacket(sess.Conn)
|
||||
if err != nil {
|
||||
if time.Now().Before(deadline) {
|
||||
continue
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
var rects []BitmapRect
|
||||
if pkt.FastPath {
|
||||
rects, err = receiver.Handle(pkt.SecFlag, pkt.Payload)
|
||||
} else {
|
||||
rects, err = receiver.HandleSlowPath(pkt.Payload)
|
||||
}
|
||||
if err == nil {
|
||||
count += len(rects)
|
||||
}
|
||||
}
|
||||
if c, ok := sess.Conn.(interface{ SetReadDeadline(time.Time) error }); ok {
|
||||
_ = c.SetReadDeadline(time.Time{})
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package trace
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Enabled 判断是否开启 RDP 协议级调试日志,默认关闭避免高频帧刷屏 // Logan.Li
|
||||
func Enabled() bool {
|
||||
v := strings.ToLower(strings.TrimSpace(os.Getenv("RDP_DEBUG")))
|
||||
return v == "1" || v == "true" || v == "yes" || v == "debug"
|
||||
}
|
||||
|
||||
// Logf 输出 RDP 调试日志,统一前缀便于 docker logs 过滤 // Logan.Li
|
||||
func Logf(format string, args ...interface{}) {
|
||||
if !Enabled() {
|
||||
return
|
||||
}
|
||||
log.Printf("[RDP:TRACE] "+format, args...)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UpgradeTLS 在 X.224 协商后升级为 TLS,RDP 自签证书由堡垒机侧 TOFU 处理 // Logan.Li
|
||||
func UpgradeTLS(conn net.Conn, serverName string, timeout time.Duration) (*tls.Conn, error) {
|
||||
_ = conn.SetDeadline(time.Now().Add(timeout))
|
||||
tlsConn := tls.Client(conn, &tls.Config{
|
||||
ServerName: serverName,
|
||||
InsecureSkipVerify: true, // RDP 常见自签证书,后续可落库校验指纹 // Logan.Li
|
||||
MinVersion: tls.VersionTLS10,
|
||||
MaxVersion: tls.VersionTLS12,
|
||||
})
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = tlsConn.SetDeadline(time.Time{})
|
||||
return tlsConn, nil
|
||||
}
|
||||
|
||||
// TLSCipherSuiteName 返回 TLS cipher suite 名称,旧版本未命名时回退十六进制 // Logan.Li
|
||||
func TLSCipherSuiteName(id uint16) string {
|
||||
for _, suite := range tls.CipherSuites() {
|
||||
if suite.ID == id {
|
||||
return suite.Name
|
||||
}
|
||||
}
|
||||
for _, suite := range tls.InsecureCipherSuites() {
|
||||
if suite.ID == id {
|
||||
return suite.Name
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("0x%04x", id)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
const (
|
||||
tpktVersion = 3
|
||||
tpktHeaderSize = 4
|
||||
maxTPKTSize = 64 * 1024
|
||||
)
|
||||
|
||||
// WriteTPKT 写入 TPKT 包头与负载,RDP 初始握手基于 TPKT 承载 // Logan.Li
|
||||
func WriteTPKT(w io.Writer, payload []byte) error {
|
||||
total := len(payload) + tpktHeaderSize
|
||||
if total > maxTPKTSize {
|
||||
return fmt.Errorf("TPKT payload too large: %d", len(payload))
|
||||
}
|
||||
header := []byte{tpktVersion, 0, 0, 0}
|
||||
binary.BigEndian.PutUint16(header[2:4], uint16(total))
|
||||
if _, err := w.Write(header); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := w.Write(payload)
|
||||
return err
|
||||
}
|
||||
|
||||
// ReadTPKT 读取一个完整 TPKT 包并返回负载 // Logan.Li
|
||||
func ReadTPKT(r io.Reader) ([]byte, error) {
|
||||
header := make([]byte, tpktHeaderSize)
|
||||
if _, err := io.ReadFull(r, header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if header[0] != tpktVersion {
|
||||
return nil, fmt.Errorf("invalid TPKT version: %d", header[0])
|
||||
}
|
||||
total := int(binary.BigEndian.Uint16(header[2:4]))
|
||||
if total < tpktHeaderSize || total > maxTPKTSize {
|
||||
return nil, fmt.Errorf("invalid TPKT length: %d", total)
|
||||
}
|
||||
payload := make([]byte, total-tpktHeaderSize)
|
||||
if _, err := io.ReadFull(r, payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// WriteRaw 直接写入已封装的 TPKT/FastPath 帧,供会话层发送输入事件 // Logan.Li
|
||||
func WriteRaw(w io.Writer, frame []byte) error {
|
||||
_, err := w.Write(frame)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
const (
|
||||
x224TypeConnectionRequest = 0xE0
|
||||
x224TypeConnectionConfirm = 0xD0
|
||||
x224TypeData = 0xF0
|
||||
|
||||
negTypeRequest = 0x01
|
||||
negTypeResponse = 0x02
|
||||
negTypeFailure = 0x03
|
||||
|
||||
protocolSSL = 0x00000001
|
||||
protocolCredSSP = 0x00000002
|
||||
)
|
||||
|
||||
const (
|
||||
ProtocolStandard uint32 = 0
|
||||
ProtocolTLS uint32 = protocolSSL
|
||||
ProtocolCredSSP uint32 = protocolCredSSP
|
||||
)
|
||||
|
||||
// NegotiationResult 是 X.224 阶段的 RDP 安全协议协商结果 // Logan.Li
|
||||
type NegotiationResult struct {
|
||||
SelectedProtocol uint32
|
||||
FailureCode uint32
|
||||
}
|
||||
|
||||
func (r NegotiationResult) ProtocolName() string {
|
||||
switch r.SelectedProtocol {
|
||||
case ProtocolCredSSP:
|
||||
return "CredSSP/NLA"
|
||||
case ProtocolTLS:
|
||||
return "TLS"
|
||||
case ProtocolStandard:
|
||||
return "Standard RDP"
|
||||
default:
|
||||
return fmt.Sprintf("unknown(0x%x)", r.SelectedProtocol)
|
||||
}
|
||||
}
|
||||
|
||||
// BuildConnectionRequest 构造 X.224 Connection Request,并请求 NLA/TLS 协商 // Logan.Li
|
||||
func BuildConnectionRequest() []byte {
|
||||
return BuildConnectionRequestWithCookie("aiops")
|
||||
}
|
||||
|
||||
// BuildConnectionRequestWithCookie 按 MS-RDPBCGR 2.2.1.1.1 构造包含 mstshash 的请求 // Logan.Li
|
||||
func BuildConnectionRequestWithCookie(username string) []byte {
|
||||
if username == "" {
|
||||
username = "aiops"
|
||||
}
|
||||
if len(username) > 9 {
|
||||
username = username[:9]
|
||||
}
|
||||
cookie := []byte("Cookie: mstshash=" + username + "\r\n")
|
||||
neg := make([]byte, 8)
|
||||
neg[0] = negTypeRequest
|
||||
neg[1] = 0
|
||||
binary.LittleEndian.PutUint16(neg[2:4], uint16(len(neg)))
|
||||
binary.LittleEndian.PutUint32(neg[4:8], protocolSSL|protocolCredSSP)
|
||||
|
||||
length := 7 + len(cookie) + len(neg)
|
||||
pdu := make([]byte, 0, length)
|
||||
pdu = append(pdu, byte(length-1))
|
||||
pdu = append(pdu, x224TypeConnectionRequest)
|
||||
pdu = append(pdu, 0x00, 0x00) // dst-ref // Logan.Li
|
||||
pdu = append(pdu, 0x00, 0x00) // src-ref // Logan.Li
|
||||
pdu = append(pdu, 0x00) // class/options // Logan.Li
|
||||
pdu = append(pdu, cookie...)
|
||||
pdu = append(pdu, neg...)
|
||||
return pdu
|
||||
}
|
||||
|
||||
// ParseConnectionConfirm 解析 X.224 Connection Confirm 与 RDP Negotiation Response // Logan.Li
|
||||
func ParseConnectionConfirm(payload []byte) (NegotiationResult, error) {
|
||||
if len(payload) < 7 {
|
||||
return NegotiationResult{}, fmt.Errorf("short X.224 confirm: %d", len(payload))
|
||||
}
|
||||
if payload[1] != x224TypeConnectionConfirm {
|
||||
return NegotiationResult{}, fmt.Errorf("unexpected X.224 pdu type: 0x%x", payload[1])
|
||||
}
|
||||
for i := 7; i+8 <= len(payload); i++ {
|
||||
switch payload[i] {
|
||||
case negTypeResponse:
|
||||
return NegotiationResult{SelectedProtocol: binary.LittleEndian.Uint32(payload[i+4 : i+8])}, nil
|
||||
case negTypeFailure:
|
||||
return NegotiationResult{FailureCode: binary.LittleEndian.Uint32(payload[i+4 : i+8])}, fmt.Errorf("RDP negotiation failure: 0x%x", binary.LittleEndian.Uint32(payload[i+4:i+8]))
|
||||
}
|
||||
}
|
||||
return NegotiationResult{SelectedProtocol: 0}, nil
|
||||
}
|
||||
|
||||
// WriteX224Data 写入 X.224 Data TPDU,NLA 后的 MCS/GCC 数据仍经由该层承载 // Logan.Li
|
||||
func WriteX224Data(w io.Writer, payload []byte) error {
|
||||
data := make([]byte, 0, len(payload)+3)
|
||||
data = append(data, 0x02, x224TypeData, 0x80)
|
||||
data = append(data, payload...)
|
||||
return WriteTPKT(w, data)
|
||||
}
|
||||
|
||||
// ReadX224Data 读取并剥离 X.224 Data TPDU 头,返回上层 MCS 负载 // Logan.Li
|
||||
func ReadX224Data(r io.Reader) ([]byte, error) {
|
||||
payload, err := ReadTPKT(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(payload) < 3 {
|
||||
return nil, fmt.Errorf("X.224 data too short: %d", len(payload))
|
||||
}
|
||||
if payload[1] != x224TypeData {
|
||||
return nil, fmt.Errorf("unexpected X.224 type: 0x%x", payload[1])
|
||||
}
|
||||
return payload[3:], nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package rdpfilestage
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Item 暂存文件条目 // Logan.Li
|
||||
type Item struct {
|
||||
Name string
|
||||
Data []byte
|
||||
Expire time.Time
|
||||
}
|
||||
|
||||
var store sync.Map
|
||||
|
||||
// Put 暂存文件并返回一次性 token(默认 30 分钟有效)// Logan.Li
|
||||
func Put(name string, data []byte, ttl time.Duration) string {
|
||||
if ttl <= 0 {
|
||||
ttl = 30 * time.Minute
|
||||
}
|
||||
token := uuid.NewString()
|
||||
store.Store(token, Item{Name: name, Data: data, Expire: time.Now().Add(ttl)})
|
||||
return token
|
||||
}
|
||||
|
||||
// Get 读取暂存文件 // Logan.Li
|
||||
func Get(token string) (Item, bool) {
|
||||
raw, ok := store.Load(token)
|
||||
if !ok {
|
||||
return Item{}, false
|
||||
}
|
||||
item := raw.(Item)
|
||||
if time.Now().After(item.Expire) {
|
||||
store.Delete(token)
|
||||
return Item{}, false
|
||||
}
|
||||
return item, true
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"ezops/model"
|
||||
"ezops/pkg/auditcleaner"
|
||||
"ezops/pkg/billingsync"
|
||||
"ezops/pkg/database"
|
||||
snmppkg "ezops/pkg/snmp"
|
||||
|
||||
@@ -66,6 +67,11 @@ var (
|
||||
Description: "清理 7 天前的 SNMP 采集指标历史,防止表膨胀",
|
||||
Schedule: "每 24 小时", Category: "cleanup", Runnable: true, Enabled: true,
|
||||
},
|
||||
{
|
||||
Code: "cloud_billing_sync", Name: "云账单同步",
|
||||
Description: "从云账号 AK 拉取昨日账单(按产品+地域),写入云账单管理;当前支持阿里云",
|
||||
Schedule: "每天 10:30", Category: "finops", Runnable: true, Enabled: true,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -195,6 +201,14 @@ func RunManual(gdb *gorm.DB, driver, code string) (Item, error) {
|
||||
r := gdb.Exec(sql)
|
||||
err = r.Error
|
||||
msg = fmt.Sprintf("清理 %d 条 SNMP 指标(保留 %d 天)", r.RowsAffected, snmppkg.MetricsRetentionDays)
|
||||
case "cloud_billing_sync":
|
||||
result := billingsync.SyncDate(gdb, billingsync.Yesterday(), nil)
|
||||
msg = billingsync.SummaryMessage(result)
|
||||
if result.Accounts == 0 {
|
||||
err = fmt.Errorf("没有可同步的活跃云账号")
|
||||
} else if result.Created == 0 && result.Updated == 0 && len(result.Errors) > 0 {
|
||||
err = fmt.Errorf("%s", result.Errors[0])
|
||||
}
|
||||
default:
|
||||
return Item{}, fmt.Errorf("任务 %s 未实现手动执行", code)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package winsmb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hirochachacha/go-smb2"
|
||||
)
|
||||
|
||||
// 虚拟 Download 盘在 Windows 上的相对路径(挂载 C$ 共享)// Logan.Li
|
||||
const (
|
||||
AdminShare = "C$"
|
||||
DownloadRelDir = `Users\Public\AIOpsShare\Download`
|
||||
DownloadFullPath = `C:\Users\Public\AIOpsShare\Download`
|
||||
)
|
||||
|
||||
// Auth SMB 管理员凭据 // Logan.Li
|
||||
type Auth struct {
|
||||
Host string
|
||||
Domain string
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
// FileEntry 目录项 // Logan.Li
|
||||
type FileEntry struct {
|
||||
Name string
|
||||
Size int64
|
||||
IsDir bool
|
||||
ModTime time.Time
|
||||
}
|
||||
|
||||
func sanitizeFileName(name string) string {
|
||||
base := path.Base(strings.ReplaceAll(name, `\`, "/"))
|
||||
base = strings.TrimSpace(base)
|
||||
if base == "" || base == "." || base == ".." {
|
||||
return "upload.bin"
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func (a Auth) dial() (*smb2.Share, func(), error) {
|
||||
if strings.TrimSpace(a.Host) == "" {
|
||||
return nil, nil, fmt.Errorf("SMB 主机为空")
|
||||
}
|
||||
d := &smb2.Dialer{
|
||||
Initiator: &smb2.NTLMInitiator{
|
||||
User: a.Username,
|
||||
Password: a.Password,
|
||||
Domain: a.Domain,
|
||||
},
|
||||
}
|
||||
tcpConn, err := net.DialTimeout("tcp", net.JoinHostPort(a.Host, "445"), 15*time.Second)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("SMB 连接失败(请确认 445 端口可达且账号有管理员权限): %w", err)
|
||||
}
|
||||
session, err := d.DialContext(context.Background(), tcpConn)
|
||||
if err != nil {
|
||||
tcpConn.Close()
|
||||
return nil, nil, fmt.Errorf("SMB 认证失败: %w", err)
|
||||
}
|
||||
share, err := session.Mount(AdminShare)
|
||||
if err != nil {
|
||||
_ = session.Logoff()
|
||||
return nil, nil, fmt.Errorf("挂载 %s 失败: %w", AdminShare, err)
|
||||
}
|
||||
cleanup := func() {
|
||||
_ = share.Umount()
|
||||
_ = session.Logoff()
|
||||
}
|
||||
return share, cleanup, nil
|
||||
}
|
||||
|
||||
// EnsureDownloadDir 创建虚拟 Download 目录 // Logan.Li
|
||||
func EnsureDownloadDir(auth Auth) error {
|
||||
share, closeFn, err := auth.dial()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeFn()
|
||||
cur := ""
|
||||
for _, part := range strings.Split(DownloadRelDir, `\`) {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if cur == "" {
|
||||
cur = part
|
||||
} else {
|
||||
cur = cur + `\` + part
|
||||
}
|
||||
_ = share.Mkdir(cur, 0755)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func remoteFilePath(fileName string) string {
|
||||
return DownloadRelDir + `\` + sanitizeFileName(fileName)
|
||||
}
|
||||
|
||||
// Upload 上传文件到虚拟 Download 盘 // Logan.Li
|
||||
func Upload(auth Auth, fileName string, data []byte) error {
|
||||
if err := EnsureDownloadDir(auth); err != nil {
|
||||
return err
|
||||
}
|
||||
share, closeFn, err := auth.dial()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeFn()
|
||||
target := remoteFilePath(fileName)
|
||||
f, err := share.Create(target)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建远端文件失败: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := f.Write(data); err != nil {
|
||||
return fmt.Errorf("写入远端文件失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Download 从虚拟 Download 盘读取文件 // Logan.Li
|
||||
func Download(auth Auth, fileName string) ([]byte, error) {
|
||||
share, closeFn, err := auth.dial()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeFn()
|
||||
target := remoteFilePath(fileName)
|
||||
f, err := share.Open(target)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("文件不存在: %s", sanitizeFileName(fileName))
|
||||
}
|
||||
return nil, fmt.Errorf("打开远端文件失败: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
return io.ReadAll(f)
|
||||
}
|
||||
|
||||
// ListDownload 列出虚拟 Download 盘文件 // Logan.Li
|
||||
func ListDownload(auth Auth) ([]FileEntry, error) {
|
||||
if err := EnsureDownloadDir(auth); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
share, closeFn, err := auth.dial()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeFn()
|
||||
entries, err := share.ReadDir(DownloadRelDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取目录失败: %w", err)
|
||||
}
|
||||
out := make([]FileEntry, 0, len(entries))
|
||||
for _, ent := range entries {
|
||||
out = append(out, FileEntry{
|
||||
Name: ent.Name(),
|
||||
Size: ent.Size(),
|
||||
IsDir: ent.IsDir(),
|
||||
ModTime: ent.ModTime(),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -23,12 +23,13 @@ import (
|
||||
|
||||
// Connection types — identifies which feature a WebSocket serves.
|
||||
const (
|
||||
TypeTerminal = "terminal" // WebShell SSH terminal
|
||||
TypeKubectl = "kubectl" // kubectl command terminal
|
||||
TypePodExec = "pod_exec" // K8s pod exec terminal
|
||||
TypeAudit = "audit" // Audit real-time stream (live monitor)
|
||||
TypeMetrics = "metrics" // Dashboard metrics push
|
||||
TypePipeline = "pipeline" // Pipeline run log stream
|
||||
TypeTerminal = "terminal" // WebShell SSH terminal
|
||||
TypeRDP = "rdp" // RDP 图形会话 // Logan.Li
|
||||
TypeKubectl = "kubectl" // kubectl command terminal
|
||||
TypePodExec = "pod_exec" // K8s pod exec terminal
|
||||
TypeAudit = "audit" // Audit real-time stream (live monitor)
|
||||
TypeMetrics = "metrics" // Dashboard metrics push
|
||||
TypePipeline = "pipeline" // Pipeline run log stream
|
||||
)
|
||||
|
||||
// Config tunes the manager behavior.
|
||||
@@ -88,8 +89,8 @@ type WSManager struct {
|
||||
conns sync.Map // id → *ConnInfo
|
||||
next uint64 // atomic counter for connection IDs
|
||||
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
// New creates a WSManager with the given config.
|
||||
|
||||
Reference in new issue
Block a user