Files
HomeDash/admin-server/gateway_import.go
HomeDash Bot 53beff646a feat(admin): 现有配置导入 + 添加表单补齐认证/导航开关 + 统一登录账号设置 + 设置分组
针对反馈的五个问题:

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 类型生成。
2026-09-11 22:00:09 +08:00

183 lines
5.0 KiB
Go
Raw Permalink 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 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 导航项。",
})
}