From bb41b61ad666842b771b418b61d0d0c561c33c69 Mon Sep 17 00:00:00 2001 From: HomeDash Bot Date: Sat, 12 Sep 2026 10:28:03 +0800 Subject: [PATCH] =?UTF-8?q?fix(gateway):=20=E8=B7=AF=E7=94=B1=E8=A1=A8?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E5=8F=AA=E6=9B=BF=E6=8D=A2=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=86=8D=E6=95=B4=E4=BB=BD=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E9=87=8D=E7=BD=AE=EF=BC=88agent=20=E5=8F=8B=E5=A5=BD=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真实验证(干净环境练兵)抓到: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 前缀说明 --- README.md | 4 +++- admin-server/main.go | 26 +++++++++++++++++---- admin-server/main_test.go | 49 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d968e4b..dc155dd 100644 --- a/README.md +++ b/README.md @@ -574,12 +574,14 @@ curl -s http://127.0.0.1:18080/api/schema | jq # 人读:items + resourc 导航分组与条目、服务转发路由表、门户页卡片、壁纸、统一登录账号、浏览器历史、图标上传、重载。 写操作需要 `Authorization: Bearer `(读与后台页面同源,可直接 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` | diff --git a/admin-server/main.go b/admin-server/main.go index 5ba550e..fafa8ce 100644 --- a/admin-server/main.go +++ b/admin-server/main.go @@ -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()) diff --git a/admin-server/main_test.go b/admin-server/main_test.go index 0978605..0a079be 100644 --- a/admin-server/main_test.go +++ b/admin-server/main_test.go @@ -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()) + } +}