feat(gateway): 一体化统一入口——路由与 Dashy 导航同一份数据、内置登录、原自定义错误页
目标:一个 HomeDash 容器取代 navi + portal-nginx + portal-auth。 - Route 扩展为"反代 + 导航"一条记录:kind(proxy/portal/auth)、streaming、 insecureTls、bypassPaths、injectBeian,以及 navEnabled/navSection/navName/ navDescription/navUrl/navIcon/navAutoIcon - 保存路由时**同时**写 gateway.yml 与 conf.yml 的导航项(gateway_nav.go): 同 host 接管而不是重复、未勾选导航的会被清理、无关手工项与系统「历史访问」分组 一律不动;两步任一步失败都回滚(避免后台显示与导航页各说各话) - 未知域名与上游故障改用**内置自定义 404/500 页面**(portal/ 目录), 不再是一行纯文本;HTTP→HTTPS 跳转段用 RedirectPort 复刻原 3081 设计 - 内置统一登录(auth.go):HMAC 签名 cookie、恒定时间比较、登录跳转白名单校验, portal-auth 容器可以退场 - 自动抓图标对托管导航项同样生效(history-server 识别 naviGatewayAutoIcon) - 后台卡片补齐"加入导航/分组/名称/描述/图标/自动抓图标/服务类型"字段 - 内部入口端口 8080 → 18080:为一体化 host 模式让开宿主机 blog(:8080) - 修掉上一轮编辑写坏的 deploy/zd.sh 一行(会让脚本直接失败) 测试:新增自定义错误页、portal 类型、导航字段校验、导航同步(接管/清理/保留)、 分组不存在必须报错等用例;admin-server 与 history-server 全绿。
This commit is contained in:
216
admin-server/auth.go
Normal file
216
admin-server/auth.go
Normal file
@ -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(`<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>统一门户登录</title><style>
|
||||
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:20px;font-family:system-ui,-apple-system,"Segoe UI",sans-serif;color:#172033;background:radial-gradient(900px 520px at 15% 0,#dbeafe,transparent 62%),radial-gradient(700px 500px at 90% 10%,#ede9fe,transparent 58%),#f8fafc}.card{width:min(400px,100%);padding:38px;border:1px solid rgba(15,23,42,.1);border-radius:20px;background:rgba(255,255,255,.82);box-shadow:0 24px 70px rgba(15,23,42,.14);backdrop-filter:blur(16px)}.logo{width:52px;height:52px;display:grid;place-items:center;margin:0 auto 15px;border-radius:15px;color:#2563eb;background:#dbeafe;font-weight:900;font-size:21px}h1{text-align:center;font-size:22px;margin:0 0 5px}.sub{text-align:center;color:#64748b;font-size:13px;margin:0 0 25px}label{display:block;font-size:12px;font-weight:700;color:#475569;margin:13px 0 6px}input{width:100%;padding:11px 13px;border:1px solid #d9e1ec;border-radius:10px;background:#f8fafc;font:inherit;font-size:15px;outline:none}input:focus{border-color:#2563eb;box-shadow:0 0 0 3px rgba(37,99,235,.12)}button{width:100%;margin-top:18px;padding:11px;border:0;border-radius:10px;background:#2563eb;color:#fff;font:inherit;font-weight:750;cursor:pointer}.err{padding:9px 11px;border-radius:9px;background:#fef2f2;color:#b91c1c;font-size:13px;margin-bottom:14px}.foot{text-align:center;margin-top:22px;font-size:11px;color:#94a3b8}.foot a{color:inherit;text-decoration:none}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}
|
||||
</style></head><body><main class="card"><div class="logo">JF</div><h1>统一门户</h1><p class="sub">登录后访问受保护的内部服务</p>{{if .Error}}<div class="err">{{.Error}}</div>{{end}}<form method="post" action="/login?redirect={{.Redirect}}"><label for="u">用户名</label><input id="u" name="username" autocomplete="username" required autofocus><label for="p">密码</label><input id="p" type="password" name="password" autocomplete="current-password" required><button type="submit">登录</button></form><div class="foot"><a href="https://beian.miit.gov.cn" target="_blank" rel="noopener">京ICP备00000000号-1</a></div></main></body></html>`))
|
||||
|
||||
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)
|
||||
}
|
||||
@ -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 '</footer>' '</footer><div class=\"navi-proxy-beian\"><a href=\"https://beian.miit.gov.cn\" target=\"_blank\">京ICP备00000000号-1</a> | <a href=\"https://beian.mps.gov.cn\" target=\"_blank\">京公网安备00000000000000号</a></div>';\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")
|
||||
}
|
||||
|
||||
140
admin-server/gateway_nav.go
Normal file
140
admin-server/gateway_nav.go
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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("分组不存在时应报错")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user