目标:不再手工维护外部 nginx 配置,把"域名→内网后端、是否走统一登录认证"
变成后台可编辑的数据,由本进程生成 nginx 配置并热加载。
本提交只做基础层(纯 Go、可测、零行为变更):
- gateway.go:Route/H gatewayConfig 数据模型 + ValidateRoute + RenderGatewayConf
· 认证**按路由**决定:RequireLogin 为真才生成 auth_request 与登录跳转,
也就是"哪些服务受统一认证保护"完全由路由表决定
· 支持 websocket 透传、单域名上传上限、HTTPS 段(可同时生成旁路端口)
· 路由表单独存 gateway.yml(不混进 Dashy 的 conf.yml),写坏了也不影响导航页
- API:GET/POST /admin/api/gateway/routes(保存)、POST /admin/api/gateway/apply
· apply 的顺序是 生成 → nginx -t 校验 → 通过才 reload;**校验失败把上一份可用
配置写回去并把 nginx 原始报错回给界面**,避免"点了应用整个入口挂掉"
- nginx.conf 加 include /app/data/gateway/*.conf(生成物在容器自己的卷里,
不碰宿主机文件,天然避开"单文件 bind mount 不能用 sed -i"的坑)
- 生成物默认监听旁路端口(8081/8443):可以先与正式入口并存验证,再切 80/443
测试:受保护域名必须有 auth_request、未受保护域名必须没有;websocket/上传上限;
空域名/带协议域名/非 http 后端/域名重复/需认证却无认证地址/启用 HTTPS 却无证书
六种非法输入必须报错;缺文件时回落默认配置、保存后回读一致。
133 lines
4.1 KiB
Go
133 lines
4.1 KiB
Go
package main
|
||
|
||
import "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)
|
||
}
|
||
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
|
||
}
|