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 类型生成。
This commit is contained in:
HomeDash Bot
2026-09-11 22:00:09 +08:00
parent 994d93cc98
commit 53beff646a
10 changed files with 671 additions and 43 deletions

View File

@ -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

View File

@ -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)

View File

@ -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 导航项。",
})
}

View File

@ -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 '</footer>' '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)
}
}

View File

@ -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)
}
}

View File

@ -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 {

133
admin-server/portal_auth.go Normal file
View File

@ -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")
}
}