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:
@@ -89,7 +89,10 @@ func (s *auditSpan) captureBody(body io.ReadCloser) io.ReadCloser {
|
||||
return body
|
||||
}
|
||||
capture := &captureReadCloser{ReadCloser: body, limit: auditRequestCaptureBytes}
|
||||
// 与 finish/setModel 的读取保持同一把锁,防止未来异步化审计时出现竞态。
|
||||
s.mu.Lock()
|
||||
s.capture = capture
|
||||
s.mu.Unlock()
|
||||
return capture
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ func TestContentPolicyAndPricingIntegration(t *testing.T) {
|
||||
go func() { recorder.Run(recordCtx); close(stopped) }()
|
||||
defer func() { cancel(); <-stopped }()
|
||||
proxy := NewProxy(adapter, "test-key", 1<<20, slog.Default())
|
||||
proxy.SetAllowPrivateProviderURLs(true) // 测试上游监听 127.0.0.1
|
||||
proxy.SetAuditRecorder(recorder)
|
||||
proxy.SetContentPolicyEngine(engine)
|
||||
proxy.SetPricingService(prices)
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"strconv"
|
||||
@@ -67,9 +66,11 @@ func NewProxyWithAuthenticator(adapter provider.Adapter, authenticator apikey.Ke
|
||||
}
|
||||
|
||||
func NewDynamicProxy(resolver AdapterResolver, authenticator apikey.KeyAuthenticator, maxBody int64, logger *slog.Logger) *Proxy {
|
||||
// 默认拒绝拨号到非公网地址:管理员未显式放行私网时,数据平面在拨号阶段
|
||||
// 复检目标地址,防止 DNS rebinding 把流量引到内网(169.254.169.254 等)。
|
||||
transport := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
|
||||
DialContext: provider.SafeDialContext(false, 5*time.Second, 30*time.Second),
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 512,
|
||||
MaxIdleConnsPerHost: 256,
|
||||
@@ -81,6 +82,15 @@ func NewDynamicProxy(resolver AdapterResolver, authenticator apikey.KeyAuthentic
|
||||
return &Proxy{resolver: resolver, auth: authenticator, maxBody: maxBody, logger: logger, transport: transport, resilience: DefaultResiliencePolicy()}
|
||||
}
|
||||
|
||||
// SetAllowPrivateProviderURLs 允许数据平面拨号到私网地址(与管理员配置的
|
||||
// ALLOW_PRIVATE_PROVIDER_URLS 保持一致);关闭时保持拨号阶段 SSRF 校验。
|
||||
func (p *Proxy) SetAllowPrivateProviderURLs(allow bool) {
|
||||
timeout := p.transport.ResponseHeaderTimeout
|
||||
p.transport = p.transport.Clone()
|
||||
p.transport.DialContext = provider.SafeDialContext(allow, 5*time.Second, 30*time.Second)
|
||||
p.transport.ResponseHeaderTimeout = timeout
|
||||
}
|
||||
|
||||
func (p *Proxy) SetAdmissionController(controller AdmissionController) {
|
||||
p.admission = controller
|
||||
}
|
||||
@@ -328,7 +338,16 @@ func (p *Proxy) proxyFor(resolved ResolvedAdapter) *httputil.ReverseProxy {
|
||||
}
|
||||
writeOpenAIError(writer, http.StatusBadGateway, "upstream_error", "upstream service is unavailable")
|
||||
}
|
||||
p.proxies.Store(resolved.Code, cachedProxy{key: key, proxy: reverseProxy})
|
||||
// 并发缓存 miss 时只保留一个胜出的代理,其余立即丢弃,避免重复构建。
|
||||
if actual, loaded := p.proxies.LoadOrStore(resolved.Code, cachedProxy{key: key, proxy: reverseProxy}); loaded {
|
||||
entry := actual.(cachedProxy)
|
||||
if entry.key == key {
|
||||
return entry.proxy
|
||||
}
|
||||
// 另一个 goroutine 写入了不同的 key(快照已前进):保留新条目。
|
||||
_ = reverseProxy
|
||||
return entry.proxy
|
||||
}
|
||||
return reverseProxy
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ func TestProxyRejectsInvalidKey(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proxy := NewProxy(adapter, "gateway-secret", 1024, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
proxy.SetAllowPrivateProviderURLs(true) // 测试上游监听 127.0.0.1
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
request.Header.Set("Authorization", "Bearer wrong")
|
||||
@@ -89,6 +90,7 @@ func TestProxyRejectsKnownOversizedBody(t *testing.T) {
|
||||
defer upstream.Close()
|
||||
adapter, _ := provideropenai.New(upstream.URL, "upstream-secret")
|
||||
proxy := NewProxy(adapter, "gateway-secret", 4, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
proxy.SetAllowPrivateProviderURLs(true) // 测试上游监听 127.0.0.1
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader("12345"))
|
||||
request.Header.Set("Authorization", "Bearer gateway-secret")
|
||||
@@ -110,6 +112,7 @@ func TestProxyReplacesClientAuthorization(t *testing.T) {
|
||||
defer upstream.Close()
|
||||
adapter, _ := provideropenai.New(upstream.URL, "upstream-secret")
|
||||
proxy := NewProxy(adapter, "gateway-secret", 1024, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
proxy.SetAllowPrivateProviderURLs(true) // 测试上游监听 127.0.0.1
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
request.Header.Set("Authorization", "Bearer gateway-secret")
|
||||
@@ -162,6 +165,7 @@ func TestProxyReconcilesReservedTokensWithUpstreamUsage(t *testing.T) {
|
||||
proxy := NewProxyWithAuthenticator(adapter, principalAuthenticator{principal: apikey.Principal{
|
||||
APIKeyID: "key-1", Scopes: []string{"gateway:invoke"}, MonthlyTokenQuota: 1000,
|
||||
}}, 1024, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
proxy.SetAllowPrivateProviderURLs(true) // 测试上游监听 127.0.0.1
|
||||
proxy.SetTokenQuotaController(quota)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"test","max_tokens":20}`))
|
||||
@@ -218,6 +222,7 @@ func TestProxyRewritesModelAliasBeforeUpstream(t *testing.T) {
|
||||
adapter, _ := provideropenai.New(upstream.URL, "upstream-secret")
|
||||
resolved := ResolvedAdapter{Code: "routed", Adapter: adapter, Capabilities: map[provider.Capability]bool{provider.CapabilityChat: true}}
|
||||
proxy := NewDynamicProxy(fixedRoutingResolver{adapter: resolved}, principalAuthenticator{principal: apikey.Principal{Scopes: []string{"gateway:invoke"}}}, 1024, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
proxy.SetAllowPrivateProviderURLs(true) // 测试上游监听 127.0.0.1
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"public-chat","messages":[]}`))
|
||||
request.Header.Set("Authorization", "Bearer gateway-secret")
|
||||
response := httptest.NewRecorder()
|
||||
@@ -238,6 +243,7 @@ func TestProxyRecordsAuditWithoutBufferingWholeResponse(t *testing.T) {
|
||||
proxy := NewProxyWithAuthenticator(adapter, principalAuthenticator{principal: apikey.Principal{
|
||||
APIKeyID: "11111111-1111-4111-8111-111111111111", Scopes: []string{"gateway:invoke"},
|
||||
}}, 1024, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
proxy.SetAllowPrivateProviderURLs(true) // 测试上游监听 127.0.0.1
|
||||
proxy.SetAuditRecorder(recorder)
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"audit-model","messages":[]}`))
|
||||
request.Header.Set("Authorization", "Bearer gateway-secret")
|
||||
|
||||
@@ -43,25 +43,48 @@ func newCircuitBreaker(policy ...ResiliencePolicy) *circuitBreaker {
|
||||
return &circuitBreaker{threshold: settings.CircuitThreshold, openFor: settings.CircuitOpenDuration}
|
||||
}
|
||||
|
||||
func (c *circuitBreaker) allow(now time.Time) bool {
|
||||
// allow returns whether the request may proceed and whether it is the
|
||||
// half-open probe (the single request allowed through an open circuit to
|
||||
// test recovery).
|
||||
func (c *circuitBreaker) allow(now time.Time) (allowed bool, probe bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.openUntil.IsZero() {
|
||||
return true
|
||||
return true, false
|
||||
}
|
||||
if now.Before(c.openUntil) || c.halfOpenRun {
|
||||
return false
|
||||
return false, false
|
||||
}
|
||||
c.halfOpenRun = true
|
||||
return true
|
||||
return true, true
|
||||
}
|
||||
|
||||
func (c *circuitBreaker) success() {
|
||||
func (c *circuitBreaker) success(probe bool) {
|
||||
c.mu.Lock()
|
||||
c.failures = 0
|
||||
c.openUntil = time.Time{}
|
||||
defer c.mu.Unlock()
|
||||
if c.openUntil.IsZero() {
|
||||
// 关闭状态下普通成功:仅清零失败计数。
|
||||
c.failures = 0
|
||||
return
|
||||
}
|
||||
if probe && c.halfOpenRun {
|
||||
// 半开探针成功:关闭电路,恢复正常流量。
|
||||
c.failures = 0
|
||||
c.openUntil = time.Time{}
|
||||
c.halfOpenRun = false
|
||||
return
|
||||
}
|
||||
// 电路已打开而请求在打开前就通过 allow():陈旧成功不得关闭电路,
|
||||
// 否则刚触发熔断的上游被一个在途成功立即放行。
|
||||
}
|
||||
|
||||
// abortProbe 在探针请求被客户端取消(而非上游失败)时调用:
|
||||
// 既无成功也无失败的证据,释放探针名额但不改变电路状态,让下一次
|
||||
// allow() 重新发起探针。
|
||||
func (c *circuitBreaker) abortProbe() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.halfOpenRun = false
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *circuitBreaker) failure(now time.Time) {
|
||||
@@ -82,7 +105,8 @@ type resilientTransport struct {
|
||||
}
|
||||
|
||||
func (t *resilientTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
if !t.circuit.allow(time.Now()) {
|
||||
allowed, probe := t.circuit.allow(time.Now())
|
||||
if !allowed {
|
||||
return nil, ErrCircuitOpen
|
||||
}
|
||||
replayable := request.Method == http.MethodGet || request.Method == http.MethodHead ||
|
||||
@@ -102,6 +126,11 @@ func (t *resilientTransport) RoundTrip(request *http.Request) (*http.Response, e
|
||||
}
|
||||
current = request.Clone(request.Context())
|
||||
if request.Body != nil && request.Body != http.NoBody {
|
||||
if request.GetBody == nil {
|
||||
// 请求体不可重放(如 GET/HEAD 携带未设置 GetBody 的 body):
|
||||
// 放弃重试,避免把已消费的空 body 重发或调用 nil 方法。
|
||||
break
|
||||
}
|
||||
body, bodyErr := request.GetBody()
|
||||
if bodyErr != nil {
|
||||
err = bodyErr
|
||||
@@ -119,14 +148,24 @@ func (t *resilientTransport) RoundTrip(request *http.Request) (*http.Response, e
|
||||
_ = response.Body.Close()
|
||||
}
|
||||
}
|
||||
if retryableResult(response, err) {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
// 客户端取消/超时不是上游故障证据:不计成败。若本请求是半开
|
||||
// 探针,释放探针名额让电路保持打开,由下一次请求重新探测。
|
||||
if probe {
|
||||
t.circuit.abortProbe()
|
||||
}
|
||||
return response, err
|
||||
}
|
||||
if failureResult(response, err) {
|
||||
t.circuit.failure(time.Now())
|
||||
} else {
|
||||
t.circuit.success()
|
||||
t.circuit.success(probe)
|
||||
}
|
||||
return response, err
|
||||
}
|
||||
|
||||
// retryableResult 决定是否值得重试:仅传输错误与 502/503/504 会重试,
|
||||
// 500 等其余 5xx 不做自动重试(响应可能已被上游处理,重试有副作用)。
|
||||
func retryableResult(response *http.Response, err error) bool {
|
||||
if err != nil {
|
||||
return !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded)
|
||||
@@ -134,6 +173,15 @@ func retryableResult(response *http.Response, err error) bool {
|
||||
return response != nil && (response.StatusCode == http.StatusBadGateway || response.StatusCode == http.StatusServiceUnavailable || response.StatusCode == http.StatusGatewayTimeout)
|
||||
}
|
||||
|
||||
// failureResult 决定是否计入熔断失败:所有 5xx 都视为上游故障。否则持续返回
|
||||
// 500 的上游永远不会触发熔断,而 success() 还会不断清零失败计数,熔断保护失效。
|
||||
func failureResult(response *http.Response, err error) bool {
|
||||
if err != nil {
|
||||
return !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded)
|
||||
}
|
||||
return response != nil && response.StatusCode >= http.StatusInternalServerError
|
||||
}
|
||||
|
||||
func waitBackoff(ctx context.Context, duration time.Duration) error {
|
||||
if duration <= 0 {
|
||||
return nil
|
||||
|
||||
@@ -49,13 +49,13 @@ func TestCircuitOpensAndAllowsSingleProbe(t *testing.T) {
|
||||
now := time.Now()
|
||||
circuit.failure(now)
|
||||
circuit.failure(now)
|
||||
if circuit.allow(now) {
|
||||
if allowed, _ := circuit.allow(now); allowed {
|
||||
t.Fatal("open circuit allowed request")
|
||||
}
|
||||
if !circuit.allow(now.Add(2 * time.Millisecond)) {
|
||||
if allowed, probe := circuit.allow(now.Add(2 * time.Millisecond)); !allowed || !probe {
|
||||
t.Fatal("circuit did not allow half-open probe")
|
||||
}
|
||||
if circuit.allow(now.Add(2 * time.Millisecond)) {
|
||||
if allowed, _ := circuit.allow(now.Add(2 * time.Millisecond)); allowed {
|
||||
t.Fatal("circuit allowed concurrent half-open probe")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,11 @@ return {1, current}
|
||||
|
||||
const tokenCommitScript = `
|
||||
local delta = tonumber(ARGV[1])
|
||||
-- 预留与提交之间月份可能已翻转,计数器键已过期:此时直接放弃回写,
|
||||
-- 不能重建一个永不过期的残留键(旧月份数据已无意义)。
|
||||
if redis.call('EXISTS', KEYS[1]) == 0 then
|
||||
return 0
|
||||
end
|
||||
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
|
||||
local updated = current + delta
|
||||
if updated < 0 then updated = 0 end
|
||||
|
||||
+39
-11
@@ -107,17 +107,19 @@ type usageCollector struct {
|
||||
}
|
||||
|
||||
func (c *usageCollector) feed(chunk []byte) {
|
||||
if !c.sse {
|
||||
c.doc = append(c.doc, chunk...)
|
||||
// Keep only a bounded tail window. The "usage" member lives at the end
|
||||
// of a non-streaming response, so dropping the head (never the tail)
|
||||
// preserves accounting for arbitrarily large bodies at a fixed memory
|
||||
// cost instead of truncating usage away.
|
||||
if len(c.doc) > maxUsageDocumentBytes {
|
||||
c.doc = append([]byte(nil), c.doc[len(c.doc)-maxUsageDocumentBytes:]...)
|
||||
if !c.sse {
|
||||
c.doc = append(c.doc, chunk...)
|
||||
// Keep only a bounded tail window. The "usage" member lives at the end
|
||||
// of a non-streaming response, so dropping the head (never the tail)
|
||||
// preserves accounting for arbitrarily large bodies at a fixed memory
|
||||
// cost instead of truncating usage away. 仅在超过 2× 窗口时压缩一次,
|
||||
// 避免每个 32KiB 块都做 O(窗口) 的尾部拷贝(大响应下退化为 O(n²))。
|
||||
if len(c.doc) > 2*maxUsageDocumentBytes {
|
||||
copy(c.doc, c.doc[len(c.doc)-maxUsageDocumentBytes:])
|
||||
c.doc = c.doc[:maxUsageDocumentBytes]
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
c.pending = append(c.pending, chunk...)
|
||||
for {
|
||||
index := bytes.IndexByte(c.pending, '\n')
|
||||
@@ -164,7 +166,19 @@ func (c *usageCollector) usage() TokenUsage {
|
||||
// still counted when the buffered window starts mid-object.
|
||||
func (c *usageCollector) consumeUsageObject(doc []byte) {
|
||||
const key = `"usage"`
|
||||
// 只认 JSON 对象成员位置的 "usage"(前一个非空白字符是 '{' 或 ','),
|
||||
// 避免命中字符串值里的同名文本。
|
||||
index := bytes.LastIndex(doc, []byte(key))
|
||||
for index >= 0 {
|
||||
j := index - 1
|
||||
for j >= 0 && (doc[j] == ' ' || doc[j] == '\t' || doc[j] == '\n' || doc[j] == '\r') {
|
||||
j--
|
||||
}
|
||||
if j < 0 || doc[j] == '{' || doc[j] == ',' {
|
||||
break
|
||||
}
|
||||
index = bytes.LastIndex(doc[:index], []byte(key))
|
||||
}
|
||||
if index < 0 {
|
||||
return
|
||||
}
|
||||
@@ -209,7 +223,21 @@ func (c *usageCollector) consumeUsageObject(doc []byte) {
|
||||
}
|
||||
}
|
||||
if end > 0 {
|
||||
c.consumeJSON(rest[:end])
|
||||
// 提取出的是 usage 对象本身:必须按 inUsage=true 解析,否则其
|
||||
// 顶层 prompt_tokens/completion_tokens/total_tokens 不会被计数,
|
||||
// 大响应(>4MB 压缩后)的 token 计量静默丢失。
|
||||
c.consumeJSONAsUsage(rest[:end])
|
||||
}
|
||||
}
|
||||
|
||||
// consumeJSONAsUsage parses payload with the "inside usage" flag already set,
|
||||
// so top-level *_tokens keys are counted.
|
||||
func (c *usageCollector) consumeJSONAsUsage(payload []byte) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
if decoder.Decode(&value) == nil {
|
||||
c.walk(value, true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user