Skip to content

Commit e7a2bfa

Browse files
committed
修复:改续费策略等四个接口把只读字段发回给 OVH,被 400 拒绝
症状:点「修改续费策略 → 到期注销 → 保存」,OVH 回 400 "Try to alter read-only properties: status, creation, expiration"。 原因:代码 GET 回整个 serviceInfos 对象,改掉 renew 字段,再把**整个对象** PUT 回去。而 services.Service 里只有 renew 一个字段可写,其余 12 个 (status / creation / expiration / serviceId / contact* / domain / engagedUpTo / possibleRenewPeriod / renewalType / canDeleteAtExpiration) 全是只读。原注释写着「OVH PUT 要求完整对象」——这个假设是错的, 三区 schema 在这一点上完全一致。 同样的写法还有三处,只是还没被点到,一并修了: /dedicated/server/{sn}/serviceInfos 只有 renew 可写 独服续费策略 /vps/{sn}/serviceInfos 只有 renew 可写 VPS 续费策略 /vps/{sn}/snapshot 只有 description VPS 快照描述 /vps/{sn}/ips/{ip} 只有 reverse VPS 反向 DNS 四处都改成只发可写字段。反向 DNS 和快照描述连那次 GET 都不需要了—— 本来就只是为了拿回整个对象再发回去。 另外写脚本把「GET 进某变量 → 同一变量被 PUT 出去」这个模式在全代码扫了一遍, 现在是零。 顺带修正 purchase.go 里一条把机制说错的注释:assign 传 nil 而不是 {} 的理由 不是「{} 会破坏签名」——go-ovh 的签名是对实际发出的 body 算的(nil→空串, {}→"{}"),两边自洽,签名不会失配。真正的理由只是 schema 没声明 body 就别发。 全量三区复审 四个并行审计员对着 EU/US/CA 三份官方 schema 核了全部调用点: 独服 106 处、order/me/services 71 处、ip+installationTemplate 19 处、 VPS 46 处(34 条不同路径),四类问题(只读字段、区域门控、必填参数与 方法、枚举取值)除上述四处外均未发现可坐实的缺陷。 之前那轮审计漏掉这四处的原因是口径问题:当时核的是「路径存不存在、 区域差异、枚举取值」,没有系统性核对 request body 字段与 model 的 readOnly 标记。四个漏网点全在这一个盲区里。
1 parent 5e823f5 commit e7a2bfa

5 files changed

Lines changed: 30 additions & 24 deletions

File tree

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.4
1+
0.1.5

