diff --git a/admin-server/gateway.go b/admin-server/gateway.go index 4794430..9baaf8f 100644 --- a/admin-server/gateway.go +++ b/admin-server/gateway.go @@ -195,7 +195,16 @@ func writeListeners(b *strings.Builder, cfg *GatewayConfig, defaultServer bool) fmt.Fprintf(b, " listen %d%s;\n", cfg.ListenPort, suffix) } -func writeErrorPages(b *strings.Builder, include404 bool) { +// 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 会被 @@ -209,8 +218,10 @@ func writeErrorPages(b *strings.Builder, include404 bool) { 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") - 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") + 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) { @@ -264,7 +275,8 @@ func writeRouteServer(b *strings.Builder, cfg *GatewayConfig, r Route) error { if r.MaxBodyMB > 0 { fmt.Fprintf(b, " client_max_body_size %dm;\n", r.MaxBodyMB) } - writeErrorPages(b, routeKind(r) == "portal") + // 只有"门户自己渲染"的块才给门户图标;代理到上游的块必须让 /favicon.ico 走 location / + writeErrorPages(b, routeKind(r) == "portal", strings.TrimSpace(r.Upstream) == "") if routeKind(r) == "portal" { // 门户首页交给 admin-server 渲染(pageInfo 可配置 + 支持数据卷整页覆盖), @@ -376,7 +388,7 @@ func RenderGatewayConf(cfg *GatewayConfig) (string, error) { if cfg.ListenHTTPS { writeTLS(&b, cfg) } - writeErrorPages(&b, true) + writeErrorPages(&b, true, true) b.WriteString(" location / { return 404; }\n}\n\n") for _, r := range routes { diff --git a/admin-server/gateway_test.go b/admin-server/gateway_test.go index b0321ca..f4245e2 100644 --- a/admin-server/gateway_test.go +++ b/admin-server/gateway_test.go @@ -493,3 +493,39 @@ func TestBeianInjectionSanitized(t *testing.T) { t.Fatalf("危险字符未清理:\n%s", out) } } + +// 代理到上游的 server 块**不得**包含门户自己的 favicon location: +// try_files 是在门户 root 里找文件,会把被代理站点的 /favicon.ico 顶成门户图标 +// (实测 pi / ittools / vikunja 三个站点的导航图标因此变成同一张门户图标)。 +func TestProxyRouteDoesNotShadowUpstreamFavicon(t *testing.T) { + out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 3081, Routes: []Route{ + {Host: "proxy.example", Upstream: "http://127.0.0.1:8080"}, + {Host: "portal.example", Kind: "portal"}, + }}) + if err != nil { + t.Fatal(err) + } + // 切出 proxy.example 的 server 块单独断言,避免被门户块的正常输出干扰 + blocks := strings.Split(out, "server {") + var proxyBlock, portalBlock string + for _, b := range blocks { + if strings.Contains(b, "server_name proxy.example;") { + proxyBlock = b + } + if strings.Contains(b, "server_name portal.example;") { + portalBlock = b + } + } + if proxyBlock == "" || portalBlock == "" { + t.Fatalf("server blocks not found\n%s", out) + } + if strings.Contains(proxyBlock, "location = /favicon.ico") { + t.Errorf("代理块不应提供门户 favicon(会顶掉上游图标):\n%s", proxyBlock) + } + if strings.Contains(proxyBlock, "location = /favicon.svg") { + t.Errorf("代理块不应提供门户 favicon.svg:\n%s", proxyBlock) + } + if !strings.Contains(portalBlock, "location = /favicon.ico") { + t.Errorf("门户块应保留门户 favicon:\n%s", portalBlock) + } +} diff --git a/history-server/favicon.go b/history-server/favicon.go index e276334..e65ffc9 100644 --- a/history-server/favicon.go +++ b/history-server/favicon.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "crypto/tls" "encoding/binary" "errors" "fmt" @@ -236,7 +237,9 @@ func faviconRebuildLoop() { } } -func httpGet(rawURL string, max int, clients []*http.Client) ([]byte, string, error) { +// httpGet 抓取图标;hostHeader 非空时用作 Host 头(内部上游要按对外域名做 +// 虚拟主机路由,例如 gitea/pi 这类按域名分站的程序)。 +func httpGet(rawURL string, max int, clients []*http.Client, hostHeader string) ([]byte, string, error) { var lastErr error for _, c := range clients { req, err := http.NewRequest(http.MethodGet, rawURL, nil) @@ -245,6 +248,9 @@ func httpGet(rawURL string, max int, clients []*http.Client) ([]byte, string, er } req.Header.Set("User-Agent", faviconUA) req.Header.Set("Accept", "*/*") + if hostHeader != "" { + req.Host = hostHeader + } resp, err := c.Do(req) if err != nil { lastErr = err @@ -276,7 +282,7 @@ func httpGet(rawURL string, max int, clients []*http.Client) ([]byte, string, er // httpGetTruncated 用于抓 HTML:只需要前若干字节里的
, // 页面比上限大不应该算失败(GitHub 首页就超 512KB)。 -func httpGetTruncated(rawURL string, max int, clients []*http.Client) ([]byte, string, error) { +func httpGetTruncated(rawURL string, max int, clients []*http.Client, hostHeader string) ([]byte, string, error) { var lastErr error for _, c := range clients { req, err := http.NewRequest(http.MethodGet, rawURL, nil) @@ -285,6 +291,9 @@ func httpGetTruncated(rawURL string, max int, clients []*http.Client) ([]byte, s } req.Header.Set("User-Agent", faviconUA) req.Header.Set("Accept", "text/html,*/*;q=0.8") + if hostHeader != "" { + req.Host = hostHeader + } resp, err := c.Do(req) if err != nil { lastErr = err @@ -312,8 +321,8 @@ var ( ) // findIconHref 从首页 HTML 里挑出最合适的图标地址。 -func findIconHref(pageURL string, clients []*http.Client) (string, error) { - data, ct, err := httpGetTruncated(pageURL, 384<<10, clients) +func findIconHref(pageURL string, clients []*http.Client, hostHeader string) (string, error) { + data, ct, err := httpGetTruncated(pageURL, 384<<10, clients, hostHeader) if err != nil { return "", err } @@ -587,55 +596,113 @@ func storeIcon(hostport string, data []byte, ext string) error { return fmt.Errorf("unsupported icon type %q", ext) } +// gatewayUpstream 把对外域名映射到路由表(gateway.yml)里的内部上游地址。 +// +// 为什么需要它:受统一登录保护(requireLogin)的域名,直接抓 https://<域名>/favicon.ico +// 会先撞上门户鉴权;再加上网关曾给每个 server 块无条件写了 +// +// location = /favicon.ico { auth_request off; try_files $uri =404; } +// +// (在门户自己的静态目录里找文件),于是所有被代理域名的 favicon 都变成门户图标 —— +// 实测 pi / ittools / vikunja 三个站点存下了**同一张**门户图标。绕过门户直接问内部服务才对。 +func gatewayUpstream(hostport string) string { + if hostport == "" || strings.Contains(hostport, ":") { + return "" // 带端口的不是路由域名(路由只按域名匹配) + } + data, err := os.ReadFile(filepath.Join(dataDir, "gateway.yml")) + if err != nil { + return "" + } + var cfg struct { + Routes []struct { + Host string `yaml:"host"` + Upstream string `yaml:"upstream"` + } `yaml:"routes"` + } + if err := yaml.Unmarshal(data, &cfg); err != nil { + return "" + } + want := strings.ToLower(strings.TrimSpace(hostport)) + for _, r := range cfg.Routes { + if strings.ToLower(strings.TrimSpace(r.Host)) == want { + return strings.TrimSpace(r.Upstream) + } + } + return "" +} + +// faviconInternalClient 抓内部上游专用:不走代理,并容忍自签证书 +// (cockpit / fnOS 这类上游本身就是 https + 自签,走默认校验必然失败)。 +var faviconInternalClient = func() *http.Client { + tr := &http.Transport{ + DisableKeepAlives: true, + TLSHandshakeTimeout: 5 * time.Second, + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + return &http.Client{ + Timeout: faviconTimeout, + Transport: tr, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 4 { + return errors.New("too many redirects") + } + return nil + }, + } +}() + +// fetchIconFrom 在某个 base(对外地址或内部上游)上按 +// /favicon.ico → 首页 的顺序取图标。 +func fetchIconFrom(hostport, base, hostHeader string, clients []*http.Client) error { + icoURL := base + "/favicon.ico" + if data, ct, err := httpGet(icoURL, faviconMaxBytes, clients, hostHeader); err == nil { + if ext := sniffExt(data, ct, icoURL); ext != "" { + return storeIcon(hostport, data, ext) + } + return errors.New("favicon.ico is not an image") + } + href, err := findIconHref(base+"/", clients, hostHeader) + if err != nil { + return err + } + target := resolveURL(base+"/", href) + if target == "" { + return errors.New("unresolvable icon href") + } + data, ct, err := httpGet(target, faviconMaxBytes, clients, hostHeader) + if err != nil { + return err + } + ext := sniffExt(data, ct, target) + if ext == "" { + return errors.New("declared icon is not a usable image") + } + return storeIcon(hostport, data, ext) +} + func fetchFavicon(hostport string) error { if hostport == "" || strings.ContainsAny(hostport, "/\\ \t") { return errors.New("invalid host") } - clients := clientsFor(hostport) var lastErr error - for _, scheme := range []string{"https", "http"} { - base := scheme + "://" + hostport - icoURL := base + "/favicon.ico" - - if data, ct, err := httpGet(icoURL, faviconMaxBytes, clients); err == nil { - if ext := sniffExt(data, ct, icoURL); ext != "" { - if err := storeIcon(hostport, data, ext); err == nil { - return nil - } else { - lastErr = err - } - } else { - lastErr = errors.New("favicon.ico is not an image") - } + // ① 先走内部上游:绕过门户统一登录,也避开"门户图标顶掉上游图标"的老问题 + if up := gatewayUpstream(hostport); up != "" { + if err := fetchIconFrom(hostport, up, hostport, []*http.Client{faviconInternalClient}); err == nil { + return nil } else { lastErr = err } + } - href, err := findIconHref(base+"/", clients) - if err != nil { + // ② 回落对外地址(站外站点、或路由表里没有的 host) + clients := clientsFor(hostport) + for _, scheme := range []string{"https", "http"} { + if err := fetchIconFrom(hostport, scheme+"://"+hostport, "", clients); err == nil { + return nil + } else { lastErr = err - continue } - target := resolveURL(base+"/", href) - if target == "" { - continue - } - data, ct, err := httpGet(target, faviconMaxBytes, clients) - if err != nil { - lastErr = err - continue - } - ext := sniffExt(data, ct, target) - if ext == "" { - lastErr = errors.New("declared icon is not a usable image") - continue - } - if err := storeIcon(hostport, data, ext); err != nil { - lastErr = err - continue - } - return nil } return lastErr }