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