server/internal/handlers/server_control_hardware.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,8 @@ func GetServiceInfo(state *app.State) gin.HandlerFunc {
473473
// UpdateServiceRenewal PUT /api/server-control/:service_name/serviceinfo
474474
//
475475
// 修改服务的续费策略。前端传 mode (auto / manual / delete-at-expiration) + 可选 period,
476-
// 后端先 GET 当前 serviceInfos,合并 renew 字段,再 PUT 整体回去(OVH PUT 要求完整对象)。
476+
// 后端先 GET 当前 serviceInfos 读出 renew 现状,改完只 PUT 回 renew 这一个字段 ——
477+
// services.Service 里其余字段全是只读,一并发回去会被 OVH 400 掉。
477478
// forced=true (engaged 合同期) 时 OVH 会拒,我们这里直接返 400 提示用户。
478479
func UpdateServiceRenewal(state *app.State) gin.HandlerFunc {
479480
return func(c *gin.Context) {
@@ -526,10 +527,15 @@ func UpdateServiceRenewal(state *app.State) gin.HandlerFunc {
526527
if body.Period > 0 {
527528
renew["period"] = body.Period
528529
}
529-
info["renew"] = renew
530+
delete(renew, "forced") // OVH 自己算的锁,回传没意义
530531

531-
// PUT 整对象回去(OVH 这个端点要求完整 services.Service)
532-
if err := client.Put("/dedicated/server/"+svc+"/serviceInfos", info, nil); err != nil {
532+
// 只发可写字段。services.Service 里唯一可写的就是 renew,
533+
// 其余(status / creation / expiration / serviceId / contact* ...)全是只读 ——
534+
// 把 GET 回来的整个对象原样 PUT 回去,OVH 会直接 400
535+
// "Try to alter read-only properties: status, creation, expiration"。
536+
// 三个区的 schema 在这一点上完全一致。
537+
if err := client.Put("/dedicated/server/"+svc+"/serviceInfos",
538+
map[string]interface{}{"renew": renew}, nil); err != nil {
533539
state.Logger.Error("修改服务器 "+svc+" 续费策略失败: "+err.Error(), "server_control")
534540
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
535541
return

server/internal/handlers/vps_control_basic.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -348,8 +348,10 @@ func UpdateVpsRenewal(state *app.State) gin.HandlerFunc {
348348
if body.Period > 0 {
349349
renew["period"] = body.Period
350350
}
351-
info["renew"] = renew
352-
if err := client.Put("/vps/"+svc+"/serviceInfos", info, nil); err != nil {
351+
delete(renew, "forced")
352+
// 同独服:services.Service 只有 renew 可写,整对象发回去会被 400
353+
if err := client.Put("/vps/"+svc+"/serviceInfos",
354+
map[string]interface{}{"renew": renew}, nil); err != nil {
353355
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
354356
return
355357
}
@@ -414,13 +416,11 @@ func SetVpsIpReverse(state *app.State) gin.HandlerFunc {
414416
Reverse string `json:"reverse"`
415417
}
416418
_ = c.ShouldBindJSON(&body)
417-
var current map[string]interface{}
418-
if err := client.Get("/vps/"+svc+"/ips/"+ip, &current); err != nil {
419-
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
420-
return
421-
}
422-
current["reverse"] = body.Reverse
423-
if err := client.Put("/vps/"+svc+"/ips/"+ip, current, nil); err != nil {
419+
// vps.Ip 里只有 reverse 可写(ipAddress / type / version / gateway /
420+
// geolocation / macAddress 都是只读),所以不需要先 GET 再 merge ——
421+
// 那样反而会把只读字段一起发回去,被 OVH 400 掉
422+
if err := client.Put("/vps/"+svc+"/ips/"+ip,
423+
map[string]interface{}{"reverse": body.Reverse}, nil); err != nil {
424424
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
425425
return
426426
}

server/internal/handlers/vps_control_snapshot.go

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -100,14 +100,11 @@ func UpdateVpsSnapshotDescription(state *app.State) gin.HandlerFunc {
100100
Description string `json:"description"`
101101
}
102102
_ = c.ShouldBindJSON(&body)
103-
// 先 GET 拿完整对象再 merge,跟 PUT serviceInfos 同款 read-modify-write
104-
var snap map[string]interface{}
105-
if err := client.Get("/vps/"+svc+"/snapshot", &snap); err != nil {
106-
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
107-
return
108-
}
109-
snap["description"] = body.Description
110-
if err := client.Put("/vps/"+svc+"/snapshot", snap, nil); err != nil {
103+
// vps.Snapshot 里只有 description 可写(id / creationDate / region 只读),
104+
// 所以直接发这一个字段。以前是 GET 回整个对象再 merge 回去,
105+
// 那会把只读字段一并发回,被 OVH 400 掉
106+
if err := client.Put("/vps/"+svc+"/snapshot",
107+
map[string]interface{}{"description": body.Description}, nil); err != nil {
111108
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
112109
return
113110
}

server/internal/purchase/purchase.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,8 +203,11 @@ func PurchaseServer(state *app.State, item *types.QueueItem) Outcome {
203203
// cart → assign → eco → configuration → options → summary → checkout。
204204
// 在 add item 之前 assign,OVH 后端不会出现"cart 未绑定就 checkout"的边界错误。
205205
// schema 里 POST /order/cart/{cartId}/assign 只有 path 参数、没有 body(对比同一个
206-
// 命名空间下的 /eco 与 /checkout 都明确列了 body),传 {} 会被算进请求签名,
207-
// OVH 一旦收紧参数校验就会在绑定这一步整单失败
206+
// 命名空间下的 /eco 与 /checkout 都明确列了 body),所以这里传 nil 而不是 {}。
207+
//
208+
// 注意别把理由记成"{} 会破坏签名"——go-ovh 的签名是对**实际发出的 body** 算的
209+
// (nil→空串,{}→"{}"),两边自洽,签名不会因此失配。真正的理由只是:
210+
// schema 没声明 body 就别发 body,少一个 OVH 将来收紧校验时会绊住的东西。
208211
state.Logger.Info("绑定购物车 "+cartID, "purchase")
209212
if err := client.Post("/order/cart/"+cartID+"/assign", nil, nil); err != nil {
210213
errMsg := err.Error()

0 commit comments

Comments
 (0)