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:
@ -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"]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
182
admin-server/gateway_import.go
Normal file
182
admin-server/gateway_import.go
Normal 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 导航项。",
|
||||
})
|
||||
}
|
||||
147
admin-server/gateway_import_test.go
Normal file
147
admin-server/gateway_import_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
133
admin-server/portal_auth.go
Normal 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")
|
||||
}
|
||||
}
|
||||
210
admin/index.html
210
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)}
|
||||
<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> </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> </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> </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> </label>
|
||||
<button id="pick-wall">上传壁纸</button>
|
||||
<input type="file" id="file-wall" accept="image/*" hidden>
|
||||
<label> </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>
|
||||
|
||||
@ -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"
|
||||
|
||||
Reference in New Issue
Block a user