初始提交:OCI 面板后端(含 GenAI 网关一期)
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user