fix(gateway/favicon): 被代理域名的 /favicon.ico 被门户图标顶掉(图标全错的真因)

用户问"同时被路由的网站为什么图标不正确"。实测三处证据:
  ① gateway.conf 里**每个** server 块都有
       location = /favicon.ico { auth_request off; try_files $uri =404; }
     而 try_files 在门户自己的 root(/app/portal) 里找文件;
  ② 于是 https://pi|ittools|vikunja.jianfgit.xyz/favicon.ico 三者返回**同一个**
     门户图标(md5 80498a88…),而它们内部上游的真实图标各不相同
     (ittools dc36588f… / vikunja 6d9507b6… / pi cf3332ac…);
  ③ 导航图标抓取优先抓 /favicon.ico → pi/ittools/vikunja 存下了同一张 1091B 门户图标。

修法两处:
- admin-server/gateway.go:门户 favicon 的两个 location 只在"门户自己渲染"的
  server 块(kind=portal/auth、默认 404 块)里输出;代理到上游的块不再输出,
  让 /favicon.ico 正常走 location / 落到上游。新增断言测试。
- history-server/favicon.go:抓取改为**优先走内部上游**(读 gateway.yml 把对外域名
  映射到内部地址,带 Host 头、容忍自签证书),失败才回落对外地址 —— 这样受统一登录
  保护的站点也能拿到真实图标,且不再依赖门户的 favicon 行为。
This commit is contained in:
HomeDash Bot
2026-09-13 08:48:42 +08:00
parent 30aff4ffd8
commit 3d1ed788f3
3 changed files with 161 additions and 46 deletions

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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:只需要前若干字节里的 <head>,
// 页面比上限大不应该算失败(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 → 首页 <link rel="icon"> 的顺序取图标。
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
}