Files
HomeDash/admin-server/gateway_test.go
HomeDash Bot 3d1ed788f3 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 行为。
2026-09-13 08:48:42 +08:00

532 lines
19 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 (
"os"
"path/filepath"
"strings"
"testing"
)
func TestRenderGatewayConfAuthPerRoute(t *testing.T) {
cfg := &GatewayConfig{
ListenPort: 8081,
AuthURL: "http://127.0.0.1:4001/_auth",
LoginURL: "https://login.example.com/login",
Routes: []Route{
{Host: "protected.example.com", Upstream: "http://10.0.0.1:3000", RequireLogin: true, Websocket: true, MaxBodyMB: 64},
{Host: "open.example.com", Upstream: "http://10.0.0.2:8000"},
},
}
out, err := RenderGatewayConf(cfg)
if err != nil {
t.Fatalf("渲染失败: %v", err)
}
// 受保护的域名要有 auth_request 与登录跳转
if !contains(out, "server_name protected.example.com;") {
t.Fatalf("缺少受保护域名的 server 块:\n%s", out)
}
if !contains(out, "auth_request /_auth;") {
t.Fatalf("受保护域名缺少 auth_request:\n%s", out)
}
if !contains(out, "location @login") {
t.Fatalf("缺少登录跳转")
}
if !contains(out, "proxy_set_header Upgrade $http_upgrade;") {
t.Fatalf("缺少 websocket 透传")
}
if !contains(out, "client_max_body_size 64m;") {
t.Fatalf("缺少上传上限")
}
// 不受保护的域名不得出现 auth_request("哪些服务受保护"是按路由决定的)
open := section(out, "server_name open.example.com;")
if open == "" {
t.Fatalf("缺少开放域名的 server 块")
}
// 断言的是"强制闸门" auth_request /_auth;,不是裸字符串:
// 错误页会合法地带 auth_request off;,用裸字符串会把正确配置判成错误。
if contains(open, "auth_request /_auth;") {
t.Fatalf("未受保护的域名不应有强制认证闸门:\n%s", open)
}
if !contains(open, "auth_request off;") {
t.Fatalf("错误页应显式关闭认证:\n%s", open)
}
// 兜底 server:未知域名必须被拒绝,而不是落进某条路由
if !contains(out, "default_server") || !contains(out, "return 404") {
t.Fatalf("缺少未知域名兜底 server:\n%s", out)
}
if contains(open, "client_max_body_size") {
t.Fatalf("未设置上限时不应生成 client_max_body_size:\n%s", open)
}
}
func TestRenderGatewayConfRejectsBadInput(t *testing.T) {
base := func(r Route) *GatewayConfig {
return &GatewayConfig{ListenPort: 8081, AuthURL: "http://x/_auth", Routes: []Route{r}}
}
cases := []struct {
name string
cfg *GatewayConfig
}{
{"空域名", base(Route{Host: "", Upstream: "http://a"})},
{"域名带协议", base(Route{Host: "http://a.com", Upstream: "http://a"})},
{"后端非 http", base(Route{Host: "a.com", Upstream: "10.0.0.1:80"})},
{"域名重复", &GatewayConfig{ListenPort: 8081, AuthURL: "http://x/_auth", Routes: []Route{
{Host: "a.com", Upstream: "http://a"}, {Host: "a.com", Upstream: "http://b"},
}}},
{"需认证但无认证地址", &GatewayConfig{ListenPort: 8081, Routes: []Route{{Host: "a.com", Upstream: "http://a", RequireLogin: true}}}},
{"启用HTTPS但无证书", &GatewayConfig{ListenPort: 8081, ListenHTTPS: true, Routes: []Route{{Host: "a.com", Upstream: "http://a"}}}},
}
for _, c := range cases {
if _, err := RenderGatewayConf(c.cfg); err == nil {
t.Errorf("%s: 应当报错但没有", c.name)
}
}
}
func TestGatewayRoundTrip(t *testing.T) {
dir := t.TempDir()
oldFile, oldOut := gatewayFile, gatewayOutDir
gatewayFile = dir + "/gateway.yml"
gatewayOutDir = dir + "/gateway"
defer func() { gatewayFile, gatewayOutDir = oldFile, oldOut }()
// 文件不存在时应给出默认配置而不是报错
cfg, err := loadGateway()
if err != nil {
t.Fatalf("缺文件应回落默认配置: %v", err)
}
if cfg.ListenPort != 8081 || len(cfg.Routes) != 0 {
t.Fatalf("默认配置不对: %+v", cfg)
}
cfg.Routes = []Route{{Host: "a.com", Upstream: "http://a", RequireLogin: true}}
if err := saveGateway(cfg); err != nil {
t.Fatalf("保存失败: %v", err)
}
got, err := loadGateway()
if err != nil || len(got.Routes) != 1 || !got.Routes[0].RequireLogin {
t.Fatalf("回读不一致: %+v err=%v", got, err)
}
}
func contains(hay, needle string) bool {
return len(hay) >= len(needle) && (func() bool {
for i := 0; i+len(needle) <= len(hay); i++ {
if hay[i:i+len(needle)] == needle {
return true
}
}
return false
})()
}
// section 取出 server_name 所属的那个 server 块(到下一个 server{ 之前)
func section(all, marker string) string {
i := indexOf(all, marker)
if i < 0 {
return ""
}
start := i
for start > 0 && all[start-1] != '\n' {
start--
}
rest := all[i:]
if j := indexOf(rest, "\nserver {"); j >= 0 {
return rest[:j]
}
return rest
}
func indexOf(hay, needle string) int {
for i := 0; i+len(needle) <= len(hay); i++ {
if hay[i:i+len(needle)] == needle {
return i
}
}
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("分组不存在时应报错")
}
}
// 登录域名必须指向容器内自带的登录服务,不能生成空的 proxy_pass。
func TestRenderGatewayConfAuthKind(t *testing.T) {
out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 8081, Routes: []Route{
{Host: "login.example.com", Kind: "auth"},
}})
if err != nil {
t.Fatalf("auth 类型不应要求后端: %v", err)
}
if !contains(out, "proxy_pass http://127.0.0.1:3005;") {
t.Fatalf("auth 类型应指向内置登录服务:\n%s", out)
}
if contains(out, "proxy_pass ;") {
t.Fatalf("生成了空的 proxy_pass:\n%s", out)
}
}
// TLS 段不得使用 nginx 1.22 不认识的 `http2 on;`,必须写成 listen 参数。
func TestRenderGatewayConfTLSHttp2Compatible(t *testing.T) {
out, err := RenderGatewayConf(&GatewayConfig{
ListenHTTPS: true, ListenPort: 18443, ListenTLSPort: 18443,
CertFile: "/c/fullchain.pem", KeyFile: "/c/privkey.pem",
Routes: []Route{{Host: "a.com", Upstream: "http://a"}},
})
if err != nil {
t.Fatalf("渲染失败: %v", err)
}
if contains(out, "http2 on;") {
t.Fatalf("不得输出 `http2 on;`(nginx 1.22 不支持):\n%s", out)
}
if !contains(out, "listen 18443 ssl http2;") {
t.Fatalf("TLS 监听应带 http2 参数:\n%s", out)
}
if !contains(out, "ssl_certificate '/c/fullchain.pem';") {
t.Fatalf("证书路径未写入")
}
}
// 错误页必须 auth_request off:否则上游故障会以 401 呈现,自定义错误页永远看不到。
func TestRenderGatewayConfErrorPagesSkipAuth(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", RequireLogin: true}}})
if err != nil {
t.Fatalf("渲染失败: %v", err)
}
for _, want := range []string{
"location = /500.html { internal; auth_request off; }",
"location = /404.html { internal; auth_request off; }",
"location ^~ /css/ { auth_request off;",
} {
if !contains(out, want) {
t.Fatalf("错误页缺少 auth_request off(%q):\n%s", want, out)
}
}
}
// 门户首页必须走 admin-server 渲染(否则 pageInfo 文案不可配置、占位符会裸露)。
func TestPortalKindUsesRenderer(t *testing.T) {
out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 8081, Routes: []Route{{Host: "p.com", Kind: "portal"}}})
if err != nil {
t.Fatalf("渲染失败: %v", err)
}
if !contains(out, "proxy_pass http://127.0.0.1:3005/portal-page;") {
t.Fatalf("门户首页未指向渲染器:\n%s", out)
}
if !contains(out, "root /app/portal;") {
t.Fatalf("静态资源根目录丢失:\n%s", out)
}
}
// nginx 不允许**同一个 server 段**内出现重复的 root 指令(实测 emerg)。
// 注意:不同 server 段各有一个 root 是正常的,所以必须按段计数。
func TestPortalKindNoDuplicateRoot(t *testing.T) {
out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 8081, Routes: []Route{{Host: "p.com", Kind: "portal"}}})
if err != nil {
t.Fatalf("渲染失败: %v", err)
}
block := section(out, "server_name p.com;")
if block == "" {
t.Fatalf("未找到门户段:\n%s", out)
}
if n := strings.Count(block, "root "); n != 1 {
t.Fatalf("门户段内 root 指令应恰好 1 次,实际 %d 次:\n%s", n, block)
}
}
// redirectWithPort=true 时跳转目标带入口端口(对外端口=入口端口的部署)。
func TestRedirectTargetIncludesEntryPort(t *testing.T) {
out, err := RenderGatewayConf(&GatewayConfig{
ListenPort: 3080, ListenTLSPort: 8443, RedirectPort: 3081, ListenHTTPS: true, RedirectWithPort: true,
CertFile: "/etc/nginx/certs/fullchain.pem", KeyFile: "/etc/nginx/certs/privkey.pem",
AuthURL: "http://127.0.0.1:3005/auth/verify", LoginURL: "https://login.x.com/login",
Routes: []Route{{Host: "x.com", Kind: "portal"}},
})
if err != nil {
t.Fatalf("渲染失败: %v", err)
}
if !strings.Contains(out, "return 302 https://$host:3080$request_uri;") {
t.Fatalf("跳转目标未带端口:\n%s", out)
}
}
// 标准 443 部署不应出现 :443 后缀。
func TestRedirectTargetOmitsPortOn443(t *testing.T) {
out, err := RenderGatewayConf(&GatewayConfig{
ListenPort: 443, RedirectPort: 3081, ListenHTTPS: true,
CertFile: "/etc/nginx/certs/fullchain.pem", KeyFile: "/etc/nginx/certs/privkey.pem",
AuthURL: "http://127.0.0.1:3005/auth/verify", LoginURL: "https://login.x.com/login",
Routes: []Route{{Host: "x.com", Kind: "portal"}},
})
if err != nil {
t.Fatalf("渲染失败: %v", err)
}
if !strings.Contains(out, "return 302 https://$host$request_uri;") || strings.Contains(out, "$host:443") {
t.Fatalf("443 部署跳转目标不正确:\n%s", out)
}
}
// 默认(redirectWithPort=false)即使入口不是 443 也不带端口——
// 生产就是这种:监听 3080,对外经 frp 是 443,带端口会把公网用户导向 :3080。
func TestRedirectDefaultsNoPortOnNonStandardPort(t *testing.T) {
out, err := RenderGatewayConf(&GatewayConfig{
ListenPort: 3080, RedirectPort: 3081, ListenHTTPS: true, RedirectWithPort: false,
CertFile: "/etc/nginx/certs/fullchain.pem", KeyFile: "/etc/nginx/certs/privkey.pem",
AuthURL: "http://127.0.0.1:3005/auth/verify", LoginURL: "https://login.x.com/login",
Routes: []Route{{Host: "x.com", Kind: "portal"}},
})
if err != nil {
t.Fatalf("渲染失败: %v", err)
}
if !strings.Contains(out, "return 302 https://$host$request_uri;") || strings.Contains(out, "$host:3080") {
t.Fatalf("默认不应带端口:\n%s", out)
}
}
// 注进被代理页面的备案号必须来自站点信息(可配置),不能再写死。
func TestBeianInjectionUsesPageInfo(t *testing.T) {
dir := t.TempDir()
old := confFile
defer func() { confFile = old }()
confFile = filepath.Join(dir, "conf.yml")
if err := os.WriteFile(confFile, []byte("pageInfo:\n icp: 京ICP备12345678号-1\n gongan: 京公网安备11000000000001号\n"), 0o644); err != nil {
t.Fatal(err)
}
out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 8081, Routes: []Route{{Host: "a.com", Upstream: "http://1.1.1.1:80", InjectBeian: true}}})
if err != nil {
t.Fatalf("渲染失败: %v", err)
}
if !strings.Contains(out, "京ICP备12345678号-1") || !strings.Contains(out, "京公网安备11000000000001号") {
t.Fatalf("未使用配置的备案号:\n%s", out)
}
}
// 未配置备案号时不注入(不得残留任何硬编码备案号)。
func TestBeianInjectionSkippedWhenUnset(t *testing.T) {
dir := t.TempDir()
old := confFile
defer func() { confFile = old }()
confFile = filepath.Join(dir, "conf.yml")
if err := os.WriteFile(confFile, []byte("pageInfo: {}\n"), 0o644); err != nil {
t.Fatal(err)
}
out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 8081, Routes: []Route{{Host: "a.com", Upstream: "http://1.1.1.1:80", InjectBeian: true}}})
if err != nil {
t.Fatalf("渲染失败: %v", err)
}
if strings.Contains(out, "navi-proxy-beian") {
t.Fatalf("未配置备案号却注入了:\n%s", out)
}
}
// 备案号里的引号/美元符不能破坏 nginx 配置。
func TestBeianInjectionSanitized(t *testing.T) {
dir := t.TempDir()
old := confFile
defer func() { confFile = old }()
confFile = filepath.Join(dir, "conf.yml")
if err := os.WriteFile(confFile, []byte("pageInfo:\n icp: \"a';b$c\"\n"), 0o644); err != nil {
t.Fatal(err)
}
out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 8081, Routes: []Route{{Host: "a.com", Upstream: "http://1.1.1.1:80", InjectBeian: true}}})
if err != nil {
t.Fatalf("渲染失败: %v", err)
}
if strings.Contains(out, "';b") || strings.Contains(out, "b$c") {
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)
}
}