From 7eedb74c3bf8d5d348a3c6e7b4d2be75036cd441 Mon Sep 17 00:00:00 2001 From: samwaf Date: Thu, 16 Jul 2026 10:26:52 +0800 Subject: [PATCH] feat:batch import hosts from nginx #881 --- api/waf_onekeymod.go | 31 ++++ model/request/waf_onekeymod_req.go | 7 + router/waf_router.go | 1 + wafonekey/nginx_parse.go | 260 +++++++++++++++++++++++++++++ wafonekey/nginx_parse_test.go | 81 +++++++++ 5 files changed, 380 insertions(+) create mode 100644 wafonekey/nginx_parse.go create mode 100644 wafonekey/nginx_parse_test.go diff --git a/api/waf_onekeymod.go b/api/waf_onekeymod.go index d19f4b7..1836571 100644 --- a/api/waf_onekeymod.go +++ b/api/waf_onekeymod.go @@ -113,6 +113,37 @@ func (w *WafOneKeyModApi) DoOneKeyModifyApi(c *gin.Context) { } } +// ParseNginxApi 解析nginx配置为待添加主机候选 +// @Summary 解析nginx配置 +// @Description 解析粘贴文本或扫描宝塔vhost目录,提取待添加的被防护主机候选(只读,不修改文件) +// @Tags 一键修改 +// @Accept json +// @Produce json +// @Param data body request.WafParseNginxReq true "解析参数" +// @Success 200 {object} response.Response "解析成功" +// @Security ApiKeyAuth +// @Router /wafhost/onekeymod/parseNginx [post] +func (w *WafOneKeyModApi) ParseNginxApi(c *gin.Context) { + var req request.WafParseNginxReq + err := c.ShouldBindJSON(&req) + if err != nil { + response.FailWithMessage("解析失败", c) + return + } + var candidates []wafonekey.NginxHostCandidate + switch req.Source { + case "scan": + candidates, err = wafonekey.ScanNginxDir(req.FilePath) + default: // text + candidates, err = wafonekey.ParseNginxText(req.Content) + } + if err != nil { + response.FailWithMessage("解析失败 "+err.Error(), c) + return + } + response.OkWithDetailed(candidates, "解析成功", c) +} + // RestoreApi 还原一键修改 // @Summary 还原一键修改 // @Description 根据ID还原指定的一键修改记录到原始状态 diff --git a/model/request/waf_onekeymod_req.go b/model/request/waf_onekeymod_req.go index 436823c..7c6dc2c 100644 --- a/model/request/waf_onekeymod_req.go +++ b/model/request/waf_onekeymod_req.go @@ -18,3 +18,10 @@ type WafOneKeyModSearchReq struct { type WafDoOneKeyModReq struct { FilePath string `json:"file_path"` //文件所在路径 } + +// WafParseNginxReq 解析 nginx 配置为待添加主机候选 +type WafParseNginxReq struct { + Source string `json:"source"` //来源: "text" 粘贴文本 | "scan" 扫描目录 + Content string `json:"content"` //source=text 时的粘贴内容 + FilePath string `json:"file_path"` //source=scan 时的目录 +} diff --git a/router/waf_router.go b/router/waf_router.go index 0bb40fa..128cadd 100644 --- a/router/waf_router.go +++ b/router/waf_router.go @@ -16,4 +16,5 @@ func (receiver *OneKeyModRouter) InitOneKeyModRouter(group *gin.RouterGroup) { router.POST("/api/v1/wafhost/onekeymod/doModify", api.DoOneKeyModifyApi) router.GET("/api/v1/wafhost/onekeymod/del", api.DelApi) router.GET("/api/v1/wafhost/onekeymod/restore", api.RestoreApi) + router.POST("/api/v1/wafhost/onekeymod/parseNginx", api.ParseNginxApi) } diff --git a/wafonekey/nginx_parse.go b/wafonekey/nginx_parse.go new file mode 100644 index 0000000..2a7c610 --- /dev/null +++ b/wafonekey/nginx_parse.go @@ -0,0 +1,260 @@ +package wafonekey + +import ( + "errors" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +// 解析相关安全上限 +const ( + maxNginxConfigLen = 1 << 20 // 粘贴文本上限 1MB + maxScanFileCount = 500 // 扫描目录最多读取的 .conf 文件数 + maxScanFileSize = 2 << 20 // 单个 .conf 文件读取上限 2MB + scanRequiredSubDir = "server/panel/vhost" +) + +// NginxHostCandidate 从一个 nginx server{} 块解析出的待添加主机候选 +type NginxHostCandidate struct { + Domains []string `json:"domains"` // server_name 全部域名(已过滤 _ / 空 / 非法FQDN) + Port int `json:"port"` // listen 端口 → 拟作 remote_port + Ssl bool `json:"ssl"` // listen ssl / 存在 ssl_certificate + Root string `json:"root"` // nginx root,仅展示/备注用,不参与代理 + SourceFile string `json:"source_file"` // 扫描模式下来源 .conf 文件名(粘贴模式为空) +} + +var ( + reServerBlock = regexp.MustCompile(`(?:^|\s)server\s*\{`) + reListen = regexp.MustCompile(`(?mi)^\s*listen\s+([^;]+);`) + reServerName = regexp.MustCompile(`(?mi)^\s*server_name\s+([^;]+);`) + reRoot = regexp.MustCompile(`(?mi)^\s*root\s+([^;]+);`) + reSslCert = regexp.MustCompile(`(?mi)^\s*ssl_certificate\s+[^;]+;`) + reFQDN = regexp.MustCompile(`^(?:\*\.)?(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`) +) + +// ParseNginxText 解析粘贴的 nginx 配置文本 +func ParseNginxText(content string) ([]NginxHostCandidate, error) { + if len(content) > maxNginxConfigLen { + return nil, errors.New("配置内容过长") + } + return parseNginxContent(content, ""), nil +} + +// ScanNginxDir 安全地扫描目录下 .conf 并解析 +func ScanNginxDir(dirPath string) ([]NginxHostCandidate, error) { + safeDir, err := resolveSafeNginxDir(dirPath) + if err != nil { + return nil, err + } + entries, err := os.ReadDir(safeDir) + if err != nil { + return nil, err + } + candidates := make([]NginxHostCandidate, 0) + readCnt := 0 + for _, entry := range entries { + if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".conf") { + continue + } + if readCnt >= maxScanFileCount { + break + } + readCnt++ + filePath := filepath.Join(safeDir, entry.Name()) + info, err := entry.Info() + if err != nil || info.Size() > maxScanFileSize { + continue + } + data, err := os.ReadFile(filePath) + if err != nil { + continue + } + candidates = append(candidates, parseNginxContent(string(data), entry.Name())...) + } + return candidates, nil +} + +// resolveSafeNginxDir 校验用户提供的目录必须是真实的 nginx vhost 目录,防止路径穿越/任意目录读取。 +// 采用「绝对化 + Clean + 路径分段判定」而非现有 OneKeyModifyBt 的弱子串判断: +// 要求路径分段中连续出现 server/panel/vhost,避免 .../vhostEVIL 之类的旁路。 +func resolveSafeNginxDir(dirPath string) (string, error) { + if strings.TrimSpace(dirPath) == "" { + dirPath = "/www/server/panel/vhost/nginx" + } + absPath, err := filepath.Abs(filepath.Clean(dirPath)) + if err != nil { + return "", errors.New("无法解析目录路径") + } + // 分段(统一为斜杠)判定 + normalized := filepath.ToSlash(absPath) + segs := strings.Split(normalized, "/") + cleaned := make([]string, 0, len(segs)) + for _, s := range segs { + if s == "" || s == "." { + continue + } + if s == ".." { + return "", errors.New("目录不在允许范围内") + } + cleaned = append(cleaned, s) + } + required := strings.Split(scanRequiredSubDir, "/") + if !containsContiguous(cleaned, required) { + return "", errors.New("目录不在允许范围内(须为宝塔 nginx vhost 目录)") + } + return absPath, nil +} + +// containsContiguous 判断 required 是否作为连续子序列出现在 segs 中 +func containsContiguous(segs, required []string) bool { + if len(required) == 0 || len(segs) < len(required) { + return false + } + for i := 0; i+len(required) <= len(segs); i++ { + match := true + for j := range required { + if segs[i+j] != required[j] { + match = false + break + } + } + if match { + return true + } + } + return false +} + +// parseNginxContent 从配置文本中提取每个 server{} 块的候选 +func parseNginxContent(content, sourceFile string) []NginxHostCandidate { + candidates := make([]NginxHostCandidate, 0) + for _, block := range splitServerBlocks(content) { + candidate, ok := parseServerBlock(block) + if !ok { + continue + } + candidate.SourceFile = sourceFile + candidates = append(candidates, candidate) + } + return candidates +} + +// splitServerBlocks 用花括号配对切分出所有 server{} 块的内部内容 +func splitServerBlocks(content string) []string { + blocks := make([]string, 0) + locs := reServerBlock.FindAllStringIndex(content, -1) + for _, loc := range locs { + open := strings.IndexByte(content[loc[0]:loc[1]], '{') + if open < 0 { + continue + } + open += loc[0] + depth := 0 + end := -1 + for i := open; i < len(content); i++ { + switch content[i] { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + end = i + } + } + if end >= 0 { + break + } + } + if end > open { + blocks = append(blocks, content[open+1:end]) + } + } + return blocks +} + +// parseServerBlock 解析单个 server 块内容 +func parseServerBlock(block string) (NginxHostCandidate, bool) { + var candidate NginxHostCandidate + + // listen:取第一条含合法端口的;ssl 为任意一条含 ssl 关键字或存在 ssl_certificate + for _, m := range reListen.FindAllStringSubmatch(block, -1) { + port, ssl := parseListen(m[1]) + if candidate.Port == 0 && port > 0 && port <= 65535 { + candidate.Port = port + } + if ssl { + candidate.Ssl = true + } + } + if reSslCert.MatchString(block) { + candidate.Ssl = true + } + if candidate.Port == 0 { + return candidate, false + } + + // server_name:拆分并过滤非法域名 + if m := reServerName.FindStringSubmatch(block); m != nil { + for _, name := range strings.Fields(m[1]) { + name = strings.TrimSpace(strings.ToLower(name)) + if name == "" || name == "_" || name == "localhost" { + continue + } + if reFQDN.MatchString(name) { + candidate.Domains = append(candidate.Domains, name) + } + } + } + if len(candidate.Domains) == 0 { + return candidate, false + } + + // root:仅展示 + if m := reRoot.FindStringSubmatch(block); m != nil { + candidate.Root = strings.TrimSpace(m[1]) + } + + return candidate, true +} + +// parseListen 解析 listen 指令值,返回端口与是否 ssl +// 兼容: "81" / "127.0.0.1:81" / "[::]:81" / "443 ssl http2" / "0.0.0.0:443 ssl" +func parseListen(value string) (int, bool) { + fields := strings.Fields(value) + if len(fields) == 0 { + return 0, false + } + ssl := false + for _, f := range fields[1:] { + if strings.EqualFold(f, "ssl") { + ssl = true + } + } + return extractPort(fields[0]), ssl +} + +// extractPort 从 listen 的地址部分取端口 +func extractPort(addr string) int { + // IPv6: [::]:81 + if i := strings.LastIndex(addr, "]:"); i >= 0 { + if n, err := strconv.Atoi(addr[i+2:]); err == nil { + return n + } + return 0 + } + // host:port + if i := strings.LastIndex(addr, ":"); i >= 0 { + if n, err := strconv.Atoi(addr[i+1:]); err == nil { + return n + } + return 0 + } + // 纯端口 + if n, err := strconv.Atoi(addr); err == nil { + return n + } + return 0 +} diff --git a/wafonekey/nginx_parse_test.go b/wafonekey/nginx_parse_test.go new file mode 100644 index 0000000..f501e89 --- /dev/null +++ b/wafonekey/nginx_parse_test.go @@ -0,0 +1,81 @@ +package wafonekey + +import "testing" + +func TestParseNginxText_Example(t *testing.T) { + content := ` +server +{ + listen 81; + server_name log.samwaf.net log2.samwaf.net; + index index.php index.html index.htm default.php default.htm default.html; + root /www/wwwroot/log.samwaf.net_81; + location / { + root /some/other; + } +} +server { + listen 444 ssl http2; + server_name shop.samwaf.net; + ssl_certificate /x/a.pem; + ssl_certificate_key /x/a.key; + root /www/wwwroot/shop; +} +` + got, err := ParseNginxText(content) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(got) != 2 { + t.Fatalf("want 2 candidates, got %d: %+v", len(got), got) + } + c0 := got[0] + if c0.Port != 81 || c0.Ssl { + t.Errorf("c0 port/ssl wrong: %+v", c0) + } + if len(c0.Domains) != 2 || c0.Domains[0] != "log.samwaf.net" || c0.Domains[1] != "log2.samwaf.net" { + t.Errorf("c0 domains wrong: %+v", c0.Domains) + } + if c0.Root != "/www/wwwroot/log.samwaf.net_81" { + t.Errorf("c0 root wrong: %q", c0.Root) + } + c1 := got[1] + if c1.Port != 444 || !c1.Ssl { + t.Errorf("c1 port/ssl wrong: %+v", c1) + } + if len(c1.Domains) != 1 || c1.Domains[0] != "shop.samwaf.net" { + t.Errorf("c1 domains wrong: %+v", c1.Domains) + } +} + +func TestParseListen(t *testing.T) { + cases := []struct { + in string + port int + ssl bool + }{ + {"81", 81, false}, + {"127.0.0.1:81", 81, false}, + {"[::]:81", 81, false}, + {"443 ssl http2", 443, true}, + {"0.0.0.0:443 ssl", 443, true}, + } + for _, c := range cases { + p, s := parseListen(c.in) + if p != c.port || s != c.ssl { + t.Errorf("parseListen(%q)=%d,%v want %d,%v", c.in, p, s, c.port, c.ssl) + } + } +} + +func TestResolveSafeNginxDir(t *testing.T) { + if _, err := resolveSafeNginxDir("/www/server/panel/vhost/nginx"); err != nil { + t.Errorf("valid dir rejected: %v", err) + } + bad := []string{"/etc", "/root", "/tmp/server/panel/vhostEVIL/x", "/www/server/panel/../../../etc"} + for _, d := range bad { + if _, err := resolveSafeNginxDir(d); err == nil { + t.Errorf("bad dir %q accepted", d) + } + } +}