Files
HomeDash/admin-server/gateway.go
HomeDash Bot ca80ba2510 fix(gateway): auth 类路由重复写 /favicon.ico 导致 nginx -t 失败(v96 部署被回滚的真因)
v96 部署时脚本报"网关配置重新生成失败 → 回滚到 ui-v95"。根因:auth 类路由的 Upstream
是在 writeErrorPages 之后才赋值的,所以它先按"门户自己渲染"拿到了门户 favicon,
随后新的"favicon 直通上游"块又写了同一个 location → nginx 拒绝重复 location。

修法:直通块对 auth 类跳过(门户页/登录页继续用门户自己的图标)。
另加回归测试 TestNoDuplicateExactLocations:解析渲染结果,断言任一 server 块内
不得出现重复的 `location = X`;已用"临时移除守卫"做过反向对照,确认该测试真能抓到。
2026-09-13 08:58:41 +08:00

426 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"fmt"
"html"
"os"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
// Route 同时描述一条反向代理及其在 Dashy 中的导航信息。
// Kind: proxy(普通反代)、portal(内置门户静态页)、auth(内置登录服务)。
type Route struct {
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 是统一入口的完整配置。ListenHTTPS=false 时 ListenPort 是普通
// HTTP 旁路端口;开启后 ListenPort/ListenTLSPort 都是 TLS 端口,RedirectPort
// 是 HTTP→HTTPS 入口。这样可复刻原 portal-nginx 的 3080/8443 + 3081 设计。
type GatewayConfig struct {
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"`
// RedirectWithPort:http→https 跳转时是否带上入口端口。
// 默认 false(跳 https://host,适用于前置 frp/443 的场景);
// 全新安装且对外端口=入口端口时置 true。
RedirectWithPort bool `yaml:"redirectWithPort,omitempty" json:"redirectWithPort,omitempty"`
Routes []Route `yaml:"routes" json:"routes"`
}
func defaultGatewayConfig() *GatewayConfig {
return &GatewayConfig{
ListenPort: 8081,
ListenTLSPort: 8443,
AuthURL: "http://127.0.0.1:3005/auth/verify", // 本机内部地址,非外部域名
// 登录地址不预置任何域名:未配置时由后台/首装脚本填入(gateway.yml loginUrl)
LoginURL: "",
Routes: []Route{},
}
}
var (
gatewayFile = envOr("NAVI_GATEWAY_FILE", filepath.Join(filepath.Dir(confFile), "gateway.yml"))
gatewayOutDir = envOr("NAVI_GATEWAY_OUT", filepath.Join(filepath.Dir(confFile), "gateway"))
)
func loadGateway() (*GatewayConfig, error) {
cfg := defaultGatewayConfig()
data, err := os.ReadFile(gatewayFile)
if err != nil {
if os.IsNotExist(err) {
return cfg, nil
}
return nil, err
}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("路由表解析失败: %w", err)
}
if cfg.ListenPort == 0 {
cfg.ListenPort = 8081
}
if cfg.ListenTLSPort == 0 {
cfg.ListenTLSPort = 8443
}
if cfg.Routes == nil {
cfg.Routes = []Route{}
}
return cfg, nil
}
func saveGateway(cfg *GatewayConfig) error {
data, err := yaml.Marshal(cfg)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(gatewayFile), 0o755); err != nil {
return err
}
tmp := gatewayFile + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
return os.Rename(tmp, gatewayFile)
}
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, "://") {
return fmt.Errorf("域名格式不对: %q", r.Host)
}
kind := routeKind(r)
if kind != "proxy" && kind != "portal" && kind != "auth" {
return fmt.Errorf("%s: 未知服务类型 %q", host, r.Kind)
}
if kind == "proxy" {
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
}
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, "'", "") + "'"
}
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")
}
func writeListeners(b *strings.Builder, cfg *GatewayConfig, defaultServer bool) {
suffix := ""
if defaultServer {
suffix = " default_server"
}
if cfg.ListenHTTPS {
// 注意:http2 只能作为 listen 的参数写。容器里是 nginx 1.22,
// 独立的 `http2 on;` 会直接报 unknown directive(本轮实测踩到)。
fmt.Fprintf(b, " listen %d ssl http2%s;\n", cfg.ListenPort, suffix)
if cfg.ListenTLSPort > 0 && cfg.ListenTLSPort != cfg.ListenPort {
fmt.Fprintf(b, " listen %d ssl http2%s;\n", cfg.ListenTLSPort, suffix)
}
return
}
fmt.Fprintf(b, " listen %d%s;\n", cfg.ListenPort, suffix)
}
// writeErrorPages 写公共 location。portalFavicon 决定是否提供**门户自己的** favicon:
// 只应写在门户页/登录页/默认 404 页这些由门户自身渲染的 server 块里。
//
// 踩坑(2026-09-13 实测):这两行曾被无条件写进每个 server 块,而 try_files 是在
// 门户的 root(/app/portal)里找文件 —— 于是**所有被代理域名的 /favicon.ico 都返回门户图标**:
//
// · 浏览器里每个站点的标签图标都变成门户图标;
// · 导航项图标抓取(优先抓 /favicon.ico)把门户图标当成了站点图标,
// 实测 pi / ittools / vikunja 三个站点存下了同一张图标。
func writeErrorPages(b *strings.Builder, include404, portalFavicon bool) {
b.WriteString(" root /app/portal;\n")
// 关键:错误页与它自己的静态资源必须 `auth_request off`。
// 上游故障时 nginx 会内部跳转到 /500.html,而服务端的 auth_request 会被
// 这个内部请求继承 —— 于是"上游 502"最终以 401(认证失败)呈现给用户,
// 自定义错误页永远显示不出来。本轮实测踩到(日志里能看到 connect() refused,
// 客户端却收到 nginx 的默认 401 页)。
if include404 {
b.WriteString(" error_page 404 /404.html;\n")
b.WriteString(" location = /404.html { internal; auth_request off; }\n")
}
b.WriteString(" error_page 500 502 503 504 /500.html;\n")
b.WriteString(" location = /500.html { internal; auth_request off; }\n")
b.WriteString(" location ^~ /css/ { auth_request off; try_files $uri =404; }\n")
if portalFavicon {
b.WriteString(" location = /favicon.svg { auth_request off; try_files $uri =404; }\n")
b.WriteString(" location = /favicon.ico { auth_request off; 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 {
// 备案号来自站点信息(可配置);未配置则不注入,避免把别人的备案号印到你的页面上。
if snip := nginxSnippetSafe(beianSnippet()); snip != "" {
fmt.Fprintf(b, " sub_filter '</footer>' '</footer><div class=\"navi-proxy-beian\">%s</div>';\n", snip)
b.WriteString(" sub_filter_once off;\n")
// sub_filter 要生效必须关掉上游压缩,故只在真正注入时才加这行
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)
}
// 只有"门户自己渲染"的块才给门户图标;代理到上游的块必须让 /favicon.ico 走 location /
writeErrorPages(b, routeKind(r) == "portal", strings.TrimSpace(r.Upstream) == "")
if routeKind(r) == "portal" {
// 门户首页交给 admin-server 渲染(pageInfo 可配置 + 支持数据卷整页覆盖),
// 静态资源仍由本容器提供(root /app/portal)。
// 注意:不要在这里再写 root —— writeErrorPages 已经写过一次,
// 重复会让 nginx -t 报 "root directive is duplicate"(本轮真实踩到)。
b.WriteString(" location = / { proxy_pass http://127.0.0.1:3005/portal-page; proxy_set_header Host $host; proxy_redirect off; }\n")
b.WriteString(" 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)
}
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)
}
// favicon 免登录直通上游。
// 理由:① 图标不是敏感资源,未登录时浏览器标签也该显示站点自己的图标;
// ② 导航项/历史条目的图标抓取依赖它在未登录时可取。
// 之前这里是"在门户 root 里 try_files",等于把每个被代理站点的图标都换成门户图标
// (实测 pi / ittools / vikunja 存下了同一张门户图标)。
// 注意:auth 类的上游是在上面 writeErrorPages 之后才赋值的,那时它已按"门户自己渲染"
// 拿到了门户 favicon —— 这里必须跳过,否则同一个 location 出现两次,nginx -t 直接失败
// (部署脚本回滚过一版,实测踩到)。
if up := strings.TrimSpace(r.Upstream); up != "" && routeKind(r) != "auth" {
for _, fa := range []string{"/favicon.ico", "/favicon.svg"} {
fmt.Fprintf(b, " location = %s {\n auth_request off;\n proxy_pass %s;\n", fa, up)
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.InsecureTLS || strings.HasPrefix(strings.ToLower(up), "https://") {
b.WriteString(" proxy_ssl_verify off;\n")
}
b.WriteString(" proxy_redirect off;\n }\n")
}
}
writeProxyLocation(b, r, "", false)
b.WriteString("}\n\n")
return nil
}
// beianSnippet 生成注入被代理页面的备案 HTML:来自后台「站点信息」的 ICP/公网安备,
// 两者都没配置时返回空串(此时不注入任何东西)。
// 原先这里写死了具体备案号,等于每个部署者都会带上别人的备案信息。
func beianSnippet() string {
cfg, err := loadConfig()
if err != nil || cfg == nil {
return ""
}
icp := strings.TrimSpace(mapString(cfg.PageInfo, "icp"))
gongan := strings.TrimSpace(mapString(cfg.PageInfo, "gongan"))
var parts []string
if icp != "" {
parts = append(parts, `<a href="https://beian.miit.gov.cn" target="_blank">`+html.EscapeString(icp)+`</a>`)
}
if gongan != "" {
parts = append(parts, `<a href="https://beian.mps.gov.cn" target="_blank">`+html.EscapeString(gongan)+`</a>`)
}
if len(parts) == 0 {
return ""
}
return strings.Join(parts, " | ")
}
// nginxSnippetSafe 去掉会破坏 nginx 配置或引发变量展开的字符。
func nginxSnippetSafe(s string) string {
return strings.NewReplacer("'", "", `\`, "", "\n", "", "\r", "", "$", "").Replace(s)
}
// 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.ToLower(strings.TrimSpace(r.Host))
if hosts[h] {
return "", fmt.Errorf("域名重复: %s", h)
}
hosts[h] = true
}
routes := append([]Route(nil), cfg.Routes...)
sort.SliceStable(routes, func(i, j int) bool { return routes[i].Host < routes[j].Host })
var b strings.Builder
b.WriteString("# 由 HomeDash 后台生成,请勿手改。源: " + gatewayFile + "\n\n")
if cfg.ListenHTTPS && cfg.RedirectPort > 0 {
// 跳转目标是否带上入口端口:由 redirectWithPort 显式决定。
// 不能用"端口 != 443"推导——生产容器监听 3080,但对外经 frp 映射为 443,
// 那种情况下带端口反而把公网用户导向 https://host:3080(打不开)。
// 只有在"对外端口就等于入口端口"的部署(如全新安装 13080)才应开启。
redirectHost := "$host"
if cfg.RedirectWithPort && cfg.ListenPort != 443 {
redirectHost = fmt.Sprintf("$host:%d", cfg.ListenPort)
}
fmt.Fprintf(&b, "server {\n listen %d default_server;\n server_name _;\n return 302 https://%s$request_uri;\n}\n\n", cfg.RedirectPort, redirectHost)
}
// 默认服务器:保留原统一代理的自定义错误页设计。
b.WriteString("server {\n")
writeListeners(&b, cfg, true)
b.WriteString(" server_name _;\n")
if cfg.ListenHTTPS {
writeTLS(&b, cfg)
}
writeErrorPages(&b, true, true)
b.WriteString(" location / { return 404; }\n}\n\n")
for _, r := range routes {
if err := writeRouteServer(&b, cfg, r); err != nil {
return "", err
}
}
return b.String(), nil
}
func gatewayFileFor() string {
return filepath.Join(gatewayOutDir, "gateway.conf")
}