package main import ( "encoding/json" "fmt" "net/http" "regexp" "strconv" "strings" ) // ── 从现有 nginx 配置导入路由 ── // // 迁移期需要把老的统一入口(portal-nginx)里已有的域名→后端映射一次性读进来, // 否则后台"统一入口"永远是空的(本轮实际发生过)。这里不去猜文件位置, // 而是让使用者把老的 nginx 配置贴在界面上,服务端只做解析: // 解析结果先回给界面供确认,用户点保存/应用才真正落盘——不做任何隐式写入。 var ( reMapEntry = regexp.MustCompile(`(?m)^\s*([A-Za-z0-9*._-]+)\s+(https?://[^\s;]+)\s*;`) reServerName = regexp.MustCompile(`(?m)^\s*server_name\s+([^;]+);`) reProxyPass = regexp.MustCompile(`proxy_pass\s+(\$backend|\$[A-Za-z_]+|https?://[^\s;]+)\s*;`) ) // parseNginxConf 把老配置解析成路由初稿。 // // 返回的 Route 一律 NavEnabled=false:导航项归 Dashy 管,导入只负责"域名→后端", // 勾选「加入导航」才会接管对应导航项,避免一导入就把手工维护的导航改掉。 func parseNginxConf(text string) ([]Route, error) { if strings.TrimSpace(text) == "" { return nil, fmt.Errorf("配置内容为空") } upstreams := map[string]string{} for _, m := range reMapEntry.FindAllStringSubmatch(text, -1) { host := strings.ToLower(strings.TrimSpace(m[1])) if host == "default" || host == "hostnames" { continue } upstreams[host] = m[2] } // 逐块扫描 server { },用大括号计数找边界(配置里没有嵌套 server) var routes []Route idx := 0 for { start := strings.Index(text[idx:], "server") if start < 0 { break } start += idx brace := strings.Index(text[start:], "{") if brace < 0 { break } open := start + brace depth, end := 0, -1 for i := open; i < len(text); i++ { switch text[i] { case '{': depth++ case '}': depth-- if depth == 0 { end = i } } if end >= 0 { break } } if end < 0 { break } body := text[open+1 : end] idx = end + 1 sn := reServerName.FindStringSubmatch(body) if sn == nil { continue } hosts := strings.Fields(strings.TrimSpace(sn[1])) if len(hosts) == 0 || (len(hosts) == 1 && hosts[0] == "_") { continue // 默认/兜底段不是具体服务 } hasAuth := strings.Contains(body, "auth_request /_auth;") hasSSE := strings.Contains(body, "postpone_output 0") hasBeian := strings.Contains(body, "sub_filter") hasWS := strings.Contains(body, "Upgrade $http_upgrade") insecure := strings.Contains(body, "proxy_ssl_verify off") proxyLit := "" if pm := reProxyPass.FindStringSubmatch(body); pm != nil { proxyLit = pm[1] } bypass := []string{} if strings.Contains(body, "location /search") { bypass = append(bypass, "/search") } if strings.Contains(body, "autocompleter") { bypass = append(bypass, "/api/", "/autocompleter/", "/static/") } for _, host := range hosts { h := strings.ToLower(strings.TrimSpace(host)) if h == "" { continue } up := upstreams[h] if up == "" && strings.HasPrefix(proxyLit, "http") { up = proxyLit } r := Route{ Host: h, Upstream: up, RequireLogin: hasAuth, Websocket: hasWS, Streaming: hasSSE, InsecureTLS: insecure, InjectBeian: hasBeian, BypassPaths: bypass, } // 登录服务与纯静态门户段没有内网后端:分别归为 auth / portal 类型 if strings.HasPrefix(h, "login.") || strings.Contains(up, ":4001") { r.Kind, r.Upstream = "auth", "" r.RequireLogin = false } else if up == "" { r.Kind = "portal" r.RequireLogin = false } r.Note = "导入" routes = append(routes, r) } } if len(routes) == 0 { return nil, fmt.Errorf("没有解析到任何 server_name(请确认贴的是完整 nginx 配置)") } // 去重:同一域名只保留第一条 seen := map[string]bool{} out := make([]Route, 0, len(routes)) for _, r := range routes { if seen[r.Host] { continue } seen[r.Host] = true out = append(out, r) } // 校验一遍,早发现问题(缺后端等)比等用户点应用时才发现好 for _, r := range out { if err := ValidateRoute(r); err != nil { return nil, err } } return out, nil } // handleGatewayImport 解析贴进来的 nginx 配置,返回路由初稿(不写盘)。 func handleGatewayImport(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { jsonErr(w, 405, "method not allowed") return } if !requireAdminToken(w, r) { return } var req struct { Conf string `json:"conf"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonErr(w, 400, "请求体解析失败") return } routes, err := parseNginxConf(req.Conf) if err != nil { jsonErr(w, 400, err.Error()) return } jsonResp(w, map[string]any{ "routes": routes, "count": len(routes), "hint": "已解析 " + strconv.Itoa(len(routes)) + " 个域名(未写入)。确认后点「保存并应用」;勾选「加入导航」的条目才会同时接管 Dashy 导航项。", }) }