fix(gateway): 路由表接口只替换路由,不再整份配置重置(agent 友好)
真实验证(干净环境练兵)抓到:POST /admin/api/gateway/routes 会把整个
GatewayConfig 反序列化为零值后保存,因此
- 只提交单个路由对象 → {"routes":0,"status":"saved"}:**静默清空整张路由表**
- 提交 {"routes":[…] } → 其余字段被重置为默认(实测入口端口 13080 → 8081,
TLS/认证地址一并丢失),随后 apply 生成的配置与预期完全不符
对 agent 尤其危险:照文档提交一条路由就会改掉入口端口与所有其它路由。
修复:
- 只补路由表:以现有配置为基准,仅替换 Routes,其它字段原样保留
- 请求体必须是 {"routes":[...]};缺该键 → 400 并说明正确形状(不再静默变空表)
- 非空表要清空必须显式传 force:true,否则 400 拒绝
- 测试:单个对象被拒;数组提交后 listenPort/TLS/认证地址/证书路径全部保留;
清空被拒、force 允许
- README:修正路由示例为完整数组形状,补"替换整张表"与 /admin/api 前缀说明
This commit is contained in:
@ -1070,14 +1070,32 @@ func handleGatewayRoutes(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
var req GatewayConfig
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
// 只替换路由表:入口端口、TLS、认证地址等必须原样保留。
|
||||
// 旧实现把整个 GatewayConfig 解成零值再保存,提交部分字段会把其它配置
|
||||
// 重置为默认(实测把入口端口 13080 变成 8081,对 agent 尤其危险)。
|
||||
var body struct {
|
||||
Routes *[]Route `json:"routes"`
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
jsonErr(w, 400, "请求体解析失败")
|
||||
return
|
||||
}
|
||||
if req.Routes == nil {
|
||||
req.Routes = []Route{}
|
||||
if body.Routes == nil {
|
||||
jsonErr(w, 400, `请用 {"routes":[...]} 提交完整路由表;本接口只替换路由表,不改动其它配置`)
|
||||
return
|
||||
}
|
||||
cur, err := loadGateway()
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if len(*body.Routes) == 0 && len(cur.Routes) > 0 && !body.Force {
|
||||
jsonErr(w, 400, "拒绝把非空路由表清空:如确要清空请传 force:true")
|
||||
return
|
||||
}
|
||||
cur.Routes = *body.Routes
|
||||
req := *cur
|
||||
for _, rt := range req.Routes {
|
||||
if err := ValidateRoute(rt); err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
|
||||
@ -289,3 +289,52 @@ func TestApplyRefusesEmptyRoutesOverExisting(t *testing.T) {
|
||||
t.Fatalf("错误信息应说明原因:%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 提交路由表不得改动入口端口/TLS/认证地址等其它配置(旧实现会重置为默认)。
|
||||
func TestGatewayRoutesPostPreservesOtherConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
oldC, oldG := confFile, gatewayFile
|
||||
defer func() { confFile, gatewayFile = oldC, oldG }()
|
||||
confFile = filepath.Join(dir, "conf.yml")
|
||||
gatewayFile = filepath.Join(dir, "gateway.yml")
|
||||
// 该接口同时会同步 Dashy 导航数据,需要一份 conf.yml
|
||||
if err := os.WriteFile(confFile, []byte("pageInfo: {}\nsections: []\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seed := "listenPort: 13080\nlistenTlsPort: 18443\nredirectPort: 13081\nlistenHttps: false\n" +
|
||||
"certFile: /etc/nginx/certs/fullchain.pem\nkeyFile: /etc/nginx/certs/privkey.pem\n" +
|
||||
"authUrl: http://127.0.0.1:3005/auth/verify\nloginUrl: https://login.example.com/login\n" +
|
||||
"routes:\n - host: a.example.com\n upstream: http://127.0.0.1:3000\n"
|
||||
if err := os.WriteFile(gatewayFile, []byte(seed), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
post := func(body string) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
handleGatewayRoutes(rec, httptest.NewRequest(http.MethodPost, "/api/gateway/routes", strings.NewReader(body)))
|
||||
return rec
|
||||
}
|
||||
// ① 只给单个路由对象(旧接口会当成"整份配置"并把其它字段清零)
|
||||
if rec := post(`{"host":"b.example.com","upstream":"http://127.0.0.1:4000"}`); rec.Code != 400 {
|
||||
t.Fatalf("单个对象应被拒绝并提示,实际 %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// ② 正确形状:数组
|
||||
if rec := post(`{"routes":[{"host":"b.example.com","upstream":"http://127.0.0.1:4000"}]}`); rec.Code != 200 {
|
||||
t.Fatalf("数组提交应成功,实际 %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
got, _ := os.ReadFile(gatewayFile)
|
||||
body := string(got)
|
||||
for _, want := range []string{"listenPort: 13080", "listenTlsPort: 18443", "redirectPort: 13081",
|
||||
"certFile: /etc/nginx/certs/fullchain.pem", "authUrl: http://127.0.0.1:3005/auth/verify",
|
||||
"loginUrl: https://login.example.com/login", "host: b.example.com"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("保存后丢失 %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
// ③ 防误清空
|
||||
if rec := post(`{"routes":[]}`); rec.Code != 400 {
|
||||
t.Fatalf("清空应被拒绝,实际 %d", rec.Code)
|
||||
}
|
||||
if rec := post(`{"routes":[],"force":true}`); rec.Code != 200 {
|
||||
t.Fatalf("force 清空应被允许,实际 %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user