package handler import ( "context" "encoding/base64" "encoding/json" "errors" "fmt" "net" "net/http" "net/url" "os" "strings" "time" "cyberstrike-ai/internal/config" openaiClient "cyberstrike-ai/internal/openai" "github.com/gin-gonic/gin" "go.uber.org/zap" ) type FofaHandler struct { cfg *config.Config logger *zap.Logger client *http.Client openAIClient *openaiClient.Client } func NewFofaHandler(cfg *config.Config, logger *zap.Logger) *FofaHandler { // LLM 请求通常比 FOFA 查询更慢一点,单独给一个更宽松的超时。 llmHTTPClient := &http.Client{Timeout: 2 * time.Minute} var llmCfg *config.OpenAIConfig if cfg != nil { llmCfg = &cfg.OpenAI } return &FofaHandler{ cfg: cfg, logger: logger, client: &http.Client{Timeout: 60 * time.Second}, openAIClient: openaiClient.NewClient(llmCfg, llmHTTPClient, logger), } } type fofaSearchRequest struct { Provider string `json:"provider,omitempty"` Query string `json:"query" binding:"required"` Size int `json:"size,omitempty"` Page int `json:"page,omitempty"` Fields string `json:"fields,omitempty"` Full bool `json:"full,omitempty"` } type fofaParseRequest struct { Provider string `json:"provider,omitempty"` Text string `json:"text" binding:"required"` } type fofaParseResponse struct { Query string `json:"query"` Explanation string `json:"explanation,omitempty"` Warnings []string `json:"warnings,omitempty"` } type fofaAPIResponse struct { Error bool `json:"error"` ErrMsg string `json:"errmsg"` Size int `json:"size"` Page int `json:"page"` Total int `json:"total"` Mode string `json:"mode"` Query string `json:"query"` Results [][]interface{} `json:"results"` } type fofaSearchResponse struct { Provider string `json:"provider,omitempty"` Query string `json:"query"` Size int `json:"size"` Page int `json:"page"` Total int `json:"total"` Fields []string `json:"fields"` ResultsCount int `json:"results_count"` ExpectedCount int `json:"expected_count,omitempty"` Shortfall int `json:"shortfall,omitempty"` Warning string `json:"warning,omitempty"` Results []map[string]interface{} `json:"results"` } func normalizeSpaceSearchProvider(provider string) string { switch strings.ToLower(strings.TrimSpace(provider)) { case "", "fofa": return "fofa" case "zoomeye", "zoom-eye": return "zoomeye" case "quake": return "quake" case "shodan": return "shodan" default: return "" } } func providerDisplayName(provider string) string { switch normalizeSpaceSearchProvider(provider) { case "zoomeye": return "ZoomEye" case "quake": return "Quake" case "shodan": return "Shodan" default: return "FOFA" } } func defaultFieldsForProvider(provider string) string { switch normalizeSpaceSearchProvider(provider) { case "zoomeye": return "ip,port,domain,hostname,title,service,app,country,city" case "quake": return "ip,port,domain,service.name,service.http.title,location.country_cn,location.province_cn,location.city_cn" case "shodan": return "ip_str,port,hostnames,domains,org,isp,location.country_name,location.city,product,transport" default: return "host,ip,port,domain,title,protocol,country,province,city,server" } } func (h *FofaHandler) resolveAPIKey(provider string) string { // 优先环境变量(便于容器部署),其次配置文件。 provider = normalizeSpaceSearchProvider(provider) envKey := map[string]string{ "fofa": "FOFA_API_KEY", "zoomeye": "ZOOMEYE_API_KEY", "quake": "QUAKE_API_KEY", "shodan": "SHODAN_API_KEY", }[provider] if apiKey := strings.TrimSpace(os.Getenv(envKey)); apiKey != "" { return apiKey } if h.cfg != nil { switch provider { case "zoomeye": return strings.TrimSpace(h.cfg.ZoomEye.APIKey) case "quake": return strings.TrimSpace(h.cfg.Quake.APIKey) case "shodan": return strings.TrimSpace(h.cfg.Shodan.APIKey) default: return strings.TrimSpace(h.cfg.FOFA.APIKey) } } return "" } func (h *FofaHandler) resolveBaseURL(provider string) string { if h.cfg != nil { switch normalizeSpaceSearchProvider(provider) { case "zoomeye": if v := strings.TrimSpace(h.cfg.ZoomEye.BaseURL); v != "" { return v } case "quake": if v := strings.TrimSpace(h.cfg.Quake.BaseURL); v != "" { return v } case "shodan": if v := strings.TrimSpace(h.cfg.Shodan.BaseURL); v != "" { return v } default: if v := strings.TrimSpace(h.cfg.FOFA.BaseURL); v != "" { return v } } } switch normalizeSpaceSearchProvider(provider) { case "zoomeye": return "https://api.zoomeye.org/v2/search" case "quake": return "https://quake.360.cn/api/v3/search/quake_service" case "shodan": return "https://api.shodan.io" default: return "https://fofa.info/api/v1/search/all" } } // ParseNaturalLanguage 将自然语言解析为 FOFA 查询语法(仅生成,不执行查询) func (h *FofaHandler) ParseNaturalLanguage(c *gin.Context) { var req fofaParseRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()}) return } req.Text = strings.TrimSpace(req.Text) provider := normalizeSpaceSearchProvider(req.Provider) if provider == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "provider 不支持,可选:fofa、zoomeye、quake、shodan"}) return } if req.Text == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "text 不能为空"}) return } if h.cfg == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "系统配置未初始化"}) return } if strings.TrimSpace(h.cfg.OpenAI.APIKey) == "" || strings.TrimSpace(h.cfg.OpenAI.Model) == "" { c.JSON(http.StatusBadRequest, gin.H{ "error": "未配置 AI 模型:请在系统设置中填写 openai.api_key 与 openai.model(支持 OpenAI 兼容 API,如 DeepSeek)", "need": []string{"openai.api_key", "openai.model"}, }) return } if h.openAIClient == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "AI 客户端未初始化"}) return } engineName := providerDisplayName(provider) syntaxNotes := map[string]string{ "fofa": ` FOFA 官方查询语法参考: - 基本格式:field="value",字符串值使用英文双引号;多个条件用 &&(与)、||(或)、!(非)连接。 - 组合优先级:复杂表达式必须使用 () 明确优先级,例如:(app="Apache" || app="nginx") && country="CN"。 - 常用字段:app、title、body、header、host、domain、ip、port、protocol、country、province、city、server、icp、cert、icon_hash、fid。 - 字段示例: - app="Apache" - title="后台管理" - body="Powered by" - header="JSESSIONID" - domain="example.com" - host="https://example.com" - ip="1.1.1.1" - port="443" - country="CN" - city="Hangzhou" - cert="example.com" - icon_hash="-247388890" - 组合示例: - app="Apache" && country="CN" - title="login" || title="登录" - (app="Apache" || app="nginx") && port="443" - domain="example.com" && !title="404" - cert="example.com" && port="443" - header="JSESSIONID" && country="CN" - 生成注意: - 用户说“排除/不要/非”时优先使用 !field="value"。 - 用户说“标题包含/页面标题”映射为 title;说“正文包含/页面包含”映射为 body;说“响应头/cookie/header”映射为 header。 - 端口在 FOFA 中通常写成 port="443"。 `, "zoomeye": ` ZoomEye 查询语法参考: - 基本格式:field="value" 或 field=value;字符串/短语建议使用英文双引号。 - 逻辑连接:可使用 && / || / !,也可使用 AND / OR / NOT;复杂表达式使用 () 明确优先级。 - 常用字段:app、service、title、domain、hostname、ip、port、country、city、org、isp、asn、cidr、ssl、ssl.cert.fingerprint、iconhash。 - 字段示例: - app="Apache" - service="ssh" - title="登录" - domain="example.com" - hostname="example.com" - ip="1.1.1.1" - cidr="1.1.1.0/24" - port=443 - country="CN" - city="Beijing" - org="Tencent" - ssl="example.com" - ssl.cert.fingerprint="F3C98F223D82CC41CF83D94671CCC6C69873FABF" - iconhash="-247388890" - 组合示例: - app="nginx" && country="CN" - service="http" && (title="login" || title="登录") - domain="example.com" && !app="cloudflare" - port=443 && country="US" - app="Elasticsearch" && port=9200 - 生成注意: - 用户说“服务/协议是 SSH、HTTP、RDP”优先映射为 service。 - 用户说“站点/网站标题”映射为 title;说“域名/主域”优先映射为 domain 或 hostname。 - 端口可以不加引号,例如 port=443;如果用户原文已给出冒号风格表达式且接近 ZoomEye 语法,可原样保留。 `, "quake": ` Quake 查询语法参考: - 基本格式:field:"value" 或 field:value;字符串/中文/短语使用英文双引号。 - 逻辑连接:使用 AND、OR、NOT;复杂表达式必须使用 () 明确优先级。 - 常用字段:domain、ip、port、service.name、service.http.title、service.http.server、service.http.response.header、service.http.favicon.hash、country_cn、province_cn、city_cn、location.country_cn、location.province_cn、location.city_cn、asn、org。 - 字段示例: - domain:"example.com" - ip:"1.1.1.1" - port:443 - service.name:"http" - service.name:"ssh" - service.http.title:"登录" - service.http.server:"nginx" - service.http.response.header:"JSESSIONID" - service.http.favicon.hash:"-247388890" - country_cn:"中国" - province_cn:"浙江" - city_cn:"杭州" - 组合示例: - service.name:"http" AND country_cn:"中国" - (service.name:"http" OR service.name:"https") AND port:443 - domain:"example.com" AND NOT service.http.title:"404" - service.http.title:"login" AND port:443 - service.name:"ssh" AND country_cn:"中国" - 生成注意: - 用户说“中国/浙江/杭州”等中文地理位置时,Quake 优先使用 country_cn/province_cn/city_cn 并保留中文值。 - 用户说“标题”映射为 service.http.title;说“Server/服务端软件”映射为 service.http.server;说“favicon/hash/icon”映射为 service.http.favicon.hash。 - Quake 不使用 && / || 作为首选输出;优先输出 AND / OR / NOT。 `, "shodan": ` Shodan 官方查询语法参考: - 默认裸关键词只搜索 banner 的 data 内容;精确条件使用 filter:value。 - filter 与 value 中间不能有空格;值包含空格时用英文双引号,例如 org:"Amazon Web Services"。 - 多个过滤器并列表示同时满足(AND);Shodan 查询不要使用 &&、||,除非用户明确给出并要求保留。 - 常用过滤器:product、port、country、city、org、asn、hostname、net、ssl、ssl.cert.subject.cn、http.title、has_screenshot、vuln。 - 字段示例: - product:nginx - port:443 - country:CN - city:Shanghai - org:"Amazon" - asn:AS15169 - hostname:example.com - ssl.cert.subject.cn:example.com - http.title:"Dashboard" - has_screenshot:true - vuln:CVE-2021-41773 - 组合示例: - product:nginx country:CN - apache country:DE - org:"Amazon" port:443 - ssl.cert.subject.cn:example.com port:443 - http.title:"login" country:CN - ssl:true port:443 hostname:example.com - 生成注意: - 用户说“产品/组件/服务软件”优先映射为 product;说“组织/公司/云厂商”映射为 org;说“证书 CN/SAN/域名证书”优先映射为 ssl.cert.subject.cn。 - 国家用两位国家代码;如果用户给出中文国家名且无法确定代码,把推断写入 explanation 或 warnings。 - Shodan 没有通用 NOT 排除语法;遇到“排除/不要”时应在 warnings 说明可能需要人工调整,不要强行编造过滤器。 `, }[provider] systemPrompt := strings.TrimSpace(fmt.Sprintf(` 你是“%s 查询语法生成器”。任务:把用户输入的自然语言搜索意图,转换成 %s 查询语法。 输出要求(非常重要): 1) 只输出 JSON(不要 markdown、不要代码块、不要额外解释文本) 2) JSON 结构必须是: { "query": "string,%s 查询语法(可直接粘贴到 %s 或本系统查询框)", "explanation": "string,可选,解释你如何映射字段/逻辑", "warnings": ["string"...] 可选,列出歧义/风险/需要人工确认的点 } 3) 如果用户输入本身已经是 %s 查询语法(或非常接近该语法的表达式),应当“原样返回”为 query: - 不要擅自改写字段名、操作符、括号结构 - 不要改写任何字符串值(尤其是地理位置类值),不要做缩写/同义词替换/翻译/音译 当前搜索引擎语法速查: %s 通用生成约束: - 严格遵守“当前搜索引擎语法速查”里的字段名、操作符和示例风格;不同数据源语法不同,不要混用。 - 字符串值保持用户原意:不要无依据缩写、翻译、音译、替换同义词或改写大小写。 - 地理位置、组织名、产品名、域名、证书名、CVE 编号等实体值必须尽量保留原文;确需推断(如“中国”到 CN)时在 explanation 或 warnings 中说明。 - 不要捏造字段。不确定字段是否支持时,选择更通用且确定的字段,或把不确定点写进 warnings。 - 当用户描述里有多个与/或条件,必须使用该数据源支持的括号和逻辑操作符明确优先级。 - 如果用户输入已经是当前数据源查询语法或非常接近,应原样返回;只在明显有语法错误且能确定修复方式时轻微修正,并在 explanation 说明。 - 如果需求范围过大、关键目标缺失或语义矛盾,允许 query 为空字符串,并在 warnings 中明确需要补充的信息。 - 只生成资产测绘/信息收集查询语法,不生成扫描、利用、爆破、绕过、命令执行或攻击步骤。 `, engineName, engineName, engineName, engineName, engineName, syntaxNotes)) userPrompt := fmt.Sprintf("自然语言意图:%s", req.Text) requestBody := map[string]interface{}{ "model": h.cfg.OpenAI.Model, "messages": []map[string]interface{}{ {"role": "system", "content": systemPrompt}, {"role": "user", "content": userPrompt}, }, "temperature": 0.1, "max_completion_tokens": 12000, } // OpenAI 返回结构:只需要 choices[0].message.content var apiResponse struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` } ctx, cancel := context.WithTimeout(c.Request.Context(), 90*time.Second) defer cancel() if err := h.openAIClient.ChatCompletion(ctx, requestBody, &apiResponse); err != nil { var apiErr *openaiClient.APIError if errors.As(err, &apiErr) { h.logger.Warn("FOFA自然语言解析:LLM返回错误", zap.Int("status", apiErr.StatusCode)) c.JSON(http.StatusBadGateway, gin.H{"error": "AI 解析失败(上游返回非 200),请检查模型配置或稍后重试"}) return } c.JSON(http.StatusBadGateway, gin.H{"error": "AI 解析失败: " + err.Error()}) return } if len(apiResponse.Choices) == 0 { c.JSON(http.StatusBadGateway, gin.H{"error": "AI 未返回有效结果"}) return } content := strings.TrimSpace(apiResponse.Choices[0].Message.Content) jsonContent, extractErr := extractInfoCollectJSONObject(content) if extractErr != nil { snippet := trimSnippet(content, 1200) c.JSON(http.StatusBadGateway, gin.H{ "error": "AI 返回内容无法解析为 JSON,请稍后重试或换个描述方式", "snippet": snippet, }) return } var parsed fofaParseResponse if err := json.Unmarshal([]byte(jsonContent), &parsed); err != nil { // 直接回传一部分原文,方便排查,但避免太大 snippet := trimSnippet(content, 1200) c.JSON(http.StatusBadGateway, gin.H{ "error": "AI 返回内容无法解析为 JSON,请稍后重试或换个描述方式", "snippet": snippet, }) return } parsed.Query = strings.TrimSpace(parsed.Query) if parsed.Query == "" { // query 允许为空(表示需求不明确),但前端需要明确提示 if len(parsed.Warnings) == 0 { parsed.Warnings = []string{"需求信息不足,未能生成可用的 " + engineName + " 查询语法,请补充关键条件(如国家/端口/产品/域名等)。"} } } c.JSON(http.StatusOK, parsed) } func extractInfoCollectJSONObject(content string) (string, error) { content = strings.TrimSpace(content) if content == "" { return "", errors.New("empty content") } candidates := []string{content} if fenced := extractFencedJSON(content); fenced != "" { candidates = append([]string{fenced}, candidates...) } for _, candidate := range candidates { candidate = strings.TrimSpace(candidate) if candidate == "" { continue } if json.Valid([]byte(candidate)) { return candidate, nil } if obj := scanBalancedJSONObject(candidate); obj != "" && json.Valid([]byte(obj)) { return obj, nil } } return "", errors.New("json object not found") } func extractFencedJSON(content string) string { start := strings.Index(content, "```") if start < 0 { return "" } rest := content[start+3:] if nl := strings.Index(rest, "\n"); nl >= 0 { lang := strings.ToLower(strings.TrimSpace(rest[:nl])) if lang == "" || lang == "json" || strings.HasPrefix(lang, "json ") { rest = rest[nl+1:] } } end := strings.Index(rest, "```") if end < 0 { return "" } return strings.TrimSpace(rest[:end]) } func scanBalancedJSONObject(content string) string { start := strings.Index(content, "{") if start < 0 { return "" } depth := 0 inString := false escaped := false for i := start; i < len(content); i++ { ch := content[i] if inString { if escaped { escaped = false continue } switch ch { case '\\': escaped = true case '"': inString = false } continue } switch ch { case '"': inString = true case '{': depth++ case '}': depth-- if depth == 0 { return strings.TrimSpace(content[start : i+1]) } } } return "" } func trimSnippet(s string, maxRunes int) string { s = strings.TrimSpace(s) if maxRunes <= 0 { return "" } runes := []rune(s) if len(runes) <= maxRunes { return s } return string(runes[:maxRunes]) } // Search FOFA 查询(后端代理,避免前端暴露 key) func (h *FofaHandler) Search(c *gin.Context) { var req fofaSearchRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()}) return } provider := normalizeSpaceSearchProvider(req.Provider) if provider == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "provider 不支持,可选:fofa、zoomeye、quake、shodan"}) return } req.Query = strings.TrimSpace(req.Query) if req.Query == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "query 不能为空"}) return } if req.Size <= 0 { req.Size = 100 } if req.Page <= 0 { req.Page = 1 } // FOFA 接口 size 上限和账户权限相关,这里只做一个合理的保护 if req.Size > 10000 { req.Size = 10000 } if req.Fields == "" { req.Fields = defaultFieldsForProvider(provider) } if provider != "fofa" { h.searchExternalProvider(c, provider, req) return } apiKey := h.resolveAPIKey(provider) if apiKey == "" { c.JSON(http.StatusBadRequest, gin.H{ "error": "FOFA 未配置:请在系统设置的资产管理中填写 FOFA API Key,或设置环境变量 FOFA_API_KEY", "need": []string{"fofa.api_key"}, "env_key": []string{"FOFA_API_KEY"}, }) return } baseURL := h.resolveBaseURL(provider) qb64 := base64.StdEncoding.EncodeToString([]byte(req.Query)) u, err := url.Parse(baseURL) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "FOFA base_url 无效: " + err.Error()}) return } params := u.Query() params.Set("key", apiKey) params.Set("qbase64", qb64) params.Set("size", fmt.Sprintf("%d", req.Size)) params.Set("page", fmt.Sprintf("%d", req.Page)) params.Set("fields", strings.TrimSpace(req.Fields)) if req.Full { params.Set("full", "true") } else { // 明确传 false,便于排查 params.Set("full", "false") } u.RawQuery = params.Encode() httpReq, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, u.String(), nil) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "创建请求失败: " + err.Error()}) return } httpReq.Header.Set("User-Agent", "CyberStrikeAI/1.7.4") httpReq.Header.Set("Accept", "application/json") resp, err := h.client.Do(httpReq) if err != nil { status, message, timeout := safeFofaRequestError(err) h.logger.Warn("请求 FOFA 失败", zap.String("host", u.Host), zap.Bool("timeout", timeout), zap.String("error_type", fmt.Sprintf("%T", err)), ) c.JSON(status, gin.H{"error": message}) return } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("FOFA 返回非 2xx: %d", resp.StatusCode)}) return } var apiResp fofaAPIResponse if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "解析 FOFA 响应失败: " + err.Error()}) return } if apiResp.Error { msg := strings.TrimSpace(apiResp.ErrMsg) if msg == "" { msg = "FOFA 返回错误" } c.JSON(http.StatusBadGateway, gin.H{"error": msg}) return } fields := splitAndCleanCSV(req.Fields) results := make([]map[string]interface{}, 0, len(apiResp.Results)) for _, row := range apiResp.Results { item := make(map[string]interface{}, len(fields)) for i, f := range fields { if i < len(row) { item[f] = row[i] } else { item[f] = nil } } results = append(results, item) } c.JSON(http.StatusOK, fofaSearchResponse{ Provider: provider, Query: req.Query, Size: apiResp.Size, Page: apiResp.Page, Total: apiResp.Total, Fields: fields, ResultsCount: len(results), Results: results, }) } func (h *FofaHandler) searchExternalProvider(c *gin.Context, provider string, req fofaSearchRequest) { apiKey := h.resolveAPIKey(provider) if apiKey == "" { envKey := map[string]string{ "zoomeye": "ZOOMEYE_API_KEY", "quake": "QUAKE_API_KEY", "shodan": "SHODAN_API_KEY", }[provider] c.JSON(http.StatusBadRequest, gin.H{ "error": providerDisplayName(provider) + " 未配置:请在 config.yaml 中填写 api_key,或设置环境变量 " + envKey, "need": []string{provider + ".api_key"}, "env_key": []string{envKey}, }) return } switch provider { case "zoomeye": h.searchZoomEye(c, req, apiKey) case "quake": h.searchQuake(c, req, apiKey) case "shodan": h.searchShodan(c, req, apiKey) } } func (h *FofaHandler) searchZoomEye(c *gin.Context, req fofaSearchRequest, apiKey string) { baseURL := h.resolveBaseURL("zoomeye") u, err := url.Parse(baseURL) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "ZoomEye base_url 无效: " + err.Error()}) return } body := map[string]interface{}{ "qbase64": base64.StdEncoding.EncodeToString([]byte(req.Query)), "page": req.Page, "pagesize": req.Size, } if fields := strings.TrimSpace(req.Fields); fields != "" { body["fields"] = fields } var apiResp struct { Code int `json:"code"` Message string `json:"message"` Query string `json:"query"` Total int `json:"total"` Page int `json:"page"` PageSize int `json:"pagesize"` Data []map[string]interface{} `json:"data"` } if !h.doJSONRequest(c, http.MethodPost, u.String(), apiKey, "API-KEY", body, &apiResp, "ZoomEye") { return } if apiResp.Code != 60000 { msg := strings.TrimSpace(apiResp.Message) if msg == "" { msg = "ZoomEye 返回错误" } c.JSON(http.StatusBadGateway, gin.H{"error": msg}) return } fields := splitAndCleanCSV(req.Fields) c.JSON(http.StatusOK, fofaSearchResponse{ Provider: "zoomeye", Query: firstNonEmptySpaceSearchValue(apiResp.Query, req.Query), Size: firstPositive(apiResp.PageSize, req.Size), Page: firstPositive(apiResp.Page, req.Page), Total: apiResp.Total, Fields: fields, ResultsCount: len(apiResp.Data), Results: projectRows(apiResp.Data, fields), }) } func (h *FofaHandler) searchQuake(c *gin.Context, req fofaSearchRequest, apiKey string) { baseURL := h.resolveBaseURL("quake") u, err := url.Parse(baseURL) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Quake base_url 无效: " + err.Error()}) return } fields := splitAndCleanCSV(req.Fields) body := map[string]interface{}{ "query": req.Query, "size": req.Size, "start": (req.Page - 1) * req.Size, "latest": req.Full, } if len(fields) > 0 { body["include"] = fields } var apiResp struct { Code int `json:"code"` Message string `json:"message"` TotalCount int `json:"total_count"` Data []map[string]interface{} `json:"data"` Meta struct { Pagination struct { Total int `json:"total"` } `json:"pagination"` } `json:"meta"` } if !h.doJSONRequest(c, http.MethodPost, u.String(), apiKey, "X-QuakeToken", body, &apiResp, "Quake") { return } if apiResp.Code != 0 { msg := strings.TrimSpace(apiResp.Message) if msg == "" { msg = "Quake 返回错误" } c.JSON(http.StatusBadGateway, gin.H{"error": msg}) return } total := firstPositive(apiResp.TotalCount, apiResp.Meta.Pagination.Total) c.JSON(http.StatusOK, fofaSearchResponse{ Provider: "quake", Query: req.Query, Size: req.Size, Page: req.Page, Total: total, Fields: fields, ResultsCount: len(apiResp.Data), Results: projectRows(apiResp.Data, fields), }) } func (h *FofaHandler) searchShodan(c *gin.Context, req fofaSearchRequest, apiKey string) { baseURL := strings.TrimRight(h.resolveBaseURL("shodan"), "/") + "/shodan/host/search" u, err := url.Parse(baseURL) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Shodan base_url 无效: " + err.Error()}) return } var apiResp struct { Total int `json:"total"` Matches []map[string]interface{} `json:"matches"` Error string `json:"error"` } targetSize := req.Size if targetSize <= 0 { targetSize = 100 } if targetSize > 1000 { targetSize = 1000 } page := req.Page matches := make([]map[string]interface{}, 0, targetSize) pagesNeeded := (targetSize + 99) / 100 for i := 0; i < pagesNeeded; i++ { pageURL := *u params := pageURL.Query() params.Set("key", apiKey) params.Set("query", req.Query) params.Set("page", fmt.Sprintf("%d", page+i)) params.Set("minify", "false") if fields := strings.TrimSpace(req.Fields); fields != "" { params.Set("fields", fields) } pageURL.RawQuery = params.Encode() apiResp.Matches = nil apiResp.Error = "" if !h.doJSONRequest(c, http.MethodGet, pageURL.String(), "", "", nil, &apiResp, "Shodan") { return } if strings.TrimSpace(apiResp.Error) != "" { c.JSON(http.StatusBadGateway, gin.H{"error": apiResp.Error}) return } if len(apiResp.Matches) == 0 { break } matches = append(matches, apiResp.Matches...) if len(matches) >= targetSize { matches = matches[:targetSize] break } } fields := splitAndCleanCSV(req.Fields) expectedCount := shodanExpectedResultCount(apiResp.Total, page, targetSize) shortfall := expectedCount - len(matches) warning := "" if shortfall > 0 { warning = fmt.Sprintf("Shodan 统计总数为 %d,但本次分页实际只返回 %d/%d 条明细", apiResp.Total, len(matches), expectedCount) } c.JSON(http.StatusOK, fofaSearchResponse{ Provider: "shodan", Query: req.Query, Size: targetSize, Page: page, Total: apiResp.Total, Fields: fields, ResultsCount: len(matches), ExpectedCount: expectedCount, Shortfall: max(0, shortfall), Warning: warning, Results: projectRows(matches, fields), }) } func shodanExpectedResultCount(total, page, size int) int { if total <= 0 || size <= 0 { return 0 } if page <= 0 { page = 1 } startOffset := (page - 1) * 100 remaining := total - startOffset if remaining <= 0 { return 0 } if remaining < size { return remaining } return size } func (h *FofaHandler) doJSONRequest(c *gin.Context, method, endpoint, apiKey, headerName string, body interface{}, out interface{}, label string) bool { var reqBody *strings.Reader if body != nil { b, err := json.Marshal(body) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "创建请求失败: " + err.Error()}) return false } reqBody = strings.NewReader(string(b)) } else { reqBody = strings.NewReader("") } httpReq, err := http.NewRequestWithContext(c.Request.Context(), method, endpoint, reqBody) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "创建请求失败: " + err.Error()}) return false } httpReq.Header.Set("User-Agent", "CyberStrikeAI/1.7.4") httpReq.Header.Set("Accept", "application/json") if body != nil { httpReq.Header.Set("Content-Type", "application/json") } if headerName != "" && apiKey != "" { httpReq.Header.Set(headerName, apiKey) } resp, err := h.client.Do(httpReq) if err != nil { status, message, timeout := safeFofaRequestError(err) h.logger.Warn("请求空间测绘搜索失败", zap.String("provider", label), zap.Bool("timeout", timeout), zap.String("error_type", fmt.Sprintf("%T", err)), ) c.JSON(status, gin.H{"error": strings.Replace(message, "FOFA", label, 1)}) return false } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("%s 返回非 2xx: %d", label, resp.StatusCode)}) return false } if err := json.NewDecoder(resp.Body).Decode(out); err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "解析 " + label + " 响应失败: " + err.Error()}) return false } return true } func safeFofaRequestError(err error) (status int, message string, timeout bool) { var netErr net.Error timeout = errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &netErr) && netErr.Timeout()) if timeout { return http.StatusGatewayTimeout, "FOFA 请求超时(60 秒):请稍后重试,或减少返回数量和返回字段", true } return http.StatusBadGateway, "无法连接 FOFA 服务,请检查服务器网络或代理配置", false } func splitAndCleanCSV(s string) []string { parts := strings.Split(s, ",") out := make([]string, 0, len(parts)) seen := make(map[string]struct{}, len(parts)) for _, p := range parts { v := strings.TrimSpace(p) if v == "" { continue } if _, ok := seen[v]; ok { continue } seen[v] = struct{}{} out = append(out, v) } return out } func projectRows(rows []map[string]interface{}, fields []string) []map[string]interface{} { if len(fields) == 0 { return rows } out := make([]map[string]interface{}, 0, len(rows)) for _, row := range rows { item := make(map[string]interface{}, len(fields)) for _, field := range fields { item[field] = valueByPath(row, field) } out = append(out, item) } return out } func valueByPath(row map[string]interface{}, path string) interface{} { if row == nil { return nil } if v, ok := row[path]; ok { return v } parts := strings.Split(path, ".") var current interface{} = row for _, part := range parts { m, ok := current.(map[string]interface{}) if !ok { return nil } current, ok = m[part] if !ok { return nil } } return current } func firstPositive(values ...int) int { for _, v := range values { if v > 0 { return v } } return 0 } func firstNonEmptySpaceSearchValue(values ...string) string { for _, v := range values { if strings.TrimSpace(v) != "" { return v } } return "" }