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:
@ -574,12 +574,14 @@ curl -s http://127.0.0.1:18080/api/schema | jq # 人读:items + resourc
|
||||
导航分组与条目、服务转发路由表、门户页卡片、壁纸、统一登录账号、浏览器历史、图标上传、重载。
|
||||
|
||||
写操作需要 `Authorization: Bearer <NAVI_ADMIN_TOKEN>`(读与后台页面同源,可直接 GET)。
|
||||
管理接口前缀是 **`/admin/api`**(经统一入口),容器内也可直连 `http://127.0.0.1:3005/api`。
|
||||
提交路由表只影响路由表,**不会**改动入口端口/TLS/认证地址等其它配置。
|
||||
|
||||
| 想改什么 | 接口 |
|
||||
| --- | --- |
|
||||
| 站点信息 / 各页文案 / 备案号 | `POST /api/pageinfo`(字段见 `/api/schema`;空串=回落默认) |
|
||||
| 门户页服务卡片 | `POST /api/portal-cards` `{"html":"…"}`(空串=恢复内置) |
|
||||
| 服务转发路由 | `POST /api/gateway/routes` 后**必须** `POST /api/gateway/apply`(失败保留旧配置) |
|
||||
| 服务转发路由 | `POST /api/gateway/routes`(**提交完整 `{"routes":[…]}`,替换整张表**;单个对象会被拒绝;清空需 `force:true`)后**必须** `POST /api/gateway/apply`(失败保留旧配置) |
|
||||
| 导入现有 nginx 配置 | `POST /api/gateway/import` |
|
||||
| 导航分组 / 条目 | `POST /api/section`、`/api/section/update`、`DELETE /api/section`、`POST /api/item`、`/api/item/delete` |
|
||||
| 壁纸 | `POST /api/wallpaper` |
|
||||
|
||||
@ -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