diff --git a/Dockerfile b/Dockerfile index 2faa32b..de4b4e9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -102,5 +102,5 @@ RUN mkdir -p /app/data/item-icons /app/data/gateway /var/log/supervisor /var/cac VOLUME ["/app/data"] EXPOSE 18080 3080 3081 8443 HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ - CMD curl -fs http://localhost:18080/conf.yml || exit 1 + CMD curl -fs http://localhost:18080/healthz || exit 1 CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] diff --git a/admin-server/auth.go b/admin-server/auth.go index 0493190..978d243 100644 --- a/admin-server/auth.go +++ b/admin-server/auth.go @@ -92,7 +92,9 @@ func verifyClaims(token string) bool { if json.Unmarshal(payload, &c) != nil { return false } - return c.User == portalUsername && c.Expiry >= time.Now().Unix() + // 用户名可能已被后台改过,按当前配置比较,避免改完账号旧会话仍有效 + user, _ := portalAuthConfigured() + return c.User == user && c.Expiry >= time.Now().Unix() } func portalAuthenticated(r *http.Request) bool { @@ -167,12 +169,13 @@ func handlePortalLogin(w http.ResponseWriter, r *http.Request) { renderPortalLogin(w, redirect, "请求解析失败") return } - if !secureEqual(r.FormValue("username"), portalUsername) || !secureEqual(r.FormValue("password"), portalPassword) { + if !checkPortalCredentials(r.FormValue("username"), r.FormValue("password")) { w.WriteHeader(http.StatusUnauthorized) renderPortalLogin(w, redirect, "用户名或密码错误") return } - token, err := signClaims(portalClaims{User: portalUsername, Expiry: time.Now().Add(portalSessionTTL).Unix()}) + user, _ := portalAuthConfigured() + token, err := signClaims(portalClaims{User: user, Expiry: time.Now().Add(portalSessionTTL).Unix()}) if err != nil { http.Error(w, "session error", http.StatusInternalServerError) return diff --git a/admin-server/gateway.go b/admin-server/gateway.go index d5c7de0..432aa16 100644 --- a/admin-server/gateway.go +++ b/admin-server/gateway.go @@ -124,7 +124,7 @@ func ValidateRoute(r Route) error { if kind != "proxy" && kind != "portal" && kind != "auth" { return fmt.Errorf("%s: 未知服务类型 %q", host, r.Kind) } - if kind != "portal" { + if kind == "proxy" { up := strings.TrimSpace(r.Upstream) if up == "" { return fmt.Errorf("%s: 后端地址不能为空", host) @@ -251,6 +251,11 @@ func writeRouteServer(b *strings.Builder, cfg *GatewayConfig, r Route) error { b.WriteString(" index index.html;\n location / { try_files $uri $uri/ /404.html; }\n}\n\n") return nil } + if routeKind(r) == "auth" { + // 登录域名由容器内自带的登录服务直接响应,替代原先的 portal-auth 容器 + r.Upstream = "http://127.0.0.1:3005" + r.RequireLogin = false + } if r.RequireLogin { if strings.TrimSpace(cfg.AuthURL) == "" || strings.TrimSpace(cfg.LoginURL) == "" { return fmt.Errorf("%s: 需要认证但未配置认证/登录地址", host) diff --git a/admin-server/gateway_import.go b/admin-server/gateway_import.go new file mode 100644 index 0000000..afed5ba --- /dev/null +++ b/admin-server/gateway_import.go @@ -0,0 +1,182 @@ +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 导航项。", + }) +} diff --git a/admin-server/gateway_import_test.go b/admin-server/gateway_import_test.go new file mode 100644 index 0000000..4722ced --- /dev/null +++ b/admin-server/gateway_import_test.go @@ -0,0 +1,147 @@ +package main + +import ( + "os" + "strings" + "testing" +) + +const sampleConf = `map $host $backend { + hostnames; + default ""; + gitea.example.com http://127.0.0.1:3000; + login.example.com http://127.0.0.1:4001; + dashy.example.com http://127.0.0.1:3003; + modulerouter.example.com http://10.0.0.11:8081; + } + server { + listen 3080 ssl; + server_name example.com www.example.com; + root /usr/share/nginx/html; + error_page 404 /404.html; + } + server { + listen 3080 ssl; + server_name gitea.example.com; + location / { + sub_filter '' 'x'; + proxy_pass $backend; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; + proxy_ssl_verify off; + } + } + server { + listen 3080 ssl; + server_name login.example.com; + location / { proxy_pass $backend; } + } + server { + listen 3080 ssl; + server_name modulerouter.example.com dify.example.com; + location / { + postpone_output 0; proxy_buffering off; + proxy_pass $backend; + } + } + server { + listen 3080 ssl; + server_name dashy.example.com; + auth_request /_auth; + error_page 401 = @login; + location @login { return 302 https://login.example.com/login; } + location /search { auth_request off; proxy_pass $backend; } + location ~ ^/(api|autocompleter|static)/ { auth_request off; proxy_pass $backend; } + location / { proxy_pass $backend; } + } + server { + listen 3080 ssl default_server; + server_name _; + root /usr/share/nginx/html; + location / { return 404; } + } +` + +func TestParseNginxConf(t *testing.T) { + routes, err := parseNginxConf(sampleConf) + if err != nil { + t.Fatalf("解析失败: %v", err) + } + byHost := map[string]Route{} + for _, r := range routes { + byHost[r.Host] = r + } + if _, ok := byHost["_"]; ok { + t.Fatalf("不应把默认段当成服务") + } + d, ok := byHost["dashy.example.com"] + if !ok || !d.RequireLogin || d.Upstream != "http://127.0.0.1:3003" { + t.Fatalf("dashy 解析不对: %+v", d) + } + if len(d.BypassPaths) == 0 { + t.Fatalf("dashy 应解析出免认证路径: %+v", d.BypassPaths) + } + g := byHost["gitea.example.com"] + if g.RequireLogin || !g.InjectBeian || !g.InsecureTLS || !g.Websocket { + t.Fatalf("gitea 解析不对: %+v", g) + } + m := byHost["modulerouter.example.com"] + if m.RequireLogin || !m.Streaming { + t.Fatalf("modulerouter 应解析为免认证 + 流式: %+v", m) + } + if _, ok := byHost["dify.example.com"]; !ok { + t.Fatalf("同一段里多个域名都要生成路由") + } + if l := byHost["login.example.com"]; l.Kind != "auth" || l.Upstream != "" { + t.Fatalf("login 应为内置 auth: %+v", l) + } + if p := byHost["example.com"]; p.Kind != "portal" { + t.Fatalf("静态门户段应为 portal: %+v", p) + } + for _, r := range routes { + if r.NavEnabled { + t.Fatalf("导入不应默认接管导航: %+v", r) + } + } +} + +func TestParseNginxConfRejectsGarbage(t *testing.T) { + if _, err := parseNginxConf(" "); err == nil { + t.Fatalf("空内容应报错") + } + if _, err := parseNginxConf("hello world"); err == nil { + t.Fatalf("没有任何 server_name 应报错") + } +} + +func TestPortalAuthStore(t *testing.T) { + dir := t.TempDir() + oldConf := confFile + confFile = dir + "/conf.yml" + defer func() { confFile = oldConf }() + + if err := savePortalAuthFile("jianf", "s3cret-pass"); err != nil { + t.Fatalf("保存失败: %v", err) + } + raw, err := os.ReadFile(portalAuthPath()) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "s3cret-pass") { + t.Fatalf("不得写入明文口令: %s", raw) + } + if !checkPortalCredentials("jianf", "s3cret-pass") { + t.Fatalf("正确口令应通过") + } + if checkPortalCredentials("jianf", "wrong") || checkPortalCredentials("other", "s3cret-pass") { + t.Fatalf("错误口令/用户名不得通过") + } + if err := savePortalAuthFile("admin2", "another-pass"); err != nil { + t.Fatal(err) + } + if checkPortalCredentials("jianf", "s3cret-pass") || !checkPortalCredentials("admin2", "another-pass") { + t.Fatalf("改账号后校验未按新值") + } + user, source := portalAuthConfigured() + if user != "admin2" || !strings.Contains(source, "数据卷") { + t.Fatalf("配置来源不对: %s %s", user, source) + } +} diff --git a/admin-server/gateway_test.go b/admin-server/gateway_test.go index b75a40c..dc9ca6b 100644 --- a/admin-server/gateway_test.go +++ b/admin-server/gateway_test.go @@ -298,3 +298,19 @@ func TestSyncGatewayNavigationMissingSection(t *testing.T) { t.Fatalf("分组不存在时应报错") } } + +// 登录域名必须指向容器内自带的登录服务,不能生成空的 proxy_pass。 +func TestRenderGatewayConfAuthKind(t *testing.T) { + out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 8081, Routes: []Route{ + {Host: "login.example.com", Kind: "auth"}, + }}) + if err != nil { + t.Fatalf("auth 类型不应要求后端: %v", err) + } + if !contains(out, "proxy_pass http://127.0.0.1:3005;") { + t.Fatalf("auth 类型应指向内置登录服务:\n%s", out) + } + if contains(out, "proxy_pass ;") { + t.Fatalf("生成了空的 proxy_pass:\n%s", out) + } +} diff --git a/admin-server/main.go b/admin-server/main.go index 54560d4..1699179 100644 --- a/admin-server/main.go +++ b/admin-server/main.go @@ -1184,6 +1184,10 @@ func main() { http.HandleFunc("/api/frameable", cors(handleFrameable)) http.HandleFunc("/api/gateway/routes", cors(handleGatewayRoutes)) http.HandleFunc("/api/gateway/apply", cors(handleGatewayApply)) + // 从现有 nginx 配置导入路由(只解析,不写盘) + http.HandleFunc("/api/gateway/import", cors(handleGatewayImport)) + // 统一登录的账号设置(存数据卷,改完立即生效,不用重新部署) + http.HandleFunc("/api/portal-auth", cors(handlePortalAuthSettings)) // 历史记录管理:列表 / 清空全部 / 删除单条 http.HandleFunc("/api/history", cors(func(w http.ResponseWriter, r *http.Request) { switch r.Method { diff --git a/admin-server/portal_auth.go b/admin-server/portal_auth.go new file mode 100644 index 0000000..de8a1ac --- /dev/null +++ b/admin-server/portal_auth.go @@ -0,0 +1,133 @@ +package main + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" +) + +// ── 统一登录的账号设置 ── +// +// 原先账号只能靠容器环境变量(PORTAL_USERNAME/PORTAL_PASSWORD)配置, +// 改一次就要重新部署。这里把它落到**数据卷**里,后台可以直接改: +// 优先级 = 数据卷里的设置 > 环境变量 > 默认值。 +// +// 只存口令的加盐哈希,不存明文;加盐值随文件一起保存。 +type portalAuthFile struct { + Username string `json:"username"` + Salt string `json:"salt"` + Hash string `json:"hash"` +} + +func portalAuthPath() string { + return filepath.Join(filepath.Dir(confFile), "portal-auth.json") +} + +func hashPassword(salt, password string) string { + sum := sha256.Sum256([]byte(salt + "\x00" + password)) + return hex.EncodeToString(sum[:]) +} + +func loadPortalAuthFile() (*portalAuthFile, error) { + b, err := os.ReadFile(portalAuthPath()) + if err != nil { + return nil, err + } + var f portalAuthFile + if err := json.Unmarshal(b, &f); err != nil { + return nil, err + } + return &f, nil +} + +func savePortalAuthFile(user, password string) error { + old, _ := loadPortalAuthFile() + salt := "" + if old != nil && old.Salt != "" { + salt = old.Salt + } else { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + return err + } + salt = hex.EncodeToString(buf) + } + f := portalAuthFile{Username: strings.TrimSpace(user), Salt: salt, Hash: hashPassword(salt, password)} + data, err := json.MarshalIndent(f, "", " ") + if err != nil { + return err + } + path := portalAuthPath() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return err + } + return os.Rename(tmp, path) +} + +// checkPortalCredentials 先看数据卷设置,再回落到环境变量/默认值。 +// 返回值表示是否通过。 +func checkPortalCredentials(user, password string) bool { + if f, err := loadPortalAuthFile(); err == nil && f.Username != "" && f.Hash != "" { + if !secureEqual(strings.TrimSpace(user), f.Username) { + return false + } + want := f.Hash + got := hashPassword(f.Salt, password) + return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 + } + return secureEqual(user, portalUsername) && secureEqual(password, portalPassword) +} + +// portalAuthConfigured 报告口令来自数据卷还是环境变量,供界面提示。 +func portalAuthConfigured() (user, source string) { + if f, err := loadPortalAuthFile(); err == nil && f.Username != "" { + return f.Username, "数据卷(可在本页修改)" + } + return portalUsername, "环境变量 PORTAL_USERNAME" +} + +// handlePortalAuthSettings:GET 返回当前用户名来源;POST 修改账号口令。 +func handlePortalAuthSettings(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + user, source := portalAuthConfigured() + jsonResp(w, map[string]any{"username": user, "source": source}) + case http.MethodPost: + if !requireAdminToken(w, r) { + return + } + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonErr(w, 400, "请求体解析失败") + return + } + if strings.TrimSpace(req.Username) == "" { + jsonErr(w, 400, "用户名不能为空") + return + } + if len(req.Password) < 6 { + jsonErr(w, 400, "密码至少 6 位") + return + } + if err := savePortalAuthFile(req.Username, req.Password); err != nil { + jsonErr(w, 500, "保存失败:"+err.Error()) + return + } + jsonResp(w, map[string]string{"status": "saved", "username": strings.TrimSpace(req.Username)}) + default: + jsonErr(w, 405, "method not allowed") + } +} diff --git a/admin/index.html b/admin/index.html index adfe23c..d671c97 100644 --- a/admin/index.html +++ b/admin/index.html @@ -88,6 +88,9 @@ details summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--tx2)} .gw-mb{flex:0 0 110px;min-width:0} .gw-ck{display:flex;align-items:center;gap:5px;font-size:12.5px;color:var(--tx2);white-space:nowrap} .gw-kind,.gw-sec{flex:0 0 118px;min-width:0} +.grp{margin:26px 0 10px;font-size:12px;font-weight:800;letter-spacing:.08em;color:var(--tx3);text-transform:uppercase} +.grp-in{margin:16px 0 8px;font-size:12.5px;font-weight:700;color:var(--tx2);padding-top:12px;border-top:1px dashed var(--bd)} +#gw-import{min-height:110px;width:100%;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px} .gw-navname{flex:1 1 130px;min-width:0} .gw-navdesc{flex:1 1 150px;min-width:0} .gw-navicon{flex:1 1 150px;min-width:0} @@ -121,6 +124,8 @@ details summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--tx2)}
站点信息、顶部导航与各分组服务。图标支持 png / svg / jpg,选择 .ico 会自动转成 png。
+上传一张壁纸作为导航页背景。上面会铺一层浅色遮罩, + 保证玻璃卡片上的文字仍然清晰(下面可调强度)。
+加载中…
- 在这里维护「域名 → 内网服务」,并逐条决定是否走统一登录认证。 - 生成物默认监听旁路端口,验证无误后再接管正式入口(80/443)。 + 一条记录 = 一个域名 → 内网后端。是否走统一登录、是否加入 Dashy 导航 + 都是这条记录上的开关;勾选导航后名称/描述/图标(或自动抓取)也在同一处配置。
把旧统一入口的 nginx 配置整段贴进来,点「解析」会在下面列出识别出的域名与后端。 + 解析只读,不会写盘;确认后点「保存并应用」。导入默认不接管导航,需要接管的条目自己勾上。
+ +上传一张壁纸作为导航页背景。上面会铺一层浅色遮罩, - 保证玻璃卡片上的文字仍然清晰(下面可调强度)。
-+ 受保护域名登录时使用。账号存在数据卷里,改完立即生效,不用重新部署。 + 当前来源:读取中… +
+