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:
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user