Files
superidou 9501751792 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 币种维度)
2026-08-13 10:50:51 +08:00

316 lines
9.2 KiB
Go

package audit
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"sync/atomic"
"time"
platformid "aigateway.local/core/internal/platform/id"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
)
type Event struct {
TenantID *string
RequestID string
APIKeyID *string
ProviderCode string
Model string
Protocol string
StatusCode int
PromptTokens int64
CompletionTokens int64
CostMicrounits int64
PriceID string
Currency string
LatencyMS int
Labels map[string]any
RecordedAt time.Time
}
type Stats struct {
Accepted uint64
Dropped uint64
Written uint64
Failures uint64
}
type Recorder struct {
pool *pgxpool.Pool
logger *slog.Logger
queue chan Event
batchSize int
flushInterval time.Duration
accepted atomic.Uint64
dropped atomic.Uint64
written atomic.Uint64
failures atomic.Uint64
}
func NewRecorder(pool *pgxpool.Pool, logger *slog.Logger, queueSize, batchSize int, flushInterval time.Duration) *Recorder {
if queueSize < 1 {
queueSize = 4096
}
if batchSize < 1 {
batchSize = 200
}
if flushInterval <= 0 {
flushInterval = time.Second
}
return &Recorder{pool: pool, logger: logger, queue: make(chan Event, queueSize), batchSize: batchSize, flushInterval: flushInterval}
}
func (r *Recorder) Record(event Event) bool {
if r == nil || r.pool == nil {
return false
}
if event.RecordedAt.IsZero() {
event.RecordedAt = time.Now().UTC()
}
select {
case r.queue <- event:
r.accepted.Add(1)
return true
default:
r.dropped.Add(1)
return false
}
}
func (r *Recorder) Stats() Stats {
if r == nil {
return Stats{}
}
return Stats{Accepted: r.accepted.Load(), Dropped: r.dropped.Load(), Written: r.written.Load(), Failures: r.failures.Load()}
}
func (r *Recorder) Prometheus() string {
stats := r.Stats()
return fmt.Sprintf("gateway_audit_events_accepted_total %d\ngateway_audit_events_dropped_total %d\ngateway_audit_events_written_total %d\ngateway_audit_flush_failures_total %d\n", stats.Accepted, stats.Dropped, stats.Written, stats.Failures)
}
func (r *Recorder) Run(ctx context.Context) {
ticker := time.NewTicker(r.flushInterval)
defer ticker.Stop()
pending := make([]Event, 0, r.batchSize)
flushAllowed := true
for {
var events <-chan Event = r.queue
if len(pending) >= cap(r.queue)+r.batchSize {
events = nil
}
select {
case event := <-events:
pending = append(pending, event)
if len(pending) >= r.batchSize && flushAllowed {
if r.flush(ctx, pending) == nil {
pending = pending[:0]
} else {
flushAllowed = false
}
}
case <-ticker.C:
flushAllowed = true
if len(pending) > 0 {
if r.flush(ctx, pending) == nil {
pending = pending[:0]
} else {
flushAllowed = false
}
}
case <-ctx.Done():
for {
select {
case event := <-r.queue:
pending = append(pending, event)
default:
// 关闭前的最后一次落盘:数据库短暂不可用时重试有限次数,
// 而不是只试一次就把整批审计事件静默丢弃。
flushCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
if len(pending) > 0 {
lastErr := r.flush(flushCtx, pending)
for attempt := 0; lastErr != nil && attempt < 3; attempt++ {
select {
case <-time.After(2 * time.Second):
case <-flushCtx.Done():
lastErr = flushCtx.Err()
}
if flushCtx.Err() != nil {
break
}
lastErr = r.flush(flushCtx, pending)
}
if lastErr != nil && r.logger != nil {
r.logger.Error("audit drain failed; events lost", "events", len(pending), "error", lastErr)
}
}
cancel()
return
}
}
}
}
}
type dailyKey struct {
date, apiKeyID, provider, model, currency string
}
type dailyValue struct {
requests, failed, prompt, completion, cost int64
}
type policyAlert struct {
eventID, requestID string
tenantID *string
payload []byte
}
func (r *Recorder) flush(ctx context.Context, events []Event) error {
if len(events) == 0 {
return nil
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return r.flushError(err)
}
defer func() { _ = tx.Rollback(ctx) }()
rows := make([][]any, 0, len(events))
daily := make(map[dailyKey]dailyValue)
alerts := make([]policyAlert, 0)
for _, event := range events {
id, err := platformid.NewUUID()
if err != nil {
return r.flushError(err)
}
labels, _ := json.Marshal(event.Labels)
if policies, matched := event.Labels["content_policies"]; matched {
alertID, idErr := platformid.NewUUID()
if idErr != nil {
return r.flushError(idErr)
}
payload, _ := json.Marshal(map[string]any{
"request_id": event.RequestID, "protocol": event.Protocol, "status_code": event.StatusCode,
"provider_code": event.ProviderCode, "model": event.Model, "content_policies": policies,
"content_redacted": event.Labels["content_redacted"], "recorded_at": event.RecordedAt,
})
alerts = append(alerts, policyAlert{eventID: alertID, requestID: event.RequestID, tenantID: event.TenantID, payload: payload})
}
rows = append(rows, []any{
uuidValue(id), uuidPointer(event.TenantID), event.RequestID, nil, uuidPointer(event.APIKeyID),
nullString(event.ProviderCode), nullString(event.Model), event.Protocol, event.StatusCode,
nullToken(event.PromptTokens), nullToken(event.CompletionTokens), nullCost(event.CostMicrounits), event.LatencyMS,
nil, nil, labels, event.RecordedAt,
})
if event.APIKeyID != nil && *event.APIKeyID != "" {
// 仅聚合合法 UUID 的 API Key:usage_daily.api_key_id 是
// REFERENCES api_keys 的 uuid 列,bootstrap 等非 UUID 身份写入
// 会让整批事务失败、审计管线永久卡死。审计事件本身仍落 audit_events。
if _, uuidErr := uuid.Parse(strings.TrimSpace(*event.APIKeyID)); uuidErr != nil {
continue
}
// 成本按币种独立聚合:不同货币的价格不能相加成单一数字。
currency := strings.ToUpper(strings.TrimSpace(event.Currency))
if currency == "" {
currency = "USD"
}
key := dailyKey{date: event.RecordedAt.UTC().Format("2006-01-02"), apiKeyID: *event.APIKeyID, provider: event.ProviderCode, model: event.Model, currency: currency}
value := daily[key]
value.requests++
if event.StatusCode >= 400 {
value.failed++
}
value.prompt += max(event.PromptTokens, 0)
value.completion += max(event.CompletionTokens, 0)
value.cost += max(event.CostMicrounits, 0)
daily[key] = value
}
}
_, err = tx.CopyFrom(ctx, pgx.Identifier{"gateway", "audit_events"}, []string{
"id", "tenant_id", "request_id", "actor_id", "api_key_id", "provider_code", "model", "protocol",
"status_code", "prompt_tokens", "completion_tokens", "cost_microunits", "latency_ms",
"request_preview", "response_preview", "labels", "recorded_at",
}, pgx.CopyFromRows(rows))
if err != nil {
return r.flushError(err)
}
batch := &pgx.Batch{}
for key, value := range daily {
batch.Queue(`INSERT INTO gateway.usage_daily(usage_date,api_key_id,provider_code,model,currency,requests,failed_requests,prompt_tokens,completion_tokens,cost_microunits)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT(usage_date,api_key_id,provider_code,model,currency) DO UPDATE SET
requests=gateway.usage_daily.requests+EXCLUDED.requests,
failed_requests=gateway.usage_daily.failed_requests+EXCLUDED.failed_requests,
prompt_tokens=gateway.usage_daily.prompt_tokens+EXCLUDED.prompt_tokens,
completion_tokens=gateway.usage_daily.completion_tokens+EXCLUDED.completion_tokens,
cost_microunits=gateway.usage_daily.cost_microunits+EXCLUDED.cost_microunits,
updated_at=clock_timestamp()`, key.date, key.apiKeyID, key.provider, key.model, key.currency, value.requests, value.failed, value.prompt, value.completion, value.cost)
}
for _, alert := range alerts {
batch.Queue(`INSERT INTO gateway.outbox_events(event_id,event_type,event_version,tenant_id,aggregate_type,aggregate_id,payload)
VALUES($1,'content_policy.matched',1,$2,'gateway_request',$3,$4)`, alert.eventID, uuidPointer(alert.tenantID), alert.requestID, alert.payload)
}
results := tx.SendBatch(ctx, batch)
if err := results.Close(); err != nil {
return r.flushError(err)
}
if err := tx.Commit(ctx); err != nil {
return r.flushError(err)
}
r.written.Add(uint64(len(events)))
return nil
}
func (r *Recorder) flushError(err error) error {
r.failures.Add(1)
if r.logger != nil {
r.logger.Error("audit batch flush failed", "error", err)
}
return err
}
func uuidValue(value string) pgtype.UUID {
var result pgtype.UUID
_ = result.Scan(value)
return result
}
func uuidPointer(value *string) any {
if value == nil || strings.TrimSpace(*value) == "" {
return nil
}
// 非 UUID 身份(bootstrap key 等)返回 nil:audit_events.api_key_id 允许
// NULL,写零值/非法值只会掩盖问题。
if _, err := uuid.Parse(strings.TrimSpace(*value)); err != nil {
return nil
}
return uuidValue(*value)
}
func nullString(value string) any {
if value == "" {
return nil
}
return value
}
func nullToken(value int64) any {
if value <= 0 {
return nil
}
return value
}
func nullCost(value int64) any {
if value <= 0 {
return nil
}
return value
}