0.10.1: 安全与业务逻辑加固、新品牌与部署加固

三轮审查修复(60+ 项),相对远端 main(b536672)的关键变更:
- 安全: 数据面 SSRF 拨号防护(防 DNS rebinding)/上游凭据剥离/登录防枚举
  与锁定态统一/可信代理(X-Forwarded-For)限流加固/会话版本失效机制/
  撤销即时传播/弱密钥拒绝启动/脱敏字节级重写(保签名契约)
- 业务逻辑: 裸 body 上传 panic/bootstrap 审计管线卡死/定价通配符优先级/
  全局工具可见性/调度器停机补跑/TOTP 挑战令牌消费顺序/熔断探针语义/
  >4MB 响应 token 计量/管理员重置密码作废会话 等
- 前端: 新 logo(语枢 AI 网关主题)/Provider 凭据异常警示/删除入口/
  后端错误消息透传/localStorage 敏感数据收敛
- 部署: CREDENTIAL_MASTER_KEY 持久化与弱值拒绝/Provider DELETE 接口/
  nginx 安全头/worker 内存限制
- 新增迁移 000029(key_hash 索引)/000030(usage_daily 币种维度)
This commit is contained in:
2026-08-13 10:50:51 +08:00
parent b536672000
commit 9501751792
136 changed files with 8024 additions and 1476 deletions
+43 -3
View File
@@ -14,6 +14,7 @@ import (
"net/http"
"strings"
"time"
"unicode/utf8"
"aigateway.local/core/internal/platform/cryptox"
"aigateway.local/core/internal/provider"
@@ -253,7 +254,13 @@ func (s *NotificationService) deliver(ctx context.Context, channel NotificationC
message = fmt.Sprintf("Webhook 返回 HTTP %d", status)
}
if len(message) > 1000 {
message = message[:1000]
// 按字节截断可能切半多字节 rune;无效 UTF-8 会被 PostgreSQL 拒绝,
// 使投递记录无法更新,事件永远重试。
cut := message[:1000]
for len(cut) > 0 && !utf8.RuneStart(cut[len(cut)-1]) {
cut = cut[:len(cut)-1]
}
message = cut
}
_, dbErr := s.assets.pool.Exec(context.WithoutCancel(ctx), `UPDATE gateway.notification_deliveries SET status=$2,attempts=attempts+1,response_status=nullif($3,0),last_error=$4,delivered_at=CASE WHEN $2='delivered' THEN clock_timestamp() ELSE delivered_at END,updated_at=clock_timestamp() WHERE id=$1`, delivery.ID, map[bool]string{true: "delivered", false: "failed"}[success], status, message)
if dbErr != nil {
@@ -280,6 +287,7 @@ func (s *NotificationService) RetryDelivery(ctx context.Context, id string) erro
type NotificationDispatcher struct {
service *NotificationService
inbox *InboxService
redis *redis.Client
stream, group, consumer string
logger *slog.Logger
@@ -288,6 +296,10 @@ type NotificationDispatcher struct {
func NewNotificationDispatcher(service *NotificationService, client *redis.Client, stream, consumer string, logger *slog.Logger) *NotificationDispatcher {
return &NotificationDispatcher{service: service, redis: client, stream: stream, group: "gateway-notifications-v1", consumer: consumer, logger: logger}
}
// SetInbox wires the in-app inbox materializer (M8 P4). 为 nil 时站内消息不落库,
// Webhook 投递不受影响。handle 内幂等:inbox 以 (source_event_id, 收件人) 去重。
func (d *NotificationDispatcher) SetInbox(inbox *InboxService) { d.inbox = inbox }
func (d *NotificationDispatcher) Run(ctx context.Context) error {
if err := d.redis.XGroupCreateMkStream(ctx, d.stream, d.group, "$").Err(); err != nil && !strings.Contains(err.Error(), "BUSYGROUP") {
return err
@@ -414,11 +426,25 @@ func (d *NotificationDispatcher) handle(ctx context.Context, message redis.XMess
eventID := fmt.Sprint(message.Values["event_id"])
eventType := fmt.Sprint(message.Values["event_type"])
payload := json.RawMessage(fmt.Sprint(message.Values["payload"]))
// M8 P4:物化站内消息。失败与 webhook 同语义——事件留在 pending,由 reclaim 重试;
// inbox 幂等(ON CONFLICT)保证重放不产生重复消息。
if d.inbox != nil {
if err := d.inbox.Materialize(ctx, eventID, eventType, payload); err != nil {
return err
}
}
channels, err := d.service.ListChannels(ctx)
if err != nil {
return err
}
selectedChannelID := payloadValue(payload, "notification_channel_id")
if selectedChannelID == "null" {
selectedChannelID = ""
}
for _, channel := range channels {
if selectedChannelID != "" && channel.ID != selectedChannelID {
continue
}
if !channel.Enabled || !matchesEvent(channel.EventPatterns, eventType) {
continue
}
@@ -429,9 +455,23 @@ func (d *NotificationDispatcher) handle(ctx context.Context, message redis.XMess
if delivery.Status == "delivered" {
continue
}
if deliveryErr = d.service.deliver(ctx, channel, delivery); deliveryErr != nil && d.logger != nil {
d.logger.Warn("webhook delivery failed", "channel", channel.Name, "event_id", eventID, "error", deliveryErr)
if deliveryErr = d.service.deliver(ctx, channel, delivery); deliveryErr != nil {
// 投递失败必须让事件留在 pending 列表由 reclaim 重试;但重试预算
// 耗尽后放弃自动重试(投递记录保留 failed 状态,管理端可人工重试),
// 否则永久失败的 Webhook 会让事件无限期卡在 pending,阻塞该事件
// 的其它通道投递与站内消息。
if delivery.Attempts >= webhookMaxAttempts {
if d.logger != nil {
d.logger.Error("webhook delivery exhausted retries; manual retry available in admin",
"channel", channel.Name, "event_id", eventID, "attempts", delivery.Attempts, "error", deliveryErr)
}
continue
}
return deliveryErr
}
}
return nil
}
// webhookMaxAttempts 是单条投递记录的自动重试上限;deliver 每次失败 attempts+1。
const webhookMaxAttempts = 10