Files
oci-portal/internal/api/webhook.go
T
wangdefa 0a86b5a291
CI / test (push) Successful in 32s
Release / release (push) Successful in 1m4s
AI网关新增TTS/重排/审核端点,xAI工具扩展,swagger修缺
- 新端点 /ai/v1/audio/speech(xai.grok-tts)、/rerank(cohere.rerank-v4)、/moderations(OCI Guardrails)
- Responses 放行 code_interpreter 与远程 mcp 工具,web_search/x_search 解除仅非流式限制
- 模型能力映射扩展:TEXT_RERANK→RERANK、TEXT_TO_AUDIO→TTS
- AI 网关文档独立 docs/ai-gateway.md,字段兼容矩阵只列支持项;README 精简引用
- swagger 修缺:135 处响应注解具体化,RawMessage/联合类型统一渲染 AnyJSON,overrides 迁至 docs/.swaggo
- CHANGELOG 0.4.0,版本段不再记日期;DASH_VERSION v0.4.0
2026-07-13 20:17:06 +08:00

178 lines
6.1 KiB
Go

package api
import (
"context"
"errors"
"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"
onsHeaderSigningCertURL = "X-OCI-NS-SigningCertURL"
onsHeaderSignature = "X-OCI-NS-Signature"
)
// 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)。
//
// @Summary OCI 日志回传入口(ONS HTTPS 订阅投递)
// @Tags 任务与日志回传
// @Param secret path string true "面板生成的回传密钥(鉴权凭据)"
// @Param body body object true "ONS 消息原文(SubscriptionConfirmation / Notification)"
// @Success 200 "已受理"
// @Failure 403 {object} errorResponse "时间戳超窗或证书源非 Oracle 域"
// @Failure 404 "secret 无效(不暴露端点存在性)"
// @Router /api/v1/webhooks/oci-logs/{secret} [post]
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 消息签名。
//
// 现状:官方未公开 canonical form。归档任务 07-06-log-relay-plan-a 与 07-07-relay-impl
// 用真实抓包样本(body+签名+证书公钥)对 15 种常见拼接 × 2 种 PSS salt 长度全部未命中,
// 逆向成本高。P0 决策以「secret 路径段 + HTTPS + 时间戳偏差 ≤5min + MessageId 幂等」上线。
//
// 本函数做能做的**证书源头硬化**:即便未来接入 canonical form + 公钥验签,
// SigningCertURL 若允许任意域名,攻击者可自签证书构造 body+签名+cert URL 三元组绕过
// (AWS SNS 曾出现同类 GHSA-8jgf-23q5-x7xx)。这里预先把 cert URL 域名锁到 Oracle 云,
// 让未来加签名校验成为「相加」而非「返修」。
func verifySignature(h http.Header) error {
certURL := h.Get(onsHeaderSigningCertURL)
if certURL == "" {
return nil // 头缺失回退到 secret+时间戳+幂等三重防护,不误杀历史 layout
}
parsed, err := url.Parse(certURL)
if err != nil || parsed.Scheme != "https" || !service.IsOracleHost(parsed.Host) {
return errors.New("ons: signing cert url not in oracle domain")
}
// TODO(canonical form 公开后):按 SigningCertURL 拉证书(含 host 复核 + 缓存),
// 用公钥对 signing string 做 SHA256withRSA/PSS 验签,失败返回 error。
return nil
}