针对反馈的五个问题: 1. 后台"没有映射":新增 /admin/api/gateway/import 与界面「从现有 nginx 配置导入」, 把老统一入口的 nginx 配置贴进来即可解析出全部「域名 → 后端」初稿 (含 requireLogin / 免认证路径 / SSE 流式 / 备案注入 / 自签证书 / auth 与 portal 类型)。 解析只读不落盘,确认后才保存;导入默认不接管导航,避免一导入就改掉手工导航。 2. 添加转发时没有导航选项:添加表单补齐「同时加入 Dashy 导航 / 分组 / 名称 / 描述 / 图标 / 自动抓取」。 3. 添加转发时没有 portal 保护选项:添加表单补齐「启用 portal 统一登录保护」, 并补上 WebSocket 透传与上传上限。 4. 设置里无法配置 portal 账号:新增统一登录账号卡片,账号存数据卷 (portal-auth.json,只存加盐哈希),优先级 数据卷 > 环境变量, 改完立即生效、无需重新部署;会话校验按当前用户名,改账号后旧会话失效。 5. 设置项太乱:按用途分组(站点与外观 / 导航内容 / 历史记录 / 转发与统一登录 / 高级), 外观卡片并入站点组。 另修两个本轮引入的缺陷: - 容器假 unhealthy:内部端口改 18080 后,基础镜像的 HEALTHCHECK 仍指向 8080; 部署脚本生成的 Dockerfile 现在显式覆盖为 :18080/healthz。 - kind=auth 不依赖 portal-auth 容器,指向内置登录服务;避免生成空 proxy_pass。 测试:nginx 配置解析(含兜底段忽略、多域名段、类型判定、默认不接管导航)、 垃圾输入报错、账号存储(不明文、可校验、改名后旧口令失效)、auth 类型生成。
317 lines
10 KiB
Go
317 lines
10 KiB
Go
package main
|
||
|
||
import (
|
||
"os"
|
||
"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 块")
|
||
}
|
||
if contains(open, "auth_request") {
|
||
t.Fatalf("未受保护的域名不应有 auth_request:\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)
|
||
}
|
||
}
|