package api import ( "context" "io" "log" "net/http" "net/url" "time" "github.com/gin-gonic/gin" "oci-portal/internal/service" ) // ONS 消息头(方案A §2.2/§2.3,官方 X-OCI-NS-* 字段)。 const ( onsHeaderMessageType = "X-OCI-NS-MessageType" onsHeaderMessageID = "X-OCI-NS-MessageId" onsHeaderTimestamp = "X-OCI-NS-Timestamp" onsHeaderConfirmURL = "X-OCI-NS-ConfirmationURL" ) // webhook 同步路径的防御参数:body 上限高于 ONS 链路 128KB 留余量, // 时间戳偏差超窗拒收防重放。 const ( webhookBodyLimit = 256 << 10 webhookMaxTimeSkew = 5 * time.Minute msgTypeSubscription = "SubscriptionConfirmation" msgTypeUnsubscribe = "UnsubscribeConfirmation" ) // webhookEvents 是 webhook 端点对回传服务的依赖切面,测试以假实现替换。 type webhookEvents interface { ResolveSecret(ctx context.Context, secret string) (uint, bool) ConfirmAsync(confirmURL string) Ingest(ctx context.Context, cfgID uint, messageID string, payload []byte, truncated bool) error } // webhookHandler 处理 OCI Notifications 的 HTTPS 订阅投递(JWT 组之外,靠 secret 鉴权)。 type webhookHandler struct { events webhookEvents } // handle 是 webhook 入口:secret 反查租户后按消息类型分派; // 同步路径只做 secret→分派→读body→幂等入库,5 秒投递预算内返回(方案A §2.4)。 func (h *webhookHandler) handle(c *gin.Context) { cfgID, ok := h.events.ResolveSecret(c.Request.Context(), c.Param("secret")) if !ok { c.Status(http.StatusNotFound) // 不暴露端点存在性,响应无 body return } switch c.GetHeader(onsHeaderMessageType) { case msgTypeSubscription: h.confirmSubscription(c) case msgTypeUnsubscribe: log.Printf("ons unsubscribe confirmation received: cfg %d", cfgID) c.Status(http.StatusOK) default: h.ingest(c, cfgID) } } // confirmSubscription 校验确认链接域名白名单后异步 GET 激活订阅,立即 200。 func (h *webhookHandler) confirmSubscription(c *gin.Context) { confirmURL := c.GetHeader(onsHeaderConfirmURL) parsed, err := url.Parse(confirmURL) if err != nil || parsed.Scheme != "https" || !service.IsOracleHost(parsed.Host) { log.Printf("ons confirmation url rejected: host not in oracle domain") c.Status(http.StatusBadRequest) return } h.events.ConfirmAsync(confirmURL) c.Status(http.StatusOK) } // ingest 校验时间戳与消息 ID 后读 body 入库;重复投递靠唯一索引幂等。 func (h *webhookHandler) ingest(c *gin.Context, cfgID uint) { if !timestampFresh(c.GetHeader(onsHeaderTimestamp)) { c.Status(http.StatusForbidden) return } messageID := c.GetHeader(onsHeaderMessageID) if messageID == "" { c.Status(http.StatusBadRequest) return } if err := verifySignature(c.Request.Header); err != nil { c.Status(http.StatusForbidden) return } body, err := io.ReadAll(io.LimitReader(c.Request.Body, webhookBodyLimit+1)) if err != nil { c.Status(http.StatusBadRequest) return } truncated := len(body) > webhookBodyLimit if truncated { body = body[:webhookBodyLimit] } if err := h.events.Ingest(c.Request.Context(), cfgID, messageID, body, truncated); err != nil { log.Printf("webhook ingest: %v", err) c.Status(http.StatusInternalServerError) return } c.Status(http.StatusOK) } // timestampFresh 校验消息时间戳与本地时钟偏差在防重放窗口内; // 头缺失时放行——ONS 是否恒带此头待验签 spike 确认(方案A §2.3),先不误杀。 func timestampFresh(value string) bool { if value == "" { return true } ts, ok := parseOnsTime(value) if !ok { return false } skew := time.Since(ts) if skew < 0 { skew = -skew } return skew <= webhookMaxTimeSkew } // onsTimestampLayouts 覆盖 ONS 实测时间戳格式与标准 RFC3339: // 2026-07-07 spike 抓包为 2026-07-07T05:30:34.779+0000(时区无冒号,严格 RFC3339 解析失败)。 var onsTimestampLayouts = []string{ time.RFC3339, "2006-01-02T15:04:05.999Z0700", } // parseOnsTime 按已知格式解析 ONS 时间戳。 func parseOnsTime(value string) (time.Time, bool) { for _, layout := range onsTimestampLayouts { if ts, err := time.Parse(layout, value); err == nil { return ts, true } } return time.Time{}, false } // verifySignature 校验 ONS 消息签名。 // TODO(方案A §2.3 spike):官方未公开签名 canonical form,待 test-server 抓取 // 真实消息确认输入格式后实现;当前按方案约定以 secret+时间戳+幂等先行。 func verifySignature(http.Header) error { return nil }