Files
HomeDash/admin-server/gateway_test.go
HomeDash Bot b99319b38b chore(sanitize): 备案号与域名一律可配置,仓库不再预置真实域名/备案号/内网地址
用户要求:备案号与域名都应当可配置,仓库内容要脱敏。

功能(不只是替换字面量):
- 被代理页面注入的备案号改为读后台「站点信息」的 icp/gongan,**未配置就不注入**;
  原先写死具体备案号,等于每个部署者都会把别人的备案信息印到自己页面上
- 注入内容做 nginx 安全处理(去引号/反斜杠/换行/美元符),并有测试覆盖
- auth.go:PORTAL_COOKIE_DOMAIN / PORTAL_HOME_URL 不再预置域名,
  cookie 默认 host-only、首页默认相对路径 "/"
- gateway.go:默认 loginUrl 不再预置域名(受保护路由缺配置会明确报错,不静默生成跳不出去的登录页)
- 首装脚本在给了 NAVI_DOMAIN 时自动注入 PORTAL_COOKIE_DOMAIN/PORTAL_HOME_URL,
  避免"去掉写死默认值"导致跨子域单点登录失效

脱敏:内网 IP(10.0.0.10)与真实备案号清零;真实域名仅剩门户页内容 8 处(待定);
测试夹具/示例/错误页/脚本/后台占位提示全部通用化。

README:新增「跨子域单点登录需要的两个环境变量」;环境变量表改为 NAVI_HOST 必填。
测试:新增备案号注入三条(用配置、未配置不注入、危险字符清理)。
2026-09-12 10:10:52 +08:00

496 lines
18 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)
}
}