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

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

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

View File

@ -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)}
<h1>导航管理</h1>
<p class="sub">站点信息、顶部导航与各分组服务。图标支持 png / svg / jpg,选择 .ico 会自动转成 png。</p>
<div class="grp">站点与外观</div>
<!-- 站点信息 -->
<div class="card">
<h2>站点信息</h2>
@ -144,6 +149,33 @@ details summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--tx2)}
<div style="margin-top:14px"><button class="primary" id="save-info">保存站点信息</button></div>
</div>
<!-- 外观 -->
<div class="card">
<h2>外观</h2>
<p class="hint">上传一张壁纸作为导航页背景。上面会铺一层浅色遮罩,
保证玻璃卡片上的文字仍然清晰(下面可调强度)。</p>
<div class="row" style="margin-top:10px">
<div class="fg"><label>壁纸地址(也可直接填 URL)</label>
<input id="wp-url" placeholder="/wallpapers/wall-xxx.jpg 或 https://..."></div>
<div class="fg" style="max-width:170px;justify-content:flex-end">
<label>&nbsp;</label>
<button id="pick-wall">上传壁纸</button>
<input type="file" id="file-wall" accept="image/*" hidden>
</div>
</div>
<div class="fg" style="max-width:340px">
<label>遮罩强度 <span id="dim-val">0.45</span></label>
<input type="range" id="wp-dim" min="0" max="0.9" step="0.05" value="0.45" style="padding:0">
</div>
<div class="row" style="margin-top:12px">
<button class="primary" id="apply-wall">应用</button>
<button id="clear-wall">移除壁纸</button>
</div>
<div id="wall-preview" style="margin-top:12px"></div>
</div>
<div class="grp">历史记录</div>
<!-- 历史访问 -->
<div class="card">
<h2>历史访问</h2>
@ -152,6 +184,8 @@ details summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--tx2)}
<div style="margin-top:14px"><button class="ghost" id="clear-hist">清空全部历史</button></div>
</div>
<div class="grp">导航内容</div>
<!-- 导航分组 -->
<div class="card">
<h2>导航分组</h2>
@ -196,39 +230,77 @@ details summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--tx2)}
<!-- 分组 -->
<div id="app"><div class="card"><p class="hint">加载中…</p></div></div>
<!-- 统一入口:反代 + 认证 -->
<!-- ===== 转发与统一登录 ===== -->
<div class="grp">转发与统一登录</div>
<div class="card">
<h2>统一入口(反向代理与认证)</h2>
<h2>服务转发</h2>
<p class="sub" style="margin:0 0 12px">
在这里维护「域名 → 内网服务」,并逐条决定是否走<b>统一登录认证</b>。
生成物默认监听旁路端口,验证无误后再接管正式入口(80/443)。
一条记录 = 一个域名 → 内网后端。<b>是否走统一登录</b>、<b>是否加入 Dashy 导航</b>
都是这条记录上的开关;勾选导航后名称/描述/图标(或自动抓取)也在同一处配置。
</p>
<div id="gw-routes"><div class="muted">读取中…</div></div>
<div class="row" style="margin-top:14px">
<div class="grp-in">添加转发</div>
<div class="row">
<div class="fg"><label>域名</label><input id="gw-n-host" placeholder="git.example.com"></div>
<div class="fg"><label>后端</label><input id="gw-n-up" placeholder="http://10.0.0.10:3000"></div>
<div class="fg" style="max-width:170px;justify-content:flex-end">
<label>&nbsp;</label><button class="primary" id="gw-add">添加映射</button>
<div class="fg" style="max-width:170px"><label>类型</label>
<select id="gw-n-kind">
<option value="proxy">反向代理</option>
<option value="portal">门户静态页</option>
<option value="auth">登录服务</option>
</select>
</div>
</div>
<div class="row">
<label class="gw-ck"><input type="checkbox" id="gw-n-auth" checked>启用 portal 统一登录保护</label>
<label class="gw-ck"><input type="checkbox" id="gw-n-ws">WebSocket 透传</label>
<input class="gw-mb" id="gw-n-mb" placeholder="上传MB上限">
</div>
<div class="row">
<label class="gw-ck"><input type="checkbox" id="gw-n-nav" checked>同时加入 Dashy 导航</label>
<div class="fg" style="max-width:180px"><label>导航分组</label><select id="gw-n-sec"></select></div>
<div class="fg" style="max-width:180px"><label>导航名称</label><input id="gw-n-name" placeholder="Gitea"></div>
<div class="fg" style="max-width:180px"><label>导航描述</label><input id="gw-n-desc" placeholder="代码仓库"></div>
</div>
<div class="row">
<div class="fg"><label>图标(文件名 / fas fa-*,留空则自动抓取)</label><input id="gw-n-icon" placeholder="gitea.png"></div>
<label class="gw-ck"><input type="checkbox" id="gw-n-auto" checked>自动抓取站点图标</label>
<div class="fg" style="max-width:170px;justify-content:flex-end">
<label>&nbsp;</label><button class="primary" id="gw-add">添加转发</button>
</div>
</div>
<details style="margin-top:12px">
<summary>从现有 nginx 配置导入(迁移用)</summary>
<p class="hint" style="margin:8px 0">把旧统一入口的 nginx 配置整段贴进来,点「解析」会在下面列出识别出的域名与后端。
解析只读,不会写盘;确认后点「保存并应用」。导入默认<b>不</b>接管导航,需要接管的条目自己勾上。</p>
<textarea id="gw-import" placeholder="map $host $backend { ... } server { server_name ... }"></textarea>
<div class="row" style="margin-top:8px">
<button id="gw-import-run">解析</button>
<span class="hint" id="gw-import-msg"></span>
</div>
</details>
<details style="margin-top:10px">
<summary>入口参数(端口 / 证书 / 认证地址)</summary>
<div class="row" style="margin-top:10px">
<div class="fg" style="max-width:150px"><label>HTTP 端口</label><input id="gw-port" inputmode="numeric"></div>
<div class="fg" style="max-width:150px"><label>HTTP(S) 端口</label><input id="gw-port" inputmode="numeric"></div>
<div class="fg" style="max-width:150px"><label>HTTPS 端口</label><input id="gw-tlsport" inputmode="numeric"></div>
<div class="fg" style="max-width:150px"><label>终结 TLS</label>
<label class="gw-ck" style="padding:9px 0"><input type="checkbox" id="gw-tls"> 本容器持有证书</label>
</div>
<div class="fg" style="max-width:150px"><label>HTTP 跳转端口</label><input id="gw-redirect" inputmode="numeric"></div>
</div>
<div class="row">
<div class="fg"><label>证书路径</label><input id="gw-cert" placeholder="/etc/nginx/certs/fullchain.pem"></div>
<div class="fg"><label>私钥路径</label><input id="gw-key" placeholder="/etc/nginx/certs/privkey.pem"></div>
<label class="gw-ck"><input type="checkbox" id="gw-tls">由本容器终结 TLS</label>
</div>
<div class="row">
<div class="fg"><label>认证校验地址(auth_request)</label><input id="gw-auth" placeholder="http://127.0.0.1:4001/_auth"></div>
<div class="fg"><label>认证校验地址</label><input id="gw-auth" placeholder="http://127.0.0.1:3005/auth/verify"></div>
<div class="fg"><label>未登录跳转</label><input id="gw-login" placeholder="https://login.example.com/login"></div>
</div>
</details>
<div class="row" style="margin-top:14px">
<button class="primary" id="gw-apply">保存并应用</button>
<button id="gw-saveonly">仅保存</button>
@ -236,31 +308,23 @@ details summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--tx2)}
</div>
</div>
<!-- 外观 -->
<div class="card">
<h2>外观</h2>
<p class="hint">上传一张壁纸作为导航页背景。上面会铺一层浅色遮罩,
保证玻璃卡片上的文字仍然清晰(下面可调强度)。</p>
<div class="row" style="margin-top:10px">
<div class="fg"><label>壁纸地址(也可直接填 URL)</label>
<input id="wp-url" placeholder="/wallpapers/wall-xxx.jpg 或 https://..."></div>
<h2>统一登录账号</h2>
<p class="sub" style="margin:0 0 12px">
受保护域名登录时使用。账号存在数据卷里,改完立即生效,不用重新部署。
当前来源:<b id="pa-source">读取中…</b>
</p>
<div class="row">
<div class="fg" style="max-width:220px"><label>用户名</label><input id="pa-user" autocomplete="off"></div>
<div class="fg" style="max-width:260px"><label>新密码(至少 6 位)</label><input id="pa-pass" type="password" autocomplete="new-password" placeholder="留空则不修改"></div>
<div class="fg" style="max-width:170px;justify-content:flex-end">
<label>&nbsp;</label>
<button id="pick-wall">上传壁纸</button>
<input type="file" id="file-wall" accept="image/*" hidden>
<label>&nbsp;</label><button class="primary" id="pa-save">保存账号</button>
</div>
</div>
<div class="fg" style="max-width:340px">
<label>遮罩强度 <span id="dim-val">0.45</span></label>
<input type="range" id="wp-dim" min="0" max="0.9" step="0.05" value="0.45" style="padding:0">
</div>
<div class="row" style="margin-top:12px">
<button class="primary" id="apply-wall">应用</button>
<button id="clear-wall">移除壁纸</button>
</div>
<div id="wall-preview" style="margin-top:12px"></div>
</div>
<div class="grp">高级</div>
<!-- 令牌 -->
<div class="card">
<details>
@ -793,6 +857,7 @@ function collectGateway() {
});
GW.listenPort = parseInt(gwVal('gw-port'), 10) || 0;
GW.listenTlsPort = parseInt(gwVal('gw-tlsport'), 10) || 0;
GW.redirectPort = parseInt(gwVal('gw-redirect'), 10) || 0;
GW.listenHttps = gwChk('gw-tls');
GW.certFile = gwVal('gw-cert');
GW.keyFile = gwVal('gw-key');
@ -841,6 +906,7 @@ function renderGateway() {
gwSet('gw-port', GW.listenPort);
gwSet('gw-tlsport', GW.listenTlsPort);
gwSet('gw-redirect', GW.redirectPort);
gwSet('gw-cert', GW.certFile);
gwSet('gw-key', GW.keyFile);
gwSet('gw-auth', GW.authUrl);
@ -873,20 +939,87 @@ async function gatewaySave() {
document.getElementById('gw-add').addEventListener('click', () => {
const host = gwVal('gw-n-host');
const up = gwVal('gw-n-up');
if (!host || !up) { toast('请填写域名和后端', '#ef4444'); return; }
const kind = gwVal('gw-n-kind') || 'proxy';
if (!host) { toast('请填写域名', '#ef4444'); return; }
if (kind === 'proxy' && !up) { toast('反向代理需要填写后端地址', '#ef4444'); return; }
collectGateway();
const firstSection = (S || []).map(s => String(s.name || '')).find(n => n && n !== '历史访问') || '';
const navEnabled = gwChk('gw-n-nav');
const mb = parseInt(gwVal('gw-n-mb'), 10);
GW.routes.push({
host: host, upstream: up, requireLogin: true, websocket: false, maxBodyMB: 0, kind: 'proxy',
navEnabled: !!firstSection, navSection: firstSection, navName: host.split('.')[0],
navDescription: '', navIcon: '', navAutoIcon: true,
host: host,
upstream: kind === 'portal' ? '' : up,
kind: kind,
requireLogin: gwChk('gw-n-auth'),
websocket: gwChk('gw-n-ws'),
maxBodyMB: Number.isFinite(mb) && mb > 0 ? mb : 0,
navEnabled: navEnabled,
navSection: navEnabled ? gwVal('gw-n-sec') : '',
navName: navEnabled ? (gwVal('gw-n-name') || host.split('.')[0]) : '',
navDescription: navEnabled ? gwVal('gw-n-desc') : '',
navIcon: navEnabled ? gwVal('gw-n-icon') : '',
navAutoIcon: navEnabled && gwChk('gw-n-auto'),
});
['gw-n-host', 'gw-n-up', 'gw-n-name', 'gw-n-desc', 'gw-n-icon', 'gw-n-mb'].forEach(id => {
const el = document.getElementById(id); if (el) el.value = '';
});
document.getElementById('gw-n-host').value = '';
document.getElementById('gw-n-up').value = '';
renderGateway();
toast('已加入列表,记得点「保存并应用」');
});
document.getElementById('gw-import-run').addEventListener('click', async () => {
const box = document.getElementById('gw-import');
const msg = document.getElementById('gw-import-msg');
const conf = (box && box.value || '').trim();
if (!conf) { toast('请先贴上 nginx 配置', '#ef4444'); return; }
if (msg) msg.textContent = '解析中…';
const r = await fetch(A + '/gateway/import', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ conf: conf }),
});
const text = await r.text();
if (!r.ok) {
if (msg) msg.innerHTML = '<span style="color:#dc2626;white-space:pre-wrap">' + esc(text.slice(0, 300)) + '</span>';
return;
}
const data = JSON.parse(text);
collectGateway();
// 导入结果覆盖当前列表:这是迁移,不是追加(避免同一域名两条记录)
GW.routes = data.routes || [];
renderGateway();
if (msg) msg.textContent = data.hint || ('已解析 ' + GW.routes.length + ' 条');
toast('已解析 ' + GW.routes.length + ' 个域名,确认后点「保存并应用」');
});
/* ── 统一登录账号 ── */
async function loadPortalAuth() {
try {
const r = await fetch(A + '/portal-auth', { headers: authHeaders() });
if (!r.ok) return;
const d = await r.json();
const u = document.getElementById('pa-user');
const src = document.getElementById('pa-source');
if (u && !u.value) u.value = d.username || '';
if (src) src.textContent = d.source || '';
} catch (e) { /* 忽略 */ }
}
document.getElementById('pa-save').addEventListener('click', async () => {
const user = (document.getElementById('pa-user').value || '').trim();
const pass = document.getElementById('pa-pass').value || '';
if (!user) { toast('请填写用户名', '#ef4444'); return; }
if (pass.length < 6) { toast('密码至少 6 位', '#ef4444'); return; }
const r = await fetch(A + '/portal-auth', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ username: user, password: pass }),
});
if (!r.ok) { toast('保存失败: ' + (await r.text()).slice(0, 140), '#ef4444'); return; }
document.getElementById('pa-pass').value = '';
toast('账号已更新(下次登录生效)');
loadPortalAuth();
});
document.getElementById('gw-saveonly').addEventListener('click', async () => {
if (await gatewaySave()) toast('已保存(未应用)');
});
@ -913,6 +1046,7 @@ document.getElementById('gw-apply').addEventListener('click', async () => {
await loadWallpaper();
await loadHistory();
await loadGateway();
await loadPortalAuth();
})();
</script>
</body>

View File

@ -73,6 +73,10 @@ COPY web/navi.js /app/dashy/navi.js
COPY web/navi.css /app/dashy/navi.css
COPY web/patch-index.py /tmp/patch-index.py
RUN python3 /tmp/patch-index.py /app/dashy/index.html
# 基础镜像里的 HEALTHCHECK 还指向旧的 8080;这里必须覆盖,
# 否则容器会一直显示 unhealthy(功能正常但健康信号是假的)。
HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \
CMD curl -fs http://localhost:18080/healthz || exit 1
EOF
DOCKER_BUILDKIT=0 docker build -q -t "$TAG" "$CTX"
docker save "$TAG" | gzip -1 > "$TAR"