针对反馈的五个问题: 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 类型生成。
134 lines
3.7 KiB
Go
134 lines
3.7 KiB
Go
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")
|
||
}
|
||
}
|