Files
CyberStrikeAI/internal/handler/fofa.go
T
2026-07-20 17:31:20 +08:00

1030 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ""
}