针对反馈的五个问题: 1. 后台"没有映射":新增 /admin/api/gateway/import 与界面「从现有 nginx 配置导入」, 把老统一入口的 nginx 配置贴进来即可解析出全部「域名 → 后端」初稿 (含 requireLogin / 免认证路径 / SSE 流式 / 备案注入 / 自签证书 / auth 与 portal 类型)。 解析只读不落盘,确认后才保存;导入默认不接管导航,避免一导入就改掉手工导航。 2. 添加转发时没有导航选项:添加表单补齐「同时加入 Dashy 导航 / 分组 / 名称 / 描述 / 图标 / 自动抓取」。 3. 添加转发时没有 portal 保护选项:添加表单补齐「启用 portal 统一登录保护」, 并补上 WebSocket 透传与上传上限。 4. 设置里无法配置 portal 账号:新增统一登录账号卡片,账号存数据卷 (portal-auth.json,只存加盐哈希),优先级 数据卷 > 环境变量, 改完立即生效、无需重新部署;会话校验按当前用户名,改账号后旧会话失效。 5. 设置项太乱:按用途分组(站点与外观 / 导航内容 / 历史记录 / 转发与统一登录 / 高级), 外观卡片并入站点组。 另修两个本轮引入的缺陷: - 容器假 unhealthy:内部端口改 18080 后,基础镜像的 HEALTHCHECK 仍指向 8080; 部署脚本生成的 Dockerfile 现在显式覆盖为 :18080/healthz。 - kind=auth 不依赖 portal-auth 容器,指向内置登录服务;避免生成空 proxy_pass。 测试:nginx 配置解析(含兜底段忽略、多域名段、类型判定、默认不接管导航)、 垃圾输入报错、账号存储(不明文、可校验、改名后旧口令失效)、auth 类型生成。
183 lines
5.0 KiB
Go
183 lines
5.0 KiB
Go
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 导航项。",
|
||
})
|
||
}
|