diff --git a/Dockerfile b/Dockerfile
index 8f4098d..2faa32b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -82,6 +82,7 @@ COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY searxng-settings.yml /etc/searxng/settings.yml
COPY conf.example.yml /app/defaults/conf.yml
COPY admin/index.html /app/admin/index.html
+COPY portal /app/portal
# 浏览器扩展安装页与安装包(通过 /extension/ 访问)
COPY web/extension/install.html /app/dashy/extension/install.html
@@ -96,10 +97,10 @@ COPY web/patch-index.py /tmp/patch-index.py
RUN python3 /tmp/patch-index.py /app/dashy/index.html
# Data directory
-RUN mkdir -p /app/data/item-icons /var/log/supervisor /var/cache/searxng /run/nginx
+RUN mkdir -p /app/data/item-icons /app/data/gateway /var/log/supervisor /var/cache/searxng /run/nginx
VOLUME ["/app/data"]
-EXPOSE 8080
+EXPOSE 18080 3080 3081 8443
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
- CMD curl -fs http://localhost:8080/conf.yml || exit 1
+ CMD curl -fs http://localhost:18080/conf.yml || 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
new file mode 100644
index 0000000..0493190
--- /dev/null
+++ b/admin-server/auth.go
@@ -0,0 +1,216 @@
+package main
+
+import (
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/subtle"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+)
+
+const portalCookieName = "portal_session"
+
+var (
+ portalUsername = envOr("PORTAL_USERNAME", "admin")
+ portalPassword = envOr("PORTAL_PASSWORD", "changeme")
+ portalCookieDomain = envOr("PORTAL_COOKIE_DOMAIN", ".example.com")
+ portalHomeURL = envOr("PORTAL_HOME_URL", "https://dashy.example.com")
+ portalSessionTTL = 24 * time.Hour
+ portalSecretOnce sync.Once
+ portalSecret []byte
+)
+
+type portalClaims struct {
+ User string `json:"u"`
+ Expiry int64 `json:"e"`
+}
+
+func sessionSecret() []byte {
+ portalSecretOnce.Do(func() {
+ if raw := strings.TrimSpace(os.Getenv("PORTAL_SESSION_SECRET")); raw != "" {
+ sum := sha256.Sum256([]byte(raw))
+ portalSecret = sum[:]
+ return
+ }
+ path := filepath.Join(filepath.Dir(confFile), "portal-session.key")
+ if b, err := os.ReadFile(path); err == nil {
+ if decoded, err := hex.DecodeString(strings.TrimSpace(string(b))); err == nil && len(decoded) >= 32 {
+ portalSecret = decoded
+ return
+ }
+ }
+ portalSecret = make([]byte, 32)
+ if _, err := rand.Read(portalSecret); err != nil {
+ sum := sha256.Sum256([]byte(fmt.Sprintf("%d-%d", time.Now().UnixNano(), os.Getpid())))
+ portalSecret = sum[:]
+ }
+ _ = os.MkdirAll(filepath.Dir(path), 0o755)
+ _ = os.WriteFile(path, []byte(hex.EncodeToString(portalSecret)), 0o600)
+ })
+ return portalSecret
+}
+
+func signClaims(c portalClaims) (string, error) {
+ payload, err := json.Marshal(c)
+ if err != nil {
+ return "", err
+ }
+ body := base64.RawURLEncoding.EncodeToString(payload)
+ mac := hmac.New(sha256.New, sessionSecret())
+ _, _ = mac.Write([]byte(body))
+ sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
+ return body + "." + sig, nil
+}
+
+func verifyClaims(token string) bool {
+ parts := strings.Split(token, ".")
+ if len(parts) != 2 {
+ return false
+ }
+ mac := hmac.New(sha256.New, sessionSecret())
+ _, _ = mac.Write([]byte(parts[0]))
+ got, err := base64.RawURLEncoding.DecodeString(parts[1])
+ if err != nil || !hmac.Equal(got, mac.Sum(nil)) {
+ return false
+ }
+ payload, err := base64.RawURLEncoding.DecodeString(parts[0])
+ if err != nil {
+ return false
+ }
+ var c portalClaims
+ if json.Unmarshal(payload, &c) != nil {
+ return false
+ }
+ return c.User == portalUsername && c.Expiry >= time.Now().Unix()
+}
+
+func portalAuthenticated(r *http.Request) bool {
+ c, err := r.Cookie(portalCookieName)
+ return err == nil && verifyClaims(c.Value)
+}
+
+func setPortalCookie(w http.ResponseWriter, token string, maxAge int) {
+ http.SetCookie(w, &http.Cookie{
+ Name: portalCookieName, Value: token, Path: "/", Domain: portalCookieDomain,
+ MaxAge: maxAge, Expires: time.Now().Add(time.Duration(maxAge) * time.Second),
+ HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
+ })
+}
+
+func secureEqual(a, b string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
+}
+
+func safePortalRedirect(raw string) string {
+ if strings.TrimSpace(raw) == "" {
+ return portalHomeURL
+ }
+ u, err := url.Parse(raw)
+ if err != nil || u.Scheme != "https" || u.Hostname() == "" {
+ return portalHomeURL
+ }
+ cfg, err := loadGateway()
+ if err != nil {
+ return portalHomeURL
+ }
+ for _, r := range cfg.Routes {
+ if strings.EqualFold(strings.TrimSpace(r.Host), u.Hostname()) {
+ return u.String()
+ }
+ }
+ return portalHomeURL
+}
+
+var portalLoginTemplate = template.Must(template.New("login").Parse(`
+
+统一门户登录JF
统一门户
登录后访问受保护的内部服务
{{if .Error}}{{.Error}}
{{end}}`))
+
+func renderPortalLogin(w http.ResponseWriter, redirect, errText string) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ _ = portalLoginTemplate.Execute(w, map[string]string{
+ "Redirect": url.QueryEscape(redirect), "Error": errText,
+ })
+}
+
+func handlePortalLogin(w http.ResponseWriter, r *http.Request) {
+ redirect := safePortalRedirect(r.URL.Query().Get("redirect"))
+ if r.Method == http.MethodGet {
+ if portalAuthenticated(r) {
+ http.Redirect(w, r, redirect, http.StatusFound)
+ return
+ }
+ renderPortalLogin(w, redirect, "")
+ return
+ }
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ if err := r.ParseForm(); err != nil {
+ renderPortalLogin(w, redirect, "请求解析失败")
+ return
+ }
+ if !secureEqual(r.FormValue("username"), portalUsername) || !secureEqual(r.FormValue("password"), portalPassword) {
+ w.WriteHeader(http.StatusUnauthorized)
+ renderPortalLogin(w, redirect, "用户名或密码错误")
+ return
+ }
+ token, err := signClaims(portalClaims{User: portalUsername, Expiry: time.Now().Add(portalSessionTTL).Unix()})
+ if err != nil {
+ http.Error(w, "session error", http.StatusInternalServerError)
+ return
+ }
+ setPortalCookie(w, token, int(portalSessionTTL/time.Second))
+ http.Redirect(w, r, redirect, http.StatusFound)
+}
+
+func handlePortalVerify(w http.ResponseWriter, r *http.Request) {
+ if !portalAuthenticated(r) {
+ w.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+ w.Header().Set("Content-Length", "0")
+ w.WriteHeader(http.StatusOK)
+}
+
+func handlePortalLogout(w http.ResponseWriter, r *http.Request) {
+ setPortalCookie(w, "", -1)
+ http.Redirect(w, r, "/login", http.StatusFound)
+}
+
+func handlePortalRoot(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" {
+ http.NotFound(w, r)
+ return
+ }
+ if portalAuthenticated(r) {
+ http.Redirect(w, r, portalHomeURL, http.StatusFound)
+ return
+ }
+ http.Redirect(w, r, "/login", http.StatusFound)
+}
+
+func registerPortalAuth() {
+ http.HandleFunc("/login", handlePortalLogin)
+ http.HandleFunc("/verify", handlePortalVerify)
+ http.HandleFunc("/_auth", handlePortalVerify)
+ http.HandleFunc("/logout", handlePortalLogout)
+ http.HandleFunc("/auth/verify", handlePortalVerify)
+}
diff --git a/admin-server/gateway.go b/admin-server/gateway.go
index 5cde081..d5c7de0 100644
--- a/admin-server/gateway.go
+++ b/admin-server/gateway.go
@@ -10,43 +10,43 @@ import (
"gopkg.in/yaml.v3"
)
-// ── 统一入口(gateway)路由表 ──
-//
-// 目标:把"哪个域名反代到哪个内网服务、要不要走统一登录认证"变成**后台可编辑的
-// 数据**,由本进程生成 nginx 配置并热加载,从而不再需要手工维护一份外部 nginx
-// 配置(也就避开了"单文件 bind mount 不能用 sed -i"那类坑)。
-//
-// 设计要点:
-// - 路由表单独存一个文件(不是 conf.yml):即使路由写坏了也不会影响导航页本身,
-// 而且 conf.yml 是 Dashy 的配置,语义上不该混进来。
-// - 生成物写在**容器自己的数据卷**里(include 目录),不碰宿主机文件。
-// - 应用配置的顺序永远是:生成 → nginx -t 校验 → 通过才替换并 reload;
-// 校验失败保留上一份可用配置并把 nginx 的报错回给调用方。
-
-// Route 一条域名→后端映射。
+// Route 同时描述一条反向代理及其在 Dashy 中的导航信息。
+// Kind: proxy(普通反代)、portal(内置门户静态页)、auth(内置登录服务)。
type Route struct {
- Host string `yaml:"host" json:"host"` // 例如 dashy.example.com
- Upstream string `yaml:"upstream" json:"upstream"` // 例如 http://10.0.0.11:8080
- RequireLogin bool `yaml:"requireLogin" json:"requireLogin"` // true=走统一认证;false=直接放行
- Websocket bool `yaml:"websocket" json:"websocket"` // 是否透传 Upgrade(面板/终端类)
- MaxBodyMB int `yaml:"maxBodyMB,omitempty" json:"maxBodyMB,omitempty"` // 上传体积上限,0=不限
- Note string `yaml:"note,omitempty" json:"note,omitempty"` // 备注,仅界面展示
+ Host string `yaml:"host" json:"host"`
+ Kind string `yaml:"kind,omitempty" json:"kind,omitempty"`
+ Upstream string `yaml:"upstream,omitempty" json:"upstream,omitempty"`
+ RequireLogin bool `yaml:"requireLogin" json:"requireLogin"`
+ Websocket bool `yaml:"websocket" json:"websocket"`
+ Streaming bool `yaml:"streaming,omitempty" json:"streaming,omitempty"`
+ InsecureTLS bool `yaml:"insecureTls,omitempty" json:"insecureTls,omitempty"`
+ MaxBodyMB int `yaml:"maxBodyMB,omitempty" json:"maxBodyMB,omitempty"`
+ BypassPaths []string `yaml:"bypassPaths,omitempty" json:"bypassPaths,omitempty"`
+ InjectBeian bool `yaml:"injectBeian,omitempty" json:"injectBeian,omitempty"`
+ Note string `yaml:"note,omitempty" json:"note,omitempty"`
+
+ NavEnabled bool `yaml:"navEnabled,omitempty" json:"navEnabled,omitempty"`
+ NavSection string `yaml:"navSection,omitempty" json:"navSection,omitempty"`
+ NavName string `yaml:"navName,omitempty" json:"navName,omitempty"`
+ NavDescription string `yaml:"navDescription,omitempty" json:"navDescription,omitempty"`
+ NavURL string `yaml:"navUrl,omitempty" json:"navUrl,omitempty"`
+ NavIcon string `yaml:"navIcon,omitempty" json:"navIcon,omitempty"`
+ NavAutoIcon bool `yaml:"navAutoIcon,omitempty" json:"navAutoIcon,omitempty"`
}
-// GatewayConfig 路由表 + 入口参数。
+// GatewayConfig 是统一入口的完整配置。ListenHTTPS=false 时 ListenPort 是普通
+// HTTP 旁路端口;开启后 ListenPort/ListenTLSPort 都是 TLS 端口,RedirectPort
+// 是 HTTP→HTTPS 入口。这样可复刻原 portal-nginx 的 3080/8443 + 3081 设计。
type GatewayConfig struct {
- // ListenHTTPS 是否由本容器终结 TLS(证书挂进来时开启;否则只监听 HTTP)
- ListenHTTPS bool `yaml:"listenHttps" json:"listenHttps"`
- // CertFile/KeyFile 在 ListenHTTPS 为真时使用
- CertFile string `yaml:"certFile,omitempty" json:"certFile,omitempty"`
- KeyFile string `yaml:"keyFile,omitempty" json:"keyFile,omitempty"`
- // AuthURL 统一认证的校验地址(auth_request)。例如 http://127.0.0.1:4001/_auth
- AuthURL string `yaml:"authUrl,omitempty" json:"authUrl,omitempty"`
- // LoginURL 未通过认证时的跳转地址
- LoginURL string `yaml:"loginUrl,omitempty" json:"loginUrl,omitempty"`
- // ListenHTTP/ListenHTTPSPort 监听端口,便于旁路验证(先跑 8081 再切 80)
+ ListenHTTPS bool `yaml:"listenHttps" json:"listenHttps"`
+ CertFile string `yaml:"certFile,omitempty" json:"certFile,omitempty"`
+ KeyFile string `yaml:"keyFile,omitempty" json:"keyFile,omitempty"`
+ AuthURL string `yaml:"authUrl,omitempty" json:"authUrl,omitempty"`
+ LoginURL string `yaml:"loginUrl,omitempty" json:"loginUrl,omitempty"`
+
ListenPort int `yaml:"listenPort,omitempty" json:"listenPort,omitempty"`
ListenTLSPort int `yaml:"listenTlsPort,omitempty" json:"listenTlsPort,omitempty"`
+ RedirectPort int `yaml:"redirectPort,omitempty" json:"redirectPort,omitempty"`
Routes []Route `yaml:"routes" json:"routes"`
}
@@ -54,7 +54,7 @@ func defaultGatewayConfig() *GatewayConfig {
return &GatewayConfig{
ListenPort: 8081,
ListenTLSPort: 8443,
- AuthURL: "http://127.0.0.1:4001/_auth",
+ AuthURL: "http://127.0.0.1:3005/auth/verify",
LoginURL: "https://login.example.com/login",
Routes: []Route{},
}
@@ -104,149 +104,223 @@ func saveGateway(cfg *GatewayConfig) error {
return os.Rename(tmp, gatewayFile)
}
-// ValidateRoute 只做语法层面的检查;连通性不在这里判断。
+func routeKind(r Route) string {
+ k := strings.ToLower(strings.TrimSpace(r.Kind))
+ if k == "" {
+ return "proxy"
+ }
+ return k
+}
+
func ValidateRoute(r Route) error {
host := strings.TrimSpace(r.Host)
if host == "" {
return fmt.Errorf("域名不能为空")
}
- if strings.ContainsAny(host, " \t/\\?#") || strings.Contains(host, "://") {
+ if strings.ContainsAny(host, " \t/\\?#;\"'") || strings.Contains(host, "://") {
return fmt.Errorf("域名格式不对: %q", r.Host)
}
- up := strings.TrimSpace(r.Upstream)
- if up == "" {
- return fmt.Errorf("%s: 后端地址不能为空", host)
+ kind := routeKind(r)
+ if kind != "proxy" && kind != "portal" && kind != "auth" {
+ return fmt.Errorf("%s: 未知服务类型 %q", host, r.Kind)
}
- if !strings.HasPrefix(up, "http://") && !strings.HasPrefix(up, "https://") {
- return fmt.Errorf("%s: 后端必须是 http:// 或 https:// 开头", host)
- }
- if strings.ContainsAny(up, " \t\"'") {
- return fmt.Errorf("%s: 后端地址含非法字符", host)
+ if kind != "portal" {
+ up := strings.TrimSpace(r.Upstream)
+ if up == "" {
+ return fmt.Errorf("%s: 后端地址不能为空", host)
+ }
+ if !strings.HasPrefix(up, "http://") && !strings.HasPrefix(up, "https://") {
+ return fmt.Errorf("%s: 后端必须是 http:// 或 https:// 开头", host)
+ }
+ if strings.ContainsAny(up, " \t\"'") {
+ return fmt.Errorf("%s: 后端地址含非法字符", host)
+ }
}
if r.MaxBodyMB < 0 {
return fmt.Errorf("%s: 上传上限不能为负", host)
}
+ for _, p := range r.BypassPaths {
+ if !strings.HasPrefix(p, "/") || strings.ContainsAny(p, " \t\"';{}") {
+ return fmt.Errorf("%s: 免认证路径格式不对: %q", host, p)
+ }
+ }
+ if r.NavEnabled {
+ if strings.TrimSpace(r.NavName) == "" {
+ return fmt.Errorf("%s: 已加入导航但名称为空", host)
+ }
+ if strings.TrimSpace(r.NavSection) == "" {
+ return fmt.Errorf("%s: 已加入导航但未选择分组", host)
+ }
+ if isHistorySectionName(r.NavSection) {
+ return fmt.Errorf("%s: 历史访问是系统分组,不能放手工导航", host)
+ }
+ }
return nil
}
-// nginxQuote 把值安全地放进单引号里(host/upstream 都已拒绝引号,这里是兜底)。
+func isHistorySectionName(name string) bool {
+ n := strings.ToLower(strings.TrimSpace(name))
+ return n == "历史访问" || n == "history" || n == "recent"
+}
+
func nginxQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "") + "'"
}
-// RenderGatewayConf 生成 include 片段的全部 server 块。
-//
-// 认证是按**每条路由**决定的:RequireLogin 为真才生成 auth_request,
-// 也就是说"哪些服务受统一认证保护"完全由后台的路由表决定。
+func writeTLS(b *strings.Builder, cfg *GatewayConfig) {
+ fmt.Fprintf(b, " ssl_certificate %s;\n ssl_certificate_key %s;\n", nginxQuote(cfg.CertFile), nginxQuote(cfg.KeyFile))
+ b.WriteString(" ssl_protocols TLSv1.2 TLSv1.3;\n http2 on;\n")
+}
+
+func writeListeners(b *strings.Builder, cfg *GatewayConfig, defaultServer bool) {
+ suffix := ""
+ if defaultServer {
+ suffix = " default_server"
+ }
+ if cfg.ListenHTTPS {
+ fmt.Fprintf(b, " listen %d ssl%s;\n", cfg.ListenPort, suffix)
+ if cfg.ListenTLSPort > 0 && cfg.ListenTLSPort != cfg.ListenPort {
+ fmt.Fprintf(b, " listen %d ssl%s;\n", cfg.ListenTLSPort, suffix)
+ }
+ return
+ }
+ fmt.Fprintf(b, " listen %d%s;\n", cfg.ListenPort, suffix)
+}
+
+func writeErrorPages(b *strings.Builder, include404 bool) {
+ b.WriteString(" root /app/portal;\n")
+ if include404 {
+ b.WriteString(" error_page 404 /404.html;\n location = /404.html { internal; }\n")
+ }
+ b.WriteString(" error_page 500 502 503 504 /500.html;\n location = /500.html { internal; }\n")
+ b.WriteString(" location ^~ /css/ { try_files $uri =404; }\n")
+ b.WriteString(" location = /favicon.svg { try_files $uri =404; }\n")
+ b.WriteString(" location = /favicon.ico { try_files $uri =404; }\n")
+}
+
+func writeProxyLocation(b *strings.Builder, r Route, path string, authOff bool) {
+ if path == "" {
+ b.WriteString(" location / {\n")
+ } else {
+ fmt.Fprintf(b, " location ^~ %s {\n", path)
+ }
+ if authOff {
+ b.WriteString(" auth_request off;\n")
+ }
+ fmt.Fprintf(b, " proxy_pass %s;\n", strings.TrimSpace(r.Upstream))
+ b.WriteString(" proxy_http_version 1.1;\n")
+ b.WriteString(" proxy_set_header Host $host;\n")
+ b.WriteString(" proxy_set_header X-Real-IP $remote_addr;\n")
+ b.WriteString(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n")
+ b.WriteString(" proxy_set_header X-Forwarded-Proto $scheme;\n")
+ if r.Websocket {
+ b.WriteString(" proxy_set_header Upgrade $http_upgrade;\n")
+ b.WriteString(" proxy_set_header Connection \"upgrade\";\n")
+ }
+ if r.Streaming {
+ b.WriteString(" postpone_output 0;\n tcp_nopush off;\n tcp_nodelay on;\n")
+ b.WriteString(" sendfile off;\n proxy_buffering off;\n proxy_request_buffering off;\n")
+ b.WriteString(" gzip off;\n chunked_transfer_encoding on;\n")
+ }
+ b.WriteString(" proxy_read_timeout 86400s;\n proxy_send_timeout 86400s;\n")
+ if r.InsecureTLS || strings.HasPrefix(strings.ToLower(r.Upstream), "https://") {
+ b.WriteString(" proxy_ssl_verify off;\n")
+ }
+ if r.InjectBeian {
+ b.WriteString(" sub_filter '' '';\n")
+ b.WriteString(" sub_filter_once off;\n")
+ b.WriteString(" proxy_set_header Accept-Encoding \"\";\n")
+ }
+ b.WriteString(" proxy_redirect off;\n }\n")
+}
+
+func writeRouteServer(b *strings.Builder, cfg *GatewayConfig, r Route) error {
+ host := strings.TrimSpace(r.Host)
+ b.WriteString("server {\n")
+ writeListeners(b, cfg, false)
+ fmt.Fprintf(b, " server_name %s;\n", host)
+ if cfg.ListenHTTPS {
+ writeTLS(b, cfg)
+ }
+ if r.MaxBodyMB > 0 {
+ fmt.Fprintf(b, " client_max_body_size %dm;\n", r.MaxBodyMB)
+ }
+ writeErrorPages(b, routeKind(r) == "portal")
+
+ if routeKind(r) == "portal" {
+ b.WriteString(" index index.html;\n location / { try_files $uri $uri/ /404.html; }\n}\n\n")
+ return nil
+ }
+ if r.RequireLogin {
+ if strings.TrimSpace(cfg.AuthURL) == "" || strings.TrimSpace(cfg.LoginURL) == "" {
+ return fmt.Errorf("%s: 需要认证但未配置认证/登录地址", host)
+ }
+ fmt.Fprintf(b, " location = /_auth { internal; proxy_pass %s; proxy_pass_request_body off;\n", cfg.AuthURL)
+ b.WriteString(" proxy_set_header Content-Length \"\";\n")
+ b.WriteString(" proxy_set_header Cookie $http_cookie;\n")
+ b.WriteString(" proxy_set_header X-Original-URI $request_uri;\n }\n")
+ b.WriteString(" auth_request /_auth;\n error_page 401 = @login;\n")
+ fmt.Fprintf(b, " location @login { return 302 %s?redirect=$scheme://$host$request_uri; }\n", cfg.LoginURL)
+ }
+ for _, p := range r.BypassPaths {
+ writeProxyLocation(b, r, p, true)
+ }
+ writeProxyLocation(b, r, "", false)
+ b.WriteString("}\n\n")
+ return nil
+}
+
+// RenderGatewayConf 生成完整统一入口配置。未知域名使用内置自定义 404 页面,
+// 反向代理故障使用自定义 500 页面;不会再返回一行纯文本。
func RenderGatewayConf(cfg *GatewayConfig) (string, error) {
if cfg == nil {
return "", fmt.Errorf("配置为空")
}
+ if cfg.ListenPort == 0 {
+ cfg.ListenPort = 8081
+ }
+ if cfg.ListenHTTPS && (cfg.CertFile == "" || cfg.KeyFile == "") {
+ return "", fmt.Errorf("启用 HTTPS 但未配置证书路径")
+ }
hosts := make(map[string]bool, len(cfg.Routes))
for _, r := range cfg.Routes {
if err := ValidateRoute(r); err != nil {
return "", err
}
- h := strings.TrimSpace(r.Host)
+ h := strings.ToLower(strings.TrimSpace(r.Host))
if hosts[h] {
return "", fmt.Errorf("域名重复: %s", h)
}
hosts[h] = true
}
- // 先按域名排序,生成结果稳定,便于 diff 与人工核对
routes := append([]Route(nil), cfg.Routes...)
- sort.Slice(routes, func(i, j int) bool { return routes[i].Host < routes[j].Host })
-
+ sort.SliceStable(routes, func(i, j int) bool { return routes[i].Host < routes[j].Host })
var b strings.Builder
- b.WriteString("# 由 HomeDash 后台生成,请勿手改:文件每次应用都会被整体覆盖。\n")
- b.WriteString("# 源: " + gatewayFile + "\n\n")
+ b.WriteString("# 由 HomeDash 后台生成,请勿手改。源: " + gatewayFile + "\n\n")
- port := cfg.ListenPort
- if port == 0 {
- port = 8081
+ if cfg.ListenHTTPS && cfg.RedirectPort > 0 {
+ fmt.Fprintf(&b, "server {\n listen %d default_server;\n server_name _;\n return 302 https://$host$request_uri;\n}\n\n", cfg.RedirectPort)
}
- // 兜底 server:没有匹配到任何 server_name 的请求必须当场拒绝。
- // 不加这一段,nginx 会把未知域名交给该端口上排序第一的 server 块——
- // 也就是"打错域名会访问到别的服务"。404 而不是 444,便于排查。
- fmt.Fprintf(&b, "server {\n listen %d default_server;\n server_name _;\n return 404 \"unknown host\\n\";\n}\n\n", port)
+
+ // 默认服务器:保留原统一代理的自定义错误页设计。
+ b.WriteString("server {\n")
+ writeListeners(&b, cfg, true)
+ b.WriteString(" server_name _;\n")
+ if cfg.ListenHTTPS {
+ writeTLS(&b, cfg)
+ }
+ writeErrorPages(&b, true)
+ b.WriteString(" location / { return 404; }\n}\n\n")
for _, r := range routes {
- host := strings.TrimSpace(r.Host)
- up := strings.TrimSpace(r.Upstream)
- fmt.Fprintf(&b, "server {\n listen %d;\n server_name %s;\n", port, host)
- if r.MaxBodyMB > 0 {
- fmt.Fprintf(&b, " client_max_body_size %dm;\n", r.MaxBodyMB)
- }
- if r.RequireLogin {
- if cfg.AuthURL == "" {
- return "", fmt.Errorf("%s: 需要认证但未配置认证地址", host)
- }
- fmt.Fprintf(&b, " # 该域名受统一认证保护\n")
- fmt.Fprintf(&b, " location = /_auth { internal; proxy_pass %s; proxy_pass_request_body off;\n", cfg.AuthURL)
- b.WriteString(" proxy_set_header Content-Length \"\";\n proxy_set_header X-Original-URI $request_uri;\n }\n")
- b.WriteString(" auth_request /_auth;\n")
- b.WriteString(" error_page 401 = @login;\n")
- fmt.Fprintf(&b, " location @login { return 302 %s?redirect=$scheme://$host$request_uri; }\n", cfg.LoginURL)
- } else {
- b.WriteString(" # 该域名不走统一认证\n")
- }
- b.WriteString(" location / {\n")
- fmt.Fprintf(&b, " proxy_pass %s;\n", up)
- b.WriteString(" proxy_http_version 1.1;\n")
- b.WriteString(" proxy_set_header Host $host;\n")
- b.WriteString(" proxy_set_header X-Real-IP $remote_addr;\n")
- b.WriteString(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n")
- b.WriteString(" proxy_set_header X-Forwarded-Proto $scheme;\n")
- if r.Websocket {
- b.WriteString(" proxy_set_header Upgrade $http_upgrade;\n")
- b.WriteString(" proxy_set_header Connection \"upgrade\";\n")
- b.WriteString(" proxy_read_timeout 3600s;\n")
- }
- b.WriteString(" proxy_redirect off;\n")
- b.WriteString(" }\n}\n\n")
- }
-
- if cfg.ListenHTTPS {
- if cfg.CertFile == "" || cfg.KeyFile == "" {
- return "", fmt.Errorf("启用 HTTPS 但未配置证书路径")
- }
- tlsPort := cfg.ListenTLSPort
- if tlsPort == 0 {
- tlsPort = 8443
- }
- fmt.Fprintf(&b, "server {\n listen %d ssl default_server;\n server_name _;\n", tlsPort)
- fmt.Fprintf(&b, " ssl_certificate %s;\n ssl_certificate_key %s;\n", nginxQuote(cfg.CertFile), nginxQuote(cfg.KeyFile))
- b.WriteString(" ssl_protocols TLSv1.2 TLSv1.3;\n return 404 \"unknown host\\n\";\n}\n\n")
- for _, r := range routes {
- host := strings.TrimSpace(r.Host)
- fmt.Fprintf(&b, "server {\n listen %d ssl;\n server_name %s;\n", tlsPort, host)
- fmt.Fprintf(&b, " ssl_certificate %s;\n ssl_certificate_key %s;\n",
- nginxQuote(cfg.CertFile), nginxQuote(cfg.KeyFile))
- b.WriteString(" ssl_protocols TLSv1.2 TLSv1.3;\n")
- // TLS 段复用同一条路由的认证与反代设置
- if r.RequireLogin {
- fmt.Fprintf(&b, " location = /_auth { internal; proxy_pass %s; proxy_pass_request_body off;\n", cfg.AuthURL)
- b.WriteString(" proxy_set_header Content-Length \"\";\n proxy_set_header X-Original-URI $request_uri;\n }\n")
- b.WriteString(" auth_request /_auth;\n error_page 401 = @login;\n")
- fmt.Fprintf(&b, " location @login { return 302 %s?redirect=$scheme://$host$request_uri; }\n", cfg.LoginURL)
- }
- b.WriteString(" location / {\n")
- fmt.Fprintf(&b, " proxy_pass %s;\n", strings.TrimSpace(r.Upstream))
- b.WriteString(" proxy_http_version 1.1;\n proxy_set_header Host $host;\n")
- b.WriteString(" proxy_set_header X-Real-IP $remote_addr;\n")
- b.WriteString(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n")
- b.WriteString(" proxy_set_header X-Forwarded-Proto $scheme;\n")
- if r.Websocket {
- b.WriteString(" proxy_set_header Upgrade $http_upgrade;\n proxy_set_header Connection \"upgrade\";\n")
- }
- b.WriteString(" proxy_redirect off;\n }\n}\n\n")
+ if err := writeRouteServer(&b, cfg, r); err != nil {
+ return "", err
}
}
return b.String(), nil
}
-// gatewayFileFor 生成物落地路径(include 目录里的固定文件名)。
func gatewayFileFor() string {
return filepath.Join(gatewayOutDir, "gateway.conf")
}
diff --git a/admin-server/gateway_nav.go b/admin-server/gateway_nav.go
new file mode 100644
index 0000000..9a7cbbb
--- /dev/null
+++ b/admin-server/gateway_nav.go
@@ -0,0 +1,140 @@
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+const (
+ gatewayManagedKey = "naviGateway"
+ gatewayHostKey = "naviGatewayHost"
+ gatewayAutoIconKey = "naviGatewayAutoIcon"
+)
+
+func normalizedHost(raw string) string {
+ u, err := url.Parse(raw)
+ if err != nil {
+ return ""
+ }
+ return strings.ToLower(u.Hostname())
+}
+
+func navURLForRoute(r Route) string {
+ if r.NavURL != "" {
+ return strings.TrimSpace(r.NavURL)
+ }
+ return "https://" + strings.TrimSpace(r.Host)
+}
+
+// validateGatewayNavigation ensures every selected Dashy group exists before either
+// gateway.yml or conf.yml is changed.
+func validateGatewayNavigation(gw *GatewayConfig, dashy *Config) error {
+ sections := make(map[string]bool, len(dashy.Sections))
+ for _, s := range dashy.Sections {
+ sections[strings.TrimSpace(s.Name)] = true
+ }
+ for _, r := range gw.Routes {
+ if !r.NavEnabled {
+ continue
+ }
+ if !sections[strings.TrimSpace(r.NavSection)] {
+ return fmt.Errorf("%s: 导航分组 %q 不存在", r.Host, r.NavSection)
+ }
+ }
+ return nil
+}
+
+// syncGatewayNavigation makes gateway.yml the source of truth for proxy-managed
+// Dashy items. Manually-created items that are unrelated to a managed host remain
+// untouched. Existing entries for a newly-managed host are adopted instead of
+// duplicated.
+func syncGatewayNavigation(gw *GatewayConfig) error {
+ dashy, err := loadConfig()
+ if err != nil {
+ return err
+ }
+ if err := validateGatewayNavigation(gw, dashy); err != nil {
+ return err
+ }
+
+ managedHosts := make(map[string]bool)
+ for _, r := range gw.Routes {
+ if r.NavEnabled {
+ managedHosts[strings.ToLower(strings.TrimSpace(r.Host))] = true
+ }
+ }
+
+ // Remove stale proxy-managed items and adopt existing items for hosts that are
+ // now managed by the gateway. The system-owned history group is never touched.
+ for si := range dashy.Sections {
+ if isHistorySection(dashy.Sections[si]) {
+ continue
+ }
+ kept := dashy.Sections[si].Items[:0]
+ for _, item := range dashy.Sections[si].Items {
+ itemHost := normalizedHost(item.URL)
+ wasManaged, _ := item.Extra[gatewayManagedKey].(bool)
+ if wasManaged || managedHosts[itemHost] {
+ continue
+ }
+ kept = append(kept, item)
+ }
+ dashy.Sections[si].Items = kept
+ }
+
+ for _, r := range gw.Routes {
+ if !r.NavEnabled {
+ continue
+ }
+ idx := -1
+ for i := range dashy.Sections {
+ if strings.TrimSpace(dashy.Sections[i].Name) == strings.TrimSpace(r.NavSection) {
+ idx = i
+ break
+ }
+ }
+ if idx < 0 {
+ return fmt.Errorf("%s: 导航分组 %q 不存在", r.Host, r.NavSection)
+ }
+ icon := strings.TrimSpace(r.NavIcon)
+ if icon == "" {
+ icon = "fas fa-globe"
+ }
+ item := NavItem{
+ Title: strings.TrimSpace(r.NavName),
+ Description: strings.TrimSpace(r.NavDescription),
+ Icon: icon,
+ URL: navURLForRoute(r),
+ Extra: map[string]any{
+ gatewayManagedKey: true,
+ gatewayHostKey: strings.ToLower(strings.TrimSpace(r.Host)),
+ gatewayAutoIconKey: r.NavAutoIcon,
+ },
+ }
+ dashy.Sections[idx].Items = append(dashy.Sections[idx].Items, item)
+ }
+ if err := saveConfig(dashy); err != nil {
+ return err
+ }
+ triggerHistoryRebuild()
+ return nil
+}
+
+func triggerHistoryRebuild() {
+ req, err := http.NewRequest(http.MethodPost, historyBaseURL+"/rebuild", bytes.NewReader(nil))
+ if err != nil {
+ return
+ }
+ if adminToken != "" {
+ req.Header.Set("Authorization", "Bearer "+adminToken)
+ }
+ client := &http.Client{Timeout: 3 * time.Second}
+ res, err := client.Do(req)
+ if err == nil {
+ _ = res.Body.Close()
+ }
+}
diff --git a/admin-server/gateway_test.go b/admin-server/gateway_test.go
index 6b4c06e..b75a40c 100644
--- a/admin-server/gateway_test.go
+++ b/admin-server/gateway_test.go
@@ -1,6 +1,9 @@
package main
-import "testing"
+import (
+ "os"
+ "testing"
+)
func TestRenderGatewayConfAuthPerRoute(t *testing.T) {
cfg := &GatewayConfig{
@@ -134,3 +137,164 @@ func indexOf(hay, needle string) int {
}
return -1
}
+
+// 未知域名必须走内置自定义 404 页面,而不是一行纯文本。
+func TestRenderGatewayConfUsesCustomErrorPages(t *testing.T) {
+ out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 8081, AuthURL: "http://x/_auth",
+ LoginURL: "https://l/login", Routes: []Route{{Host: "a.com", Upstream: "http://a"}}})
+ if err != nil {
+ t.Fatalf("渲染失败: %v", err)
+ }
+ for _, want := range []string{
+ "default_server",
+ "error_page 404 /404.html;",
+ "error_page 500 502 503 504 /500.html;",
+ "root /app/portal;",
+ } {
+ if !contains(out, want) {
+ t.Fatalf("缺少 %q:\n%s", want, out)
+ }
+ }
+ if contains(out, `return 404 "unknown host`) {
+ t.Fatalf("不应再返回纯文本 404:\n%s", out)
+ }
+}
+
+// 门户静态页类型:不需要后端,且必须能直接给出 404 页面。
+func TestRenderGatewayConfPortalKind(t *testing.T) {
+ out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 8081, Routes: []Route{
+ {Host: "example.com", Kind: "portal"},
+ }})
+ if err != nil {
+ t.Fatalf("portal 类型不应要求后端: %v", err)
+ }
+ if !contains(out, "server_name example.com;") || !contains(out, "try_files $uri $uri/ /404.html;") {
+ t.Fatalf("portal 段生成不对:\n%s", out)
+ }
+}
+
+// 加入导航必须同时给出分组名与名称;历史访问是系统分组。
+func TestValidateRouteNavigation(t *testing.T) {
+ cases := []struct {
+ name string
+ r Route
+ wantErr bool
+ }{
+ {"缺少导航名称", Route{Host: "a.com", Upstream: "http://a", NavEnabled: true, NavSection: "常用服务"}, true},
+ {"缺少分组", Route{Host: "a.com", Upstream: "http://a", NavEnabled: true, NavName: "A"}, true},
+ {"占用历史分组", Route{Host: "a.com", Upstream: "http://a", NavEnabled: true, NavName: "A", NavSection: "历史访问"}, true},
+ {"合法", Route{Host: "a.com", Upstream: "http://a", NavEnabled: true, NavName: "A", NavSection: "常用服务"}, false},
+ {"未加入导航则不校验", Route{Host: "a.com", Upstream: "http://a"}, false},
+ {"免认证路径非法", Route{Host: "a.com", Upstream: "http://a", BypassPaths: []string{"search"}}, true},
+ }
+ for _, c := range cases {
+ err := ValidateRoute(c.r)
+ if c.wantErr && err == nil {
+ t.Errorf("%s: 应当报错", c.name)
+ }
+ if !c.wantErr && err != nil {
+ t.Errorf("%s: 不应报错,得到 %v", c.name, err)
+ }
+ }
+}
+
+// 路由即导航:写入分组、同 host 去重、清理已移除的托管项、不动历史分组。
+func TestSyncGatewayNavigation(t *testing.T) {
+ dir := t.TempDir()
+ oldConf, oldURL := confFile, historyBaseURL
+ confFile, historyBaseURL = dir+"/conf.yml", "http://127.0.0.1:1"
+ defer func() { confFile, historyBaseURL = oldConf, oldURL }()
+
+ seed := `appConfig: {}
+pageInfo: {}
+sections:
+ - name: 常用服务
+ icon: fas fa-star
+ items:
+ - title: Gitea
+ url: https://gitea.example.com
+ - title: 手工项
+ url: https://manual.example.com
+ - name: 历史访问
+ icon: fas fa-history
+ items:
+ - title: 历史项
+ url: https://history.example.com
+`
+ if err := os.WriteFile(confFile, []byte(seed), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ gw := &GatewayConfig{Routes: []Route{
+ {Host: "gitea.example.com", Upstream: "http://127.0.0.1:3000", NavEnabled: true, NavSection: "常用服务", NavName: "Gitea", NavDescription: "代码仓库", NavAutoIcon: true},
+ {Host: "new.example.com", Upstream: "http://127.0.0.1:9000", NavEnabled: true, NavSection: "常用服务", NavName: "新服务"},
+ {Host: "hidden.example.com", Upstream: "http://127.0.0.1:9001", NavEnabled: false},
+ }}
+ if err := syncGatewayNavigation(gw); err != nil {
+ t.Fatalf("同步失败: %v", err)
+ }
+ cfg, err := loadConfig()
+ if err != nil {
+ t.Fatal(err)
+ }
+ names := map[string]int{}
+ for _, s := range cfg.Sections {
+ for _, it := range s.Items {
+ names[it.Title]++
+ if s.Name == "历史访问" && it.Title != "历史项" {
+ t.Fatalf("历史分组被写入手工项: %+v", it)
+ }
+ }
+ }
+ if names["Gitea"] != 1 {
+ t.Fatalf("同 host 应被接管而不是重复,Gitea 出现 %d 次", names["Gitea"])
+ }
+ if names["新服务"] != 1 {
+ t.Fatalf("新托管项未写入")
+ }
+ if names["手工项"] != 1 {
+ t.Fatalf("无关手工项被误删")
+ }
+ if names["历史项"] != 1 {
+ t.Fatalf("历史分组内容被改动")
+ }
+ // 未加入导航的路由不得出现在导航里
+ if names["hidden.example.com"] != 0 {
+ t.Fatalf("未勾选导航的路由不应写入")
+ }
+
+ // 再次同步:勾选取消后,托管项应被清理;手工项与历史项保留
+ gw.Routes = []Route{{Host: "gitea.example.com", Upstream: "http://127.0.0.1:3000"}}
+ if err := syncGatewayNavigation(gw); err != nil {
+ t.Fatal(err)
+ }
+ cfg, _ = loadConfig()
+ after := map[string]int{}
+ for _, s := range cfg.Sections {
+ for _, it := range s.Items {
+ after[it.Title]++
+ }
+ }
+ if after["Gitea"] != 0 || after["新服务"] != 0 {
+ t.Fatalf("托管项未被清理: %+v", after)
+ }
+ if after["手工项"] != 1 || after["历史项"] != 1 {
+ t.Fatalf("非托管项被误删: %+v", after)
+ }
+}
+
+// 分组不存在时必须报错,避免把导航写进不存在的栏目。
+func TestSyncGatewayNavigationMissingSection(t *testing.T) {
+ dir := t.TempDir()
+ oldConf, oldURL := confFile, historyBaseURL
+ confFile, historyBaseURL = dir+"/conf.yml", "http://127.0.0.1:1"
+ defer func() { confFile, historyBaseURL = oldConf, oldURL }()
+ _ = os.WriteFile(confFile, []byte("appConfig: {}\npageInfo: {}\nsections: []\n"), 0o644)
+
+ err := syncGatewayNavigation(&GatewayConfig{Routes: []Route{
+ {Host: "a.com", Upstream: "http://a", NavEnabled: true, NavSection: "不存在", NavName: "A"},
+ }})
+ if err == nil {
+ t.Fatalf("分组不存在时应报错")
+ }
+}
diff --git a/admin-server/main.go b/admin-server/main.go
index a628355..54560d4 100644
--- a/admin-server/main.go
+++ b/admin-server/main.go
@@ -1053,11 +1053,38 @@ func handleGatewayRoutes(w http.ResponseWriter, r *http.Request) {
if req.ListenTLSPort == 0 {
req.ListenTLSPort = 8443
}
+ // 路由与 Dashy 导航是一份操作:先确认导航分组都存在,再同时更新。
+ // 任一步失败都恢复 gateway.yml 与 conf.yml,避免后台显示和导航页各说各话。
+ dashy, err := loadConfig()
+ if err != nil {
+ jsonErr(w, 500, err.Error())
+ return
+ }
+ if err := validateGatewayNavigation(&req, dashy); err != nil {
+ jsonErr(w, 400, err.Error())
+ return
+ }
+ oldGateway, gatewayExisted := os.ReadFile(gatewayFile)
+ oldConf, confErr := os.ReadFile(confFile)
+ if confErr != nil {
+ jsonErr(w, 500, confErr.Error())
+ return
+ }
if err := saveGateway(&req); err != nil {
jsonErr(w, 500, err.Error())
return
}
- jsonResp(w, map[string]string{"status": "saved"})
+ if err := syncGatewayNavigation(&req); err != nil {
+ if gatewayExisted == nil {
+ _ = os.WriteFile(gatewayFile, oldGateway, 0o600)
+ } else {
+ _ = os.Remove(gatewayFile)
+ }
+ _ = os.WriteFile(confFile, oldConf, 0o644)
+ jsonErr(w, 500, "同步 Dashy 导航失败,已回滚:"+err.Error())
+ return
+ }
+ jsonResp(w, map[string]any{"status": "saved", "routes": len(req.Routes)})
default:
jsonErr(w, 405, "method not allowed")
}
@@ -1130,6 +1157,9 @@ func main() {
}
}
+ // 内置统一登录服务:与 gateway 共处一个容器,不再需要 portal-auth 容器。
+ registerPortalAuth()
+
// Serve admin UI static files (nginx strips /admin/ prefix)
http.Handle("/", http.FileServer(http.Dir("/app/admin")))
diff --git a/admin/index.html b/admin/index.html
index ade07f6..adfe23c 100644
--- a/admin/index.html
+++ b/admin/index.html
@@ -83,6 +83,16 @@ details summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--tx2)}
.sec-in-name{flex:1 1 120px;max-width:200px}
.sec-in-icon{flex:1 1 140px;max-width:220px}
.sec-acts{display:flex;gap:6px;flex:0 0 auto}
+.gw-host{flex:1 1 170px;max-width:280px;min-width:0}
+.gw-up{flex:1 1 190px;max-width:320px;min-width:0}
+.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}
+.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}
+.gw-ck input{width:auto;padding:0}
+#gw-msg{word-break:break-all}
@media (max-width:560px){
body{padding:16px 12px 48px}
@@ -94,7 +104,8 @@ details summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--tx2)}
.fg[style]{max-width:none!important}
.itm{flex-wrap:wrap;gap:8px}
.sec-row .itm-i{flex-basis:100%;min-width:0}
- .sec-in-name,.sec-in-icon{flex:1 1 100%;max-width:none!important}
+ .sec-in-name,.sec-in-icon,.gw-host,.gw-up{flex:1 1 100%;max-width:none!important}
+ .gw-mb,.gw-kind,.gw-sec,.gw-navname,.gw-navdesc,.gw-navicon{flex:1 1 100%;max-width:none!important}
.sec-acts{width:100%;justify-content:flex-end}
.link-row{flex-wrap:wrap}
.link-row input{flex:1 1 100%}
@@ -185,6 +196,46 @@ details summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--tx2)}
+
+
+
统一入口(反向代理与认证)
+
+ 在这里维护「域名 → 内网服务」,并逐条决定是否走统一登录认证。
+ 生成物默认监听旁路端口,验证无误后再接管正式入口(80/443)。
+
+
+
+
+ 入口参数(端口 / 证书 / 认证地址)
+
+
+
+
+
+
+
+
+
+
+
外观
@@ -700,11 +751,168 @@ document.getElementById('file-wall').addEventListener('change', async (ev) => {
toast('壁纸已上传并应用');
});
+
+/* ── 统一入口:反代 + 认证 ──
+ 路由表存 gateway.yml;"是否走统一认证"就是每条记录上的一个开关。
+ "保存并应用"会先让服务端做 nginx -t 校验,不通过会把原始报错显示出来,
+ 并保留上一份可用配置(不会把入口改坏)。 */
+let GW = { routes: [] };
+
+const gwVal = (id) => { const e = document.getElementById(id); return e ? e.value.trim() : ''; };
+const gwChk = (id) => { const e = document.getElementById(id); return !!(e && e.checked); };
+const gwSet = (id, v) => { const e = document.getElementById(id); if (e) e.value = (v === undefined || v === null) ? '' : v; };
+
+async function loadGateway() {
+ try {
+ const r = await fetch(A + '/gateway/routes', { headers: authHeaders() });
+ if (r.ok) GW = Object.assign(GW, await r.json());
+ } catch (e) { /* 读不到就先给空表 */ }
+ if (!Array.isArray(GW.routes)) GW.routes = [];
+ renderGateway();
+}
+
+/* 把界面上的编辑回读到 GW(保存/应用前必调) */
+function collectGateway() {
+ const routes = GW.routes;
+ document.querySelectorAll('input[data-gw-host]').forEach((el) => {
+ const i = +el.dataset.gwHost;
+ if (!routes[i]) return;
+ routes[i].host = el.value.trim();
+ routes[i].upstream = (document.querySelector(`input[data-gw-up="${i}"]`) || {}).value?.trim() || '';
+ routes[i].requireLogin = !!(document.querySelector(`input[data-gw-auth="${i}"]`) || {}).checked;
+ routes[i].websocket = !!(document.querySelector(`input[data-gw-ws="${i}"]`) || {}).checked;
+ const mb = parseInt((document.querySelector(`input[data-gw-mb="${i}"]`) || {}).value, 10);
+ routes[i].maxBodyMB = Number.isFinite(mb) && mb > 0 ? mb : 0;
+ routes[i].kind = (document.querySelector(`select[data-gw-kind="${i}"]`) || {}).value || 'proxy';
+ routes[i].navEnabled = !!(document.querySelector(`input[data-gw-nav="${i}"]`) || {}).checked;
+ routes[i].navSection = (document.querySelector(`select[data-gw-sec="${i}"]`) || {}).value || '';
+ routes[i].navName = ((document.querySelector(`input[data-gw-navname="${i}"]`) || {}).value || '').trim();
+ routes[i].navDescription = ((document.querySelector(`input[data-gw-navdesc="${i}"]`) || {}).value || '').trim();
+ routes[i].navIcon = ((document.querySelector(`input[data-gw-navicon="${i}"]`) || {}).value || '').trim();
+ routes[i].navAutoIcon = !!(document.querySelector(`input[data-gw-auto="${i}"]`) || {}).checked;
+ });
+ GW.listenPort = parseInt(gwVal('gw-port'), 10) || 0;
+ GW.listenTlsPort = parseInt(gwVal('gw-tlsport'), 10) || 0;
+ GW.listenHttps = gwChk('gw-tls');
+ GW.certFile = gwVal('gw-cert');
+ GW.keyFile = gwVal('gw-key');
+ GW.authUrl = gwVal('gw-auth');
+ GW.loginUrl = gwVal('gw-login');
+}
+
+/* 导航分组下拉:分组来自「导航分组」卡片,必须已存在(服务端会校验) */
+function sectionOptions(current) {
+ const opts = ['
'];
+ (S || []).forEach((s) => {
+ const n = String(s.name || '');
+ if (n === '历史访问') return; // 系统分组不接受手工导航
+ opts.push(`
`);
+ });
+ return opts.join('');
+}
+
+function renderGateway() {
+ const box = document.getElementById('gw-routes');
+ box.innerHTML = GW.routes.length ? GW.routes.map((r, i) => `
+
`).join('')
+ : '
还没有映射。添加一条「域名 → 内网服务」,需要登录才能访问的勾上「统一认证」。
';
+
+ gwSet('gw-port', GW.listenPort);
+ gwSet('gw-tlsport', GW.listenTlsPort);
+ gwSet('gw-cert', GW.certFile);
+ gwSet('gw-key', GW.keyFile);
+ gwSet('gw-auth', GW.authUrl);
+ gwSet('gw-login', GW.loginUrl);
+ const tls = document.getElementById('gw-tls');
+ if (tls) tls.checked = !!GW.listenHttps;
+
+ document.querySelectorAll('button[data-gw-del]').forEach((el) => {
+ el.addEventListener('click', () => {
+ collectGateway();
+ const i = +el.dataset.gwDel;
+ if (!confirm('删除映射「' + (GW.routes[i] || {}).host + '」?')) return;
+ GW.routes.splice(i, 1);
+ renderGateway();
+ });
+ });
+}
+
+async function gatewaySave() {
+ collectGateway();
+ const r = await fetch(A + '/gateway/routes', {
+ method: 'POST',
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
+ body: JSON.stringify(GW),
+ });
+ if (!r.ok) { toast('保存失败: ' + (await r.text()).slice(0, 160), '#ef4444'); return false; }
+ return true;
+}
+
+document.getElementById('gw-add').addEventListener('click', () => {
+ const host = gwVal('gw-n-host');
+ const up = gwVal('gw-n-up');
+ if (!host || !up) { toast('请填写域名和后端', '#ef4444'); return; }
+ collectGateway();
+ const firstSection = (S || []).map(s => String(s.name || '')).find(n => n && n !== '历史访问') || '';
+ 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,
+ });
+ document.getElementById('gw-n-host').value = '';
+ document.getElementById('gw-n-up').value = '';
+ renderGateway();
+ toast('已加入列表,记得点「保存并应用」');
+});
+
+document.getElementById('gw-saveonly').addEventListener('click', async () => {
+ if (await gatewaySave()) toast('已保存(未应用)');
+});
+
+document.getElementById('gw-apply').addEventListener('click', async () => {
+ const msg = document.getElementById('gw-msg');
+ if (msg) msg.textContent = '校验中…';
+ if (!(await gatewaySave())) { if (msg) msg.textContent = ''; return; }
+ const r = await fetch(A + '/gateway/apply', { method: 'POST', headers: authHeaders() });
+ const body = await r.text();
+ if (r.ok) {
+ toast('已应用并热加载');
+ if (msg) msg.textContent = '已应用 ✓';
+ } else {
+ toast('应用失败', '#ef4444');
+ if (msg) msg.innerHTML = '
' + esc(body.slice(0, 600)) + '';
+ }
+ loadGateway();
+});
+
(async function init() {
await loadPageInfo();
await loadSections();
await loadWallpaper();
await loadHistory();
+ await loadGateway();
})();