fix(m0): 收口工程基建与运行时安全边界

This commit is contained in:
tbphp committed 2026-07-16 11:33:59 +08:00
1 parent 8392047d8c
commit 8725b033dc
35 files changed
+955 -337

No files matched your search

+10
View File
@@ -1,9 +1,19 @@
.git
.github
.env
.env.*
!.env.example
gpt-load
data
tmp
web/node_modules
web/dist
*.key
*.db
*.db-*
*.sqlite
*.sqlite-*
*.sqlite3
*.sqlite3-*
*.log
.DS_Store
+6
View File
@@ -2,6 +2,12 @@
HOST=0.0.0.0
PORT=3001
GRACEFUL_SHUTDOWN_TIMEOUT=10
# Container runtime budget; keep this longer than the application timeout.
CONTAINER_STOP_GRACE_PERIOD=15s
# Maximum time to read a complete request, including its body (seconds).
READ_TIMEOUT=60
# Maximum keep-alive idle time between requests (seconds).
IDLE_TIMEOUT=120
# Persistent data. An empty DATABASE_DSN resolves to ${DATA_DIR}/gpt-load.db.
DATA_DIR=./data
+14 -12
View File
@@ -19,6 +19,18 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Build backend
run: go build -o gpt-load .
- name: Run race-enabled tests
run: go test -race . ./internal/...
- name: Set up Node.js
uses: actions/setup-node@v4
with:
@@ -30,16 +42,6 @@ jobs:
working-directory: web
run: |
npm ci
npm run lint:check
npm run format:check
npm run build
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Build backend
run: go build -o gpt-load .
- name: Run race-enabled tests
run: go test -race ./...
-9
View File
@@ -14,15 +14,6 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20.x"
cache: "npm"
cache-dependency-path: "web/package-lock.json"
- name: Build Frontend
run: npm ci && VITE_VERSION=${{ github.ref_name }} npm run build
working-directory: ./web
- name: Setup Go
uses: actions/setup-go@v5
with:
-9
View File
@@ -14,15 +14,6 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20.x"
cache: "npm"
cache-dependency-path: "web/package-lock.json"
- name: Build Frontend
run: npm ci && VITE_VERSION=${{ github.ref_name }} npm run build
working-directory: ./web
- name: Setup Go
uses: actions/setup-go@v5
with:
-9
View File
@@ -17,15 +17,6 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20.x"
cache: "npm"
cache-dependency-path: "web/package-lock.json"
- name: Build Frontend
run: npm ci && VITE_VERSION=${{ github.ref_name }} npm run build
working-directory: ./web
- name: Setup Go
uses: actions/setup-go@v5
with:
+5 -15
View File
@@ -1,16 +1,6 @@
FROM --platform=$BUILDPLATFORM node:20-alpine AS builder
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
ARG VERSION=1.0.0
WORKDIR /build
COPY ./web/package*.json ./
RUN npm ci
COPY ./web .
RUN VITE_VERSION=${VERSION} npm run build
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder2
ARG VERSION=1.0.0
ARG VERSION=2.0.0-dev
ARG TARGETOS
ARG TARGETARCH
ENV GO111MODULE=on \
@@ -21,8 +11,8 @@ WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=builder /build/dist ./web/dist
COPY main.go ./
COPY internal ./internal
RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags "-s -w -X gpt-load/internal/platform/version.Version=${VERSION}" -o gpt-load
@@ -32,6 +22,6 @@ WORKDIR /app
RUN apk add --no-cache ca-certificates tzdata \
&& update-ca-certificates
COPY --from=builder2 /build/gpt-load .
COPY --from=builder /build/gpt-load .
EXPOSE 3001
ENTRYPOINT ["/app/gpt-load"]
+3 -6
View File
@@ -5,17 +5,14 @@
# Run & Development
# ==============================================================================
.PHONY: run
run: ## Build frontend and run server
@echo "--- Building frontend... ---"
cd web && npm install && npm run build
@echo "--- Preparing backend... ---"
run: ## Run server
@echo "--- Starting backend... ---"
go run ./main.go
go run .
.PHONY: dev
dev: ## Run in development mode (with race detection)
@echo "🔧 Starting development mode..."
go run -race ./main.go
go run -race .
# ==============================================================================
# Deferred Key Migration
+7 -15
View File
@@ -154,16 +154,9 @@ After deployment:
> Use your modified AUTH_KEY to log in to the management interface.
### Method 4: Cluster Deployment
### GPT-Load 2.0 Deployment Boundary
Cluster deployment requires all nodes to connect to the same MySQL (or PostgreSQL) and Redis, with Redis being mandatory. It's recommended to use unified distributed MySQL and Redis clusters.
**Deployment Requirements:**
- All nodes must configure identical `AUTH_KEY`, `DATABASE_DSN`, `REDIS_DSN`
- Leader-follower architecture where follower nodes must configure environment variable: `IS_SLAVE=true`
For details, please refer to [Cluster Deployment Documentation](https://www.gpt-load.com/docs/cluster?lang=en)
Version 2.0.0 guarantees single-instance correctness and uses SQLite. Redis is optional runtime-state storage; multi-instance consistency and MySQL/PostgreSQL support are planned for later releases.
## Configuration System
@@ -193,11 +186,10 @@ GPT-Load adopts a dual-layer configuration architecture:
| ------------------------- | ---------------------------------- | --------------- | ----------------------------------------------- |
| Service Port | `PORT` | 3001 | HTTP server listening port |
| Service Address | `HOST` | 0.0.0.0 | HTTP server binding address |
| Read Timeout | `SERVER_READ_TIMEOUT` | 60 | HTTP server read timeout (seconds) |
| Write Timeout | `SERVER_WRITE_TIMEOUT` | 600 | HTTP server write timeout (seconds) |
| Idle Timeout | `SERVER_IDLE_TIMEOUT` | 120 | HTTP connection idle timeout (seconds) |
| Graceful Shutdown Timeout | `SERVER_GRACEFUL_SHUTDOWN_TIMEOUT` | 10 | Service graceful shutdown wait time (seconds) |
| Follower Mode | `IS_SLAVE` | false | Follower node identifier for cluster deployment |
| Read Timeout | `READ_TIMEOUT` | 60 | Complete HTTP request read timeout (seconds) |
| Idle Timeout | `IDLE_TIMEOUT` | 120 | HTTP keep-alive idle timeout (seconds) |
| Graceful Shutdown Timeout | `GRACEFUL_SHUTDOWN_TIMEOUT` | 10 | Application graceful shutdown timeout (seconds) |
| Container Stop Budget | `CONTAINER_STOP_GRACE_PERIOD` | 15s | Container stop budget; must exceed app timeout |
| Timezone | `TZ` | `Asia/Shanghai` | Specify timezone |
**Security Configuration:**
@@ -205,7 +197,7 @@ GPT-Load adopts a dual-layer configuration architecture:
| Setting | Environment Variable | Default | Description |
| -------------- | -------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| Admin Key | `AUTH_KEY` | - | Access authentication key for the **management end**, please change it to a strong password |
| Encryption Key | `ENCRYPTION_KEY` | - | Encrypts API keys at rest. Supports any string or leave empty to disable encryption. See [Data Encryption Migration](#data-encryption-migration) |
| Encryption Key | `ENCRYPTION_KEY` | - | Explicit master key; when empty, a persistent key is generated under `DATA_DIR`. At-rest encryption cannot be disabled. |
**Database Configuration:**
+7 -15
View File
@@ -154,16 +154,9 @@ make run
> 使用你修改的 AUTH_KEY 登录管理端。
### 方式四:集群部署
### GPT-Load 2.0 部署边界
集群部署需要所有节点都连接同一个 MySQL(或者 PostgreSQL) 和 Redis,并且 Redis 是必须要求。建议使用统一的分布式 MySQL 和 Redis 集群。
**部署要求:**
- 所有节点必须配置相同的 `AUTH_KEY`、`DATABASE_DSN`、`REDIS_DSN`
- 一主多从架构,从节点必须配置环境变量:`IS_SLAVE=true`
详细请参考[集群部署文档](https://www.gpt-load.com/docs/cluster?lang=zh)
2.0.0 只保证单实例正确性并使用 SQLite。Redis 是可选的运行时状态存储;多实例一致性及 MySQL/PostgreSQL 支持将在后续版本提供。
## 配置系统
@@ -193,11 +186,10 @@ GPT-Load 采用双层配置架构:
| ------------ | ---------------------------------- | --------------- | -------------------------- |
| 服务端口 | `PORT` | 3001 | HTTP 服务器监听端口 |
| 服务地址 | `HOST` | 0.0.0.0 | HTTP 服务器绑定地址 |
| 读取超时 | `SERVER_READ_TIMEOUT` | 60 | HTTP 服务器读取超时(秒) |
| 写入超时 | `SERVER_WRITE_TIMEOUT` | 600 | HTTP 服务器写入超时(秒) |
| 空闲超时 | `SERVER_IDLE_TIMEOUT` | 120 | HTTP 连接空闲超时(秒) |
| 优雅关闭超时 | `SERVER_GRACEFUL_SHUTDOWN_TIMEOUT` | 10 | 服务优雅关闭等待时间(秒) |
| 从节点模式 | `IS_SLAVE` | false | 集群部署时从节点标识 |
| 读取超时 | `READ_TIMEOUT` | 60 | 完整 HTTP 请求读取超时(秒) |
| 空闲超时 | `IDLE_TIMEOUT` | 120 | HTTP keep-alive 空闲超时(秒) |
| 优雅关闭超时 | `GRACEFUL_SHUTDOWN_TIMEOUT` | 10 | 应用优雅关闭超时(秒) |
| 容器停止预算 | `CONTAINER_STOP_GRACE_PERIOD` | 15s | 容器停止预算,必须长于应用关闭超时 |
| 时区 | `TZ` | `Asia/Shanghai` | 指定时区 |
**安全配置:**
@@ -205,7 +197,7 @@ GPT-Load 采用双层配置架构:
| 配置项 | 环境变量 | 默认值 | 说明 |
| -------- | ---------------- | ------ | ---------------------------------------------------------------------------------- |
| 管理密钥 | `AUTH_KEY` | - | **管理端**的访问认证密钥,请修改为强密码 |
| 加密密钥 | `ENCRYPTION_KEY` | - | 加密存储的API密钥,支持任意字符串或留空禁用加密。参见[数据加密迁移](#数据加密迁移) |
| 加密密钥 | `ENCRYPTION_KEY` | - | 显式主密钥;留空时会在 `DATA_DIR` 下生成持久化密钥,静态加密不可关闭 |
**数据库配置:**
+7 -15
View File
@@ -154,16 +154,9 @@ make run
> 変更したAUTH_KEYを使用して管理インターフェースにログインしてください。
### 方法4: クラスターデプロイメント
### GPT-Load 2.0 のデプロイ境界
クラスターデプロイメントでは、すべてのノードが同じMySQL(またはPostgreSQL)とRedisに接続する必要があり、Redisは必須です。統一された分散MySQLとRedisクラスターの使用を推奨します。
**デプロイメント要件:**
- すべてのノードは同一の`AUTH_KEY`、`DATABASE_DSN`、`REDIS_DSN`を設定する必要があります
- リーダー・フォロワーアーキテクチャで、フォロワーノードは環境変数を設定する必要があります:`IS_SLAVE=true`
詳細については、[クラスターデプロイメントドキュメント](https://www.gpt-load.com/docs/cluster?lang=ja)を参照してください。
2.0.0 は単一インスタンスの正しさのみを保証し、SQLite を使用します。Redis は任意のランタイム状態ストアです。マルチインスタンス整合性と MySQL/PostgreSQL 対応は後続リリースで提供予定です。
## 設定システム
@@ -193,11 +186,10 @@ GPT-Loadは二層設定アーキテクチャを採用しています:
| -------------------------------------- | ---------------------------------- | --------------- | ------------------------------------------------ |
| サービスポート | `PORT` | 3001 | HTTPサーバーリスニングポート |
| サービスアドレス | `HOST` | 0.0.0.0 | HTTPサーバーバインディングアドレス |
| 読み取りタイムアウト | `SERVER_READ_TIMEOUT` | 60 | HTTPサーバー読み取りタイムアウト(秒) |
| 書き込みタイムアウト | `SERVER_WRITE_TIMEOUT` | 600 | HTTPサーバー書き込みタイムアウト(秒) |
| アイドルタイムアウト | `SERVER_IDLE_TIMEOUT` | 120 | HTTP接続アイドルタイムアウト(秒) |
| グレースフルシャットダウンタイムアウト | `SERVER_GRACEFUL_SHUTDOWN_TIMEOUT` | 10 | サービスグレースフルシャットダウン待機時間(秒) |
| フォロワーモード | `IS_SLAVE` | false | クラスターデプロイメント用フォロワーノード識別子 |
| 読み取りタイムアウト | `READ_TIMEOUT` | 60 | HTTPリクエスト全体の読み取りタイムアウト(秒) |
| アイドルタイムアウト | `IDLE_TIMEOUT` | 120 | HTTP keep-aliveアイドルタイムアウト(秒) |
| グレースフルシャットダウンタイムアウト | `GRACEFUL_SHUTDOWN_TIMEOUT` | 10 | アプリのグレースフルシャットダウン時間(秒) |
| コンテナ停止猶予 | `CONTAINER_STOP_GRACE_PERIOD` | 15s | アプリの終了時間より長いコンテナ停止猶予 |
| タイムゾーン | `TZ` | `Asia/Shanghai` | タイムゾーンを指定 |
**セキュリティ設定:**
@@ -205,7 +197,7 @@ GPT-Loadは二層設定アーキテクチャを採用しています:
| 設定 | 環境変数 | デフォルト | 説明 |
| ---------- | ---------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------- |
| 管理キー | `AUTH_KEY` | - | **管理端末**のアクセス認証キー、強力なパスワードに変更してください |
| 暗号化キー | `ENCRYPTION_KEY` | - | APIキーを保存時に暗号化。任意の文字列をサポート、空の場合は暗号化を無効化。[データ暗号化移行](#データ暗号化移行)を参照 |
| 暗号化キー | `ENCRYPTION_KEY` | - | 明示的なマスターキー。空の場合は `DATA_DIR` に永続キーを生成し、保存時暗号化は無効化できません |
**データベース設定:**
+1 -1
View File
@@ -12,7 +12,7 @@ services:
restart: always
volumes:
- ./data:/app/data
stop_grace_period: ${SERVER_GRACEFUL_SHUTDOWN_TIMEOUT:-10}s
stop_grace_period: ${CONTAINER_STOP_GRACE_PERIOD:-15s}
healthcheck:
test: wget -q --spider -T 10 -O /dev/null http://localhost:${PORT:-3001}/health
interval: 30s
+20 -10
View File
@@ -32,9 +32,10 @@ type App struct {
store store.Store
db *gorm.DB
mu sync.Mutex
httpServer *http.Server
listener net.Listener
mu sync.Mutex
httpServer *http.Server
listener net.Listener
serveErrors chan error
}
// AppParams defines dependencies injected into App.
@@ -50,8 +51,9 @@ type AppParams struct {
// NewEngine creates the M0 HTTP engine and health endpoint.
func NewEngine() *gin.Engine {
gin.SetMode(gin.ReleaseMode)
engine := gin.New()
engine.Use(gin.Recovery())
engine.Use(recoveryMiddleware())
engine.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
@@ -64,11 +66,12 @@ func NewEngine() *gin.Engine {
// NewApp creates the application lifecycle manager.
func NewApp(params AppParams) *App {
return &App{
engine: params.Engine,
config: params.Config,
encryption: params.Encryption,
store: params.Store,
db: params.DB,
engine: params.Engine,
config: params.Config,
encryption: params.Encryption,
store: params.Store,
db: params.DB,
serveErrors: make(chan error, 1),
}
}
@@ -96,7 +99,9 @@ func (a *App) Start() error {
server := &http.Server{
Addr: address,
Handler: a.engine,
ReadTimeout: time.Duration(a.config.Server.ReadTimeout) * time.Second,
ReadHeaderTimeout: 30 * time.Second,
IdleTimeout: time.Duration(a.config.Server.IdleTimeout) * time.Second,
MaxHeaderBytes: 1 << 20,
}
a.httpServer = server
@@ -104,7 +109,7 @@ func (a *App) Start() error {
go func() {
if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
logrus.WithError(err).Error("HTTP server stopped unexpectedly")
a.serveErrors <- fmt.Errorf("serve HTTP: %w", err)
}
}()
@@ -115,6 +120,11 @@ func (a *App) Start() error {
return nil
}
// ServeErrors reports an unexpected terminal error from the HTTP accept loop.
func (a *App) ServeErrors() <-chan error {
return a.serveErrors
}
// Address returns the bound listener address after Start succeeds.
func (a *App) Address() string {
a.mu.Lock()
+121
View File
@@ -1,20 +1,86 @@
package app
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gpt-load/internal/platform/config"
"gpt-load/internal/platform/encryption"
app_errors "gpt-load/internal/platform/errors"
"gpt-load/internal/platform/version"
"gpt-load/internal/storage"
"gpt-load/internal/storage/store"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
)
func TestNewEngineRecoversWithoutLoggingCredentials(t *testing.T) {
previousMode := gin.Mode()
gin.SetMode(gin.DebugMode)
t.Cleanup(func() { gin.SetMode(previousMode) })
var logs bytes.Buffer
previousGinErrorWriter := gin.DefaultErrorWriter
previousLogOutput := logrus.StandardLogger().Out
gin.DefaultErrorWriter = &logs
logrus.SetOutput(&logs)
t.Cleanup(func() {
gin.DefaultErrorWriter = previousGinErrorWriter
logrus.SetOutput(previousLogOutput)
})
engine := NewEngine()
engine.GET("/panic", func(*gin.Context) {
panic("panic-secret")
})
request := httptest.NewRequest(http.MethodGet, "/panic?token=query-secret", nil)
request.Header.Set("Authorization", "Bearer authorization-secret")
request.Header.Set("X-Api-Key", "x-api-key-secret")
request.Header.Set("Api-Key", "api-key-secret")
request.Header.Set("X-Goog-Api-Key", "google-api-key-secret")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusInternalServerError {
t.Fatalf("panic status = %d, want %d", recorder.Code, http.StatusInternalServerError)
}
var body struct {
Code string `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatalf("decode panic response: %v; body = %q", err, recorder.Body.String())
}
if body.Code != app_errors.ErrInternalServer.Code || body.Message != app_errors.ErrInternalServer.Message {
t.Fatalf("panic response = %#v", body)
}
if gin.Mode() != gin.ReleaseMode {
t.Fatalf("gin mode = %q, want %q", gin.Mode(), gin.ReleaseMode)
}
for _, secret := range []string{
"panic-secret",
"query-secret",
"authorization-secret",
"x-api-key-secret",
"api-key-secret",
"google-api-key-secret",
} {
if strings.Contains(logs.String(), secret) {
t.Fatalf("recovery logs contain secret %q:\n%s", secret, logs.String())
}
}
}
func TestNewEngineServesHealth(t *testing.T) {
engine := NewEngine()
recorder := httptest.NewRecorder()
@@ -54,6 +120,18 @@ func TestAppStartMigratesDatabaseAndServesHTTP(t *testing.T) {
if err := application.Start(); err != nil {
t.Fatalf("Start() error = %v", err)
}
if application.httpServer.ReadTimeout != 2*time.Second {
t.Fatalf("ReadTimeout = %s, want 2s", application.httpServer.ReadTimeout)
}
if application.httpServer.ReadHeaderTimeout != 30*time.Second {
t.Fatalf("ReadHeaderTimeout = %s, want 30s", application.httpServer.ReadHeaderTimeout)
}
if application.httpServer.IdleTimeout != 3*time.Second {
t.Fatalf("IdleTimeout = %s, want 3s", application.httpServer.IdleTimeout)
}
if application.httpServer.WriteTimeout != 0 {
t.Fatalf("WriteTimeout = %s, want 0 for streaming responses", application.httpServer.WriteTimeout)
}
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
@@ -81,6 +159,47 @@ func TestAppStartMigratesDatabaseAndServesHTTP(t *testing.T) {
}
}
func TestAppReportsUnexpectedHTTPServeFailure(t *testing.T) {
db, err := storage.Open(":memory:")
if err != nil {
t.Fatalf("storage.Open() error = %v", err)
}
keyService, err := encryption.NewService("test-master-key")
if err != nil {
t.Fatalf("encryption.NewService() error = %v", err)
}
application := NewApp(AppParams{
Engine: NewEngine(),
Config: testConfig(t),
Encryption: keyService,
Store: store.NewMemoryStore(),
DB: db,
})
if err := application.Start(); err != nil {
t.Fatalf("Start() error = %v", err)
}
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := application.Stop(ctx); err != nil {
t.Errorf("Stop() error = %v", err)
}
})
if err := application.listener.Close(); err != nil {
t.Fatalf("close live listener: %v", err)
}
select {
case err := <-application.ServeErrors():
if err == nil || !strings.Contains(err.Error(), "serve HTTP") {
t.Fatalf("ServeErrors() = %v, want wrapped HTTP Serve failure", err)
}
case <-time.After(time.Second):
t.Fatal("unexpected HTTP Serve failure was not propagated")
}
}
func testConfig(t *testing.T) *config.Config {
t.Helper()
return &config.Config{
@@ -88,6 +207,8 @@ func testConfig(t *testing.T) *config.Config {
Host: "127.0.0.1",
Port: 0,
GracefulShutdownTimeout: 1,
ReadTimeout: 2,
IdleTimeout: 3,
},
DataDir: t.TempDir(),
DatabaseDSN: ":memory:",
+29
View File
@@ -0,0 +1,29 @@
package app
import (
app_errors "gpt-load/internal/platform/errors"
"gpt-load/internal/platform/response"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
)
// recoveryMiddleware converts handler panics into the standard internal-error
// response without logging request data, credentials, or the raw panic value.
func recoveryMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if recovered := recover(); recovered == nil {
return
}
logrus.Error("Recovered from HTTP handler panic")
if !c.Writer.Written() {
response.Error(c, app_errors.ErrInternalServer)
}
c.Abort()
}()
c.Next()
}
}
+3
View File
@@ -0,0 +1,3 @@
// Package health contains pure-domain upstream result classification and
// failure judgment. It must not depend on HTTP handlers or persistence.
package health
+38 -4
View File
@@ -16,6 +16,8 @@ const (
defaultPort = 3001
defaultDataDir = "./data"
defaultGracefulShutdownSeconds = 10
defaultReadTimeoutSeconds = 60
defaultIdleTimeoutSeconds = 120
)
// ServerConfig contains the process-level HTTP server settings needed in M0.
@@ -23,6 +25,8 @@ type ServerConfig struct {
Host string
Port int
GracefulShutdownTimeout int
ReadTimeout int
IdleTimeout int
}
// LogConfig contains process-wide logger settings.
@@ -44,8 +48,9 @@ type Config struct {
}
// Settings is the dynamic settings shape shared by system and group layers.
// Concrete fields are introduced with the features that consume them.
type Settings map[string]any
// Values use the standard JSON-decoded representation: map[string]any, []any,
// and scalar values. Concrete fields are introduced with their consumers.
type Settings = map[string]any
// Load reads process configuration from the environment. A local .env file is
// loaded when present, but existing environment variables always win.
@@ -64,6 +69,14 @@ func Load() (*Config, error) {
if err != nil {
return nil, err
}
readTimeout, err := parsePositiveInt("READ_TIMEOUT", defaultReadTimeoutSeconds)
if err != nil {
return nil, err
}
idleTimeout, err := parsePositiveInt("IDLE_TIMEOUT", defaultIdleTimeoutSeconds)
if err != nil {
return nil, err
}
authKey := os.Getenv("AUTH_KEY")
if authKey == "" {
@@ -86,6 +99,8 @@ func Load() (*Config, error) {
Host: valueOrDefault("HOST", defaultHost),
Port: port,
GracefulShutdownTimeout: shutdownTimeout,
ReadTimeout: readTimeout,
IdleTimeout: idleTimeout,
},
DataDir: dataDir,
DatabaseDSN: databaseDSN,
@@ -104,14 +119,33 @@ func Load() (*Config, error) {
func MergeSettings(system, group Settings) Settings {
merged := make(Settings, len(system)+len(group))
for key, value := range system {
merged[key] = value
merged[key] = cloneSettingValue(value)
}
for key, value := range group {
merged[key] = value
merged[key] = cloneSettingValue(value)
}
return merged
}
func cloneSettingValue(value any) any {
switch typed := value.(type) {
case map[string]any:
cloned := make(map[string]any, len(typed))
for key, nested := range typed {
cloned[key] = cloneSettingValue(nested)
}
return cloned
case []any:
cloned := make([]any, len(typed))
for index, nested := range typed {
cloned[index] = cloneSettingValue(nested)
}
return cloned
default:
return value
}
}
func parsePositiveInt(key string, defaultValue int) (int, error) {
value := os.Getenv(key)
if value == "" {
+38
View File
@@ -23,6 +23,12 @@ func TestLoadUsesM0Defaults(t *testing.T) {
if cfg.Server.GracefulShutdownTimeout != 10 {
t.Fatalf("GracefulShutdownTimeout = %d, want 10", cfg.Server.GracefulShutdownTimeout)
}
if cfg.Server.ReadTimeout != 60 {
t.Fatalf("ReadTimeout = %d, want 60", cfg.Server.ReadTimeout)
}
if cfg.Server.IdleTimeout != 120 {
t.Fatalf("IdleTimeout = %d, want 120", cfg.Server.IdleTimeout)
}
if cfg.DataDir != "./data" {
t.Fatalf("DataDir = %q, want ./data", cfg.DataDir)
}
@@ -46,6 +52,8 @@ func TestLoadAppliesEnvironmentOverrides(t *testing.T) {
t.Setenv("LOG_LEVEL", "debug")
t.Setenv("LOG_FORMAT", "json")
t.Setenv("GRACEFUL_SHUTDOWN_TIMEOUT", "25")
t.Setenv("READ_TIMEOUT", "45")
t.Setenv("IDLE_TIMEOUT", "90")
cfg, err := Load()
if err != nil {
@@ -58,6 +66,9 @@ func TestLoadAppliesEnvironmentOverrides(t *testing.T) {
if cfg.Server.GracefulShutdownTimeout != 25 {
t.Fatalf("GracefulShutdownTimeout = %d", cfg.Server.GracefulShutdownTimeout)
}
if cfg.Server.ReadTimeout != 45 || cfg.Server.IdleTimeout != 90 {
t.Fatalf("read/idle timeouts not loaded: %#v", cfg.Server)
}
if cfg.DatabaseDSN != ":memory:" || cfg.EncryptionKey != "explicit-encryption-key" {
t.Fatalf("database/encryption overrides not loaded: %#v", cfg)
}
@@ -93,6 +104,8 @@ func TestLoadRejectsInvalidRequiredAndNumericValues(t *testing.T) {
{name: "invalid port", env: map[string]string{"AUTH_KEY": "x", "PORT": "nope"}},
{name: "port out of range", env: map[string]string{"AUTH_KEY": "x", "PORT": "70000"}},
{name: "invalid shutdown timeout", env: map[string]string{"AUTH_KEY": "x", "GRACEFUL_SHUTDOWN_TIMEOUT": "0"}},
{name: "invalid read timeout", env: map[string]string{"AUTH_KEY": "x", "READ_TIMEOUT": "0"}},
{name: "invalid idle timeout", env: map[string]string{"AUTH_KEY": "x", "IDLE_TIMEOUT": "nope"}},
}
for _, tt := range tests {
@@ -123,11 +136,36 @@ func TestMergeSettingsUsesGroupOverridesWithoutMutatingInputs(t *testing.T) {
}
}
func TestMergeSettingsDeepCopiesNestedJSONValues(t *testing.T) {
systemRule := map[string]any{"name": "system"}
systemRules := []any{systemRule}
groupFilters := []any{"group-a", "group-b"}
system := Settings{"headers": map[string]any{"rules": systemRules}}
group := Settings{"filters": groupFilters}
merged := MergeSettings(system, group)
mergedRule := merged["headers"].(map[string]any)["rules"].([]any)[0].(map[string]any)
mergedFilters := merged["filters"].([]any)
systemRule["name"] = "input-mutated"
groupFilters[0] = "input-mutated"
if mergedRule["name"] != "system" || mergedFilters[0] != "group-a" {
t.Fatalf("merged settings changed with inputs: %#v", merged)
}
mergedRule["name"] = "output-mutated"
mergedFilters[0] = "output-mutated"
if systemRule["name"] != "input-mutated" || groupFilters[0] != "input-mutated" {
t.Fatalf("input settings changed with merged output: system=%#v group=%#v", system, group)
}
}
func clearEnvironment(t *testing.T) {
t.Helper()
for _, key := range []string{
"HOST", "PORT", "DATA_DIR", "DATABASE_DSN", "ENCRYPTION_KEY", "AUTH_KEY",
"REDIS_DSN", "LOG_LEVEL", "LOG_FORMAT", "GRACEFUL_SHUTDOWN_TIMEOUT",
"READ_TIMEOUT", "IDLE_TIMEOUT",
} {
t.Setenv(key, "")
}
+103 -66
View File
@@ -9,13 +9,12 @@ import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
"gpt-load/internal/platform/utils"
@@ -25,13 +24,12 @@ import (
// KeyFileName is the persistent master-key filename within DATA_DIR.
const (
KeyFileName = "encryption.key"
keyFileReadMaxAttempts = 50
keyFileReadRetryDelay = 2 * time.Millisecond
encodedKeyMaterialBytes = 64
temporaryFileAttempts = 10
encryptionKeyDomain = "gpt-load/encryption/aes-256-gcm/v1"
fingerprintKeyDomain = "gpt-load/encryption/fingerprint-hmac/v1"
)
var keyFileMu sync.Mutex
// Service defines credential encryption and fingerprinting operations.
type Service interface {
Encrypt(plaintext string) (string, error)
@@ -45,7 +43,9 @@ func NewService(keyMaterial string) (Service, error) {
return nil, fmt.Errorf("encryption key material is required")
}
aesKey := utils.DeriveAESKey(keyMaterial)
rootKey := utils.DeriveAESKey(keyMaterial)
aesKey := deriveDomainKey(rootKey, encryptionKeyDomain)
hashKey := deriveDomainKey(rootKey, fingerprintKeyDomain)
utils.ValidatePasswordStrength(keyMaterial, "ENCRYPTION_KEY")
block, err := aes.NewCipher(aesKey)
@@ -57,7 +57,7 @@ func NewService(keyMaterial string) (Service, error) {
return nil, fmt.Errorf("create GCM: %w", err)
}
return &aesService{key: aesKey, gcm: gcm}, nil
return &aesService{hashKey: hashKey, gcm: gcm}, nil
}
// NewServiceWithKeyFile resolves explicit key material or a persistent keyfile
@@ -73,16 +73,14 @@ func NewServiceWithKeyFile(explicitKey, dataDir string) (Service, error) {
// LoadOrCreateKeyMaterial prefers explicit key material. When it is absent, a
// 32-byte random key is loaded from or created at DATA_DIR/encryption.key.
func LoadOrCreateKeyMaterial(explicitKey, dataDir string) (string, error) {
return loadOrCreateKeyMaterial(explicitKey, dataDir, syncParentDirectory)
}
func loadOrCreateKeyMaterial(explicitKey, dataDir string, syncDirectory func(string) error) (string, error) {
if explicitKey != "" {
return explicitKey, nil
}
// Creating the keyfile makes its directory entry visible before its contents
// are written. Serialize callers so none of them can observe that transient
// empty file during the first application start.
keyFileMu.Lock()
defer keyFileMu.Unlock()
if dataDir == "" {
return "", fmt.Errorf("DATA_DIR is required when ENCRYPTION_KEY is empty")
}
@@ -91,7 +89,7 @@ func LoadOrCreateKeyMaterial(explicitKey, dataDir string) (string, error) {
}
path := filepath.Join(dataDir, KeyFileName)
if material, err := readKeyFileWithRetry(path); err == nil {
if material, err := loadDurableKeyMaterial(path, syncDirectory); err == nil {
return material, nil
} else if !os.IsNotExist(err) {
return "", err
@@ -103,38 +101,94 @@ func LoadOrCreateKeyMaterial(explicitKey, dataDir string) (string, error) {
}
material := hex.EncodeToString(randomKey)
file, err := createSecureKeyFile(path)
if err != nil {
if os.IsExist(err) {
return readKeyFileWithRetry(path)
if err := persistGeneratedKeyMaterial(path, material, publishSecureKeyFile, syncDirectory); err != nil {
if errors.Is(err, os.ErrExist) {
return loadDurableKeyMaterial(path, syncDirectory)
}
return "", fmt.Errorf("create encryption keyfile: %w", err)
}
contents := material + "\n"
if written, err := file.WriteString(contents); err != nil || written != len(contents) {
cleanupKeyFile(file, path)
if err == nil {
err = io.ErrShortWrite
}
return "", fmt.Errorf("write encryption keyfile: %w", err)
}
if err := file.Sync(); err != nil {
cleanupKeyFile(file, path)
return "", fmt.Errorf("sync encryption keyfile: %w", err)
}
if err := file.Close(); err != nil {
_ = os.Remove(path)
return "", fmt.Errorf("close encryption keyfile: %w", err)
}
if err := secureKeyFile(path); err != nil {
_ = os.Remove(path)
return "", fmt.Errorf("secure encryption keyfile: %w", err)
return "", err
}
logrus.WithField("path", path).Warn("Generated encryption keyfile; back it up before relying on encrypted credentials")
return material, nil
}
func persistGeneratedKeyMaterial(
path string,
material string,
publish func(temporaryPath, finalPath string) error,
syncDirectory func(string) error,
) error {
file, temporaryPath, err := createSecureTemporaryKeyFile(filepath.Dir(path))
if err != nil {
return fmt.Errorf("create temporary encryption keyfile: %w", err)
}
removeTemporary := true
defer func() {
if removeTemporary {
_ = os.Remove(temporaryPath)
}
}()
contents := material + "\n"
if written, err := file.WriteString(contents); err != nil || written != len(contents) {
cleanupKeyFile(file, temporaryPath)
if err == nil {
err = io.ErrShortWrite
}
return fmt.Errorf("write temporary encryption keyfile: %w", err)
}
if err := file.Sync(); err != nil {
cleanupKeyFile(file, temporaryPath)
return fmt.Errorf("sync temporary encryption keyfile: %w", err)
}
if err := file.Close(); err != nil {
return fmt.Errorf("close temporary encryption keyfile: %w", err)
}
if err := secureKeyFile(temporaryPath); err != nil {
return fmt.Errorf("secure temporary encryption keyfile: %w", err)
}
if err := publish(temporaryPath, path); err != nil {
return fmt.Errorf("publish encryption keyfile: %w", err)
}
if err := os.Remove(temporaryPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove temporary encryption keyfile: %w", err)
}
removeTemporary = false
if err := syncDirectory(path); err != nil {
return fmt.Errorf("sync encryption keyfile directory: %w", err)
}
return nil
}
func createSecureTemporaryKeyFile(dataDir string) (*os.File, string, error) {
for range temporaryFileAttempts {
suffix := make([]byte, 16)
if _, err := io.ReadFull(rand.Reader, suffix); err != nil {
return nil, "", fmt.Errorf("generate temporary keyfile name: %w", err)
}
path := filepath.Join(dataDir, "."+KeyFileName+"."+hex.EncodeToString(suffix)+".tmp")
file, err := createSecureKeyFile(path)
if err == nil {
return file, path, nil
}
if !os.IsExist(err) {
return nil, "", err
}
}
return nil, "", fmt.Errorf("could not allocate a unique temporary keyfile")
}
func loadDurableKeyMaterial(path string, syncDirectory func(string) error) (string, error) {
material, err := readKeyFile(path)
if err != nil {
return "", err
}
if err := syncDirectory(path); err != nil {
return "", fmt.Errorf("sync encryption keyfile directory: %w", err)
}
return material, nil
}
func readKeyFile(path string) (string, error) {
if err := requireRegularKeyFile(path); err != nil {
return "", err
@@ -160,29 +214,6 @@ func readKeyFile(path string) (string, error) {
return material, nil
}
func readKeyFileWithRetry(path string) (string, error) {
var lastErr error
for attempt := 0; attempt < keyFileReadMaxAttempts; attempt++ {
material, err := readKeyFile(path)
if err == nil {
return material, nil
}
lastErr = err
if !keyFileMayBePartiallyWritten(path) {
return "", err
}
if attempt+1 < keyFileReadMaxAttempts {
time.Sleep(keyFileReadRetryDelay)
}
}
return "", lastErr
}
func keyFileMayBePartiallyWritten(path string) bool {
info, err := os.Lstat(path)
return err == nil && info.Mode().IsRegular() && info.Size() < encodedKeyMaterialBytes
}
func requireRegularKeyFile(path string) error {
info, err := os.Lstat(path)
if err != nil {
@@ -200,8 +231,14 @@ func cleanupKeyFile(file *os.File, path string) {
}
type aesService struct {
key []byte
gcm cipher.AEAD
hashKey []byte
gcm cipher.AEAD
}
func deriveDomainKey(rootKey []byte, domain string) []byte {
mac := hmac.New(sha256.New, rootKey)
_, _ = mac.Write([]byte(domain))
return mac.Sum(nil)
}
func (s *aesService) Encrypt(plaintext string) (string, error) {
@@ -233,7 +270,7 @@ func (s *aesService) Hash(plaintext string) string {
if plaintext == "" {
return ""
}
mac := hmac.New(sha256.New, s.key)
mac := hmac.New(sha256.New, s.hashKey)
_, _ = mac.Write([]byte(plaintext))
return hex.EncodeToString(mac.Sum(nil))
}
+146 -30
View File
@@ -1,15 +1,161 @@
package encryption
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"gpt-load/internal/platform/utils"
)
func TestServiceUsesDomainSeparatedFingerprintKey(t *testing.T) {
const (
masterKey = "domain-separated-master-key"
plaintext = "sk-secret-value"
)
service, err := NewService(masterKey)
if err != nil {
t.Fatalf("NewService() error = %v", err)
}
legacyMAC := hmac.New(sha256.New, utils.DeriveAESKey(masterKey))
_, _ = legacyMAC.Write([]byte(plaintext))
legacyHash := hex.EncodeToString(legacyMAC.Sum(nil))
if got := service.Hash(plaintext); got == legacyHash {
t.Fatal("Hash() reuses the AES key; fingerprinting requires a domain-separated subkey")
}
}
func TestPersistGeneratedKeyMaterialPublishesOnlyCompleteFile(t *testing.T) {
dataDir := t.TempDir()
path := filepath.Join(dataDir, KeyFileName)
material := hex.EncodeToString(make([]byte, 32))
publishReady := make(chan error, 1)
releasePublish := make(chan struct{})
done := make(chan error, 1)
go func() {
done <- persistGeneratedKeyMaterial(
path,
material,
func(temporaryPath, finalPath string) error {
contents, err := os.ReadFile(temporaryPath)
if err == nil && string(contents) != material+"\n" {
err = errors.New("temporary keyfile is incomplete")
}
publishReady <- err
if err != nil {
return err
}
<-releasePublish
return publishSecureKeyFile(temporaryPath, finalPath)
},
func(string) error { return nil },
)
}()
select {
case err := <-publishReady:
if err != nil {
t.Fatalf("temporary keyfile validation: %v", err)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for atomic keyfile publication")
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("final keyfile became visible before publication: %v", err)
}
close(releasePublish)
if err := <-done; err != nil {
t.Fatalf("persistGeneratedKeyMaterial() error = %v", err)
}
contents, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read published keyfile: %v", err)
}
if string(contents) != material+"\n" {
t.Fatalf("published keyfile = %q, want complete material", contents)
}
}
func TestLoadOrCreateKeyMaterialWaitsForDirectorySyncBeforeUse(t *testing.T) {
dataDir := t.TempDir()
syncFailure := errors.New("directory sync failed")
syncCalls := 0
syncDirectory := func(string) error {
syncCalls++
if syncCalls == 1 {
return syncFailure
}
return nil
}
material, err := loadOrCreateKeyMaterial("", dataDir, syncDirectory)
if !errors.Is(err, syncFailure) {
t.Fatalf("first LoadOrCreateKeyMaterial() error = %v, want directory sync failure", err)
}
if material != "" {
t.Fatalf("key material returned before directory sync = %q", material)
}
path := filepath.Join(dataDir, KeyFileName)
contents, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read keyfile retained after sync failure: %v", err)
}
want := strings.TrimSpace(string(contents))
if len(want) != encodedKeyMaterialBytes {
t.Fatalf("retained key material length = %d, want %d", len(want), encodedKeyMaterialBytes)
}
got, err := loadOrCreateKeyMaterial("", dataDir, syncDirectory)
if err != nil {
t.Fatalf("second LoadOrCreateKeyMaterial() error = %v", err)
}
if got != want {
t.Fatalf("second LoadOrCreateKeyMaterial() = %q, want retained material %q", got, want)
}
if syncCalls != 2 {
t.Fatalf("directory sync calls = %d, want 2", syncCalls)
}
}
func TestLoadDurableKeyMaterialSyncsDirectoryBeforeUse(t *testing.T) {
dataDir := t.TempDir()
path := filepath.Join(dataDir, KeyFileName)
want := hex.EncodeToString(make([]byte, 32))
if err := os.WriteFile(path, []byte(want+"\n"), 0o600); err != nil {
t.Fatalf("write keyfile: %v", err)
}
syncCalls := 0
syncDirectory := func(string) error {
syncCalls++
return nil
}
got, err := loadDurableKeyMaterial(path, syncDirectory)
if err != nil {
t.Fatalf("loadDurableKeyMaterial() error = %v", err)
}
if got != want {
t.Fatalf("loadDurableKeyMaterial() = %q, want %q", got, want)
}
if syncCalls != 1 {
t.Fatalf("directory sync calls = %d, want 1", syncCalls)
}
}
func TestServiceEncryptDecryptAndStableHash(t *testing.T) {
service, err := NewService("a-test-master-key")
if err != nil {
@@ -170,33 +316,3 @@ func TestLoadOrCreateKeyMaterialConcurrentFirstStartReusesOneKey(t *testing.T) {
t.Fatal("no concurrent caller returned key material")
}
}
func TestLoadOrCreateKeyMaterialRetriesKeyFileBeingWrittenByAnotherProcess(t *testing.T) {
dataDir := t.TempDir()
path := filepath.Join(dataDir, KeyFileName)
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
t.Fatalf("create partial keyfile: %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("close partial keyfile: %v", err)
}
want := hex.EncodeToString(make([]byte, 32))
writeDone := make(chan error, 1)
go func() {
time.Sleep(10 * time.Millisecond)
writeDone <- os.WriteFile(path, []byte(want+"\n"), 0o600)
}()
got, err := LoadOrCreateKeyMaterial("", dataDir)
if err != nil {
t.Fatalf("LoadOrCreateKeyMaterial() error = %v", err)
}
if err := <-writeDone; err != nil {
t.Fatalf("complete keyfile write: %v", err)
}
if got != want {
t.Fatalf("key material = %q, want %q", got, want)
}
}
+24 -1
View File
@@ -2,15 +2,38 @@
package encryption
import "os"
import (
"fmt"
"os"
"path/filepath"
)
func createSecureKeyFile(path string) (*os.File, error) {
return os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
}
func publishSecureKeyFile(temporaryPath, finalPath string) error {
return os.Link(temporaryPath, finalPath)
}
func secureKeyFile(path string) error {
if err := requireRegularKeyFile(path); err != nil {
return err
}
return os.Chmod(path, 0o600)
}
func syncParentDirectory(path string) error {
directory, err := os.Open(filepath.Dir(path))
if err != nil {
return fmt.Errorf("open parent directory: %w", err)
}
if err := directory.Sync(); err != nil {
_ = directory.Close()
return fmt.Errorf("sync parent directory: %w", err)
}
if err := directory.Close(); err != nil {
return fmt.Errorf("close parent directory: %w", err)
}
return nil
}
@@ -37,6 +37,21 @@ func createSecureKeyFile(path string) (*os.File, error) {
return os.NewFile(uintptr(handle), path), nil
}
func publishSecureKeyFile(temporaryPath, finalPath string) error {
temporaryPathPtr, err := windows.UTF16PtrFromString(temporaryPath)
if err != nil {
return err
}
finalPathPtr, err := windows.UTF16PtrFromString(finalPath)
if err != nil {
return err
}
if err := windows.MoveFileEx(temporaryPathPtr, finalPathPtr, windows.MOVEFILE_WRITE_THROUGH); err != nil {
return &os.LinkError{Op: "rename", Old: temporaryPath, New: finalPath, Err: err}
}
return nil
}
func secureKeyFile(path string) error {
if err := requireRegularKeyFile(path); err != nil {
return err
@@ -60,6 +75,12 @@ func secureKeyFile(path string) error {
)
}
// Windows does not expose a portable directory fsync equivalent. File.Sync
// and the restrictive keyfile handle/ACL provide the strongest common path.
func syncParentDirectory(string) error {
return nil
}
func currentUserSecurityDescriptor() (*windows.SECURITY_DESCRIPTOR, error) {
user, err := windows.GetCurrentProcessToken().GetTokenUser()
if err != nil {
+44 -23
View File
@@ -1,10 +1,12 @@
package httpclient
import (
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
@@ -102,31 +104,19 @@ func (m *HTTPClientManager) GetClient(config *Config) *http.Client {
newClient := &http.Client{
Transport: transport,
Timeout: config.RequestTimeout,
CheckRedirect: stripSensitiveOnCrossHostRedirect,
CheckRedirect: rejectCrossOriginRedirect,
}
m.clients[fingerprint] = newClient
return newClient
}
// sensitiveProxyHeaders are custom-named credential headers that proxy channels
// attach to upstream requests (e.g. x-api-key set by the messages-format
// channel's ModifyRequest). Unlike the standard Authorization header, net/http
// does NOT strip these on a cross-host redirect, so without an explicit policy a
// redirect from the operator-configured upstream to another host would leak the
// operator's upstream key to that host (CWE-200 / CWE-522).
var sensitiveProxyHeaders = []string{
"Authorization",
"x-api-key",
"api-key",
"X-Goog-Api-Key",
"X-Auth-Token",
}
var errCrossOriginRedirect = errors.New("cross-origin redirect is not allowed")
// stripSensitiveOnCrossHostRedirect removes credential headers whenever a
// redirect changes origin (scheme, hostname, or port). It preserves the
// default redirect cap.
func stripSensitiveOnCrossHostRedirect(req *http.Request, via []*http.Request) error {
// rejectCrossOriginRedirect follows redirects only while they remain on the
// original effective origin. Rejecting the redirect also prevents 307/308 from
// replaying request bodies or arbitrary credential headers to another origin.
func rejectCrossOriginRedirect(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
}
@@ -134,16 +124,47 @@ func stripSensitiveOnCrossHostRedirect(req *http.Request, via []*http.Request) e
return nil
}
if !sameOrigin(req, via[0]) {
for _, h := range sensitiveProxyHeaders {
req.Header.Del(h)
}
return errCrossOriginRedirect
}
return nil
}
func sameOrigin(left, right *http.Request) bool {
return strings.EqualFold(left.URL.Scheme, right.URL.Scheme) &&
strings.EqualFold(left.URL.Host, right.URL.Host)
leftScheme, leftHost, leftPort, leftOK := normalizedOrigin(left)
rightScheme, rightHost, rightPort, rightOK := normalizedOrigin(right)
return leftOK && rightOK &&
leftScheme == rightScheme &&
leftHost == rightHost &&
leftPort == rightPort
}
func normalizedOrigin(request *http.Request) (scheme, host, port string, ok bool) {
if request == nil || request.URL == nil {
return "", "", "", false
}
scheme = strings.ToLower(request.URL.Scheme)
switch scheme {
case "http":
port = "80"
case "https":
port = "443"
default:
return "", "", "", false
}
host = strings.ToLower(request.URL.Hostname())
if host == "" {
return "", "", "", false
}
if explicitPort := request.URL.Port(); explicitPort != "" {
parsedPort, err := strconv.Atoi(explicitPort)
if err != nil || parsedPort < 1 || parsedPort > 65535 {
return "", "", "", false
}
port = strconv.Itoa(parsedPort)
}
return scheme, host, port, true
}
// getFingerprint generates a unique string representation of the client configuration.
+76 -44
View File
@@ -1,30 +1,65 @@
package httpclient
import (
"bytes"
"context"
"errors"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// TestStripSensitiveOnCrossHostRedirect asserts that the custom-named x-api-key
// credential header set by a proxy channel's ModifyRequest is NOT replayed to a
// different host when the operator-configured upstream issues a cross-host
// redirect. Regression test for the upstream-key leak (CWE-200 / CWE-522).
func TestStripSensitiveOnCrossHostRedirect(t *testing.T) {
var gotAPIKey, gotAuthorization string
func TestSameOriginUsesEffectivePorts(t *testing.T) {
tests := []struct {
name string
left string
right string
want bool
}{
{name: "HTTPS default port", left: "https://api.example.com/v1", right: "https://api.example.com:443/v2", want: true},
{name: "HTTP default port", left: "http://api.example.com/v1", right: "http://api.example.com:80/v2", want: true},
{name: "different explicit port", left: "https://api.example.com:443/v1", right: "https://api.example.com:444/v2", want: false},
{name: "different scheme", left: "http://api.example.com:80/v1", right: "https://api.example.com:443/v2", want: false},
{name: "hostname case", left: "https://API.example.com/v1", right: "https://api.EXAMPLE.com:443/v2", want: true},
{name: "IPv6 default port", left: "https://[2001:db8::1]/v1", right: "https://[2001:db8::1]:443/v2", want: true},
{name: "unknown scheme fails closed", left: "custom://api.example.com/v1", right: "custom://api.example.com/v2", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
leftURL, err := url.Parse(tt.left)
if err != nil {
t.Fatalf("parse left URL: %v", err)
}
rightURL, err := url.Parse(tt.right)
if err != nil {
t.Fatalf("parse right URL: %v", err)
}
got := sameOrigin(&http.Request{URL: leftURL}, &http.Request{URL: rightURL})
if got != tt.want {
t.Fatalf("sameOrigin(%q, %q) = %t, want %t", tt.left, tt.right, got, tt.want)
}
})
}
}
func TestCrossOriginRedirectIsRejectedBeforeReplayingRequest(t *testing.T) {
var targetCalls int
var gotCredential string
attacker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAPIKey = r.Header.Get("x-api-key")
gotAuthorization = r.Header.Get("Authorization")
targetCalls++
gotCredential = r.Header.Get("X-Custom-Credential")
w.WriteHeader(http.StatusOK)
}))
defer attacker.Close()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "http://attacker.local/v1/messages", http.StatusFound)
http.Redirect(w, r, "http://attacker.local/v1/messages", http.StatusTemporaryRedirect)
}))
defer upstream.Close()
@@ -45,30 +80,32 @@ func TestStripSensitiveOnCrossHostRedirect(t *testing.T) {
client := &http.Client{
Transport: transport,
CheckRedirect: stripSensitiveOnCrossHostRedirect,
CheckRedirect: rejectCrossOriginRedirect,
}
req, _ := http.NewRequest(http.MethodPost, "http://victim.local/v1/messages", nil)
req.Header.Set("x-api-key", "sk-secret-upstream-key")
req.Header.Set("Authorization", "Bearer secret-bearer")
req, _ := http.NewRequest(
http.MethodPost,
"http://victim.local/v1/messages",
bytes.NewBufferString(`{"prompt":"secret request body"}`),
)
req.Header.Set("X-Custom-Credential", "custom-secret")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
if resp != nil {
resp.Body.Close()
}
resp.Body.Close()
if gotAPIKey != "" {
t.Errorf("x-api-key leaked to cross-host redirect target: %q", gotAPIKey)
if err == nil {
t.Fatal("cross-origin redirect was followed, want rejection")
}
if gotAuthorization != "" {
t.Errorf("Authorization leaked to cross-host redirect target: %q", gotAuthorization)
if !errors.Is(err, errCrossOriginRedirect) {
t.Fatalf("request error = %v, want %v", err, errCrossOriginRedirect)
}
if targetCalls != 0 || gotCredential != "" {
t.Fatalf("redirect target received request: calls=%d credential=%q", targetCalls, gotCredential)
}
}
// TestSensitiveHeadersPreservedSameHost asserts the policy does NOT strip the
// credential header on a same-host redirect (legitimate behavior must survive).
func TestSensitiveHeadersPreservedSameHost(t *testing.T) {
func TestSameOriginRedirectPreservesRequestHeaders(t *testing.T) {
var gotAPIKey string
var hops int
@@ -83,7 +120,7 @@ func TestSensitiveHeadersPreservedSameHost(t *testing.T) {
}))
defer srv.Close()
client := &http.Client{CheckRedirect: stripSensitiveOnCrossHostRedirect}
client := &http.Client{CheckRedirect: rejectCrossOriginRedirect}
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/redirect", nil)
req.Header.Set("x-api-key", "sk-secret-upstream-key")
@@ -101,12 +138,10 @@ func TestSensitiveHeadersPreservedSameHost(t *testing.T) {
}
}
// TestSensitiveHeadersStrippedOnSameHostnameDifferentPort covers origin changes
// that Hostname alone cannot distinguish.
func TestSensitiveHeadersStrippedOnSameHostnameDifferentPort(t *testing.T) {
var gotAPIKey string
func TestRedirectToSameHostnameDifferentPortIsRejected(t *testing.T) {
var targetCalls int
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAPIKey = r.Header.Get("x-api-key")
targetCalls++
w.WriteHeader(http.StatusOK)
}))
defer target.Close()
@@ -116,7 +151,7 @@ func TestSensitiveHeadersStrippedOnSameHostnameDifferentPort(t *testing.T) {
}))
defer source.Close()
client := &http.Client{CheckRedirect: stripSensitiveOnCrossHostRedirect}
client := &http.Client{CheckRedirect: rejectCrossOriginRedirect}
req, err := http.NewRequest(http.MethodGet, source.URL+"/redirect", nil)
if err != nil {
t.Fatalf("create request: %v", err)
@@ -124,16 +159,18 @@ func TestSensitiveHeadersStrippedOnSameHostnameDifferentPort(t *testing.T) {
req.Header.Set("x-api-key", "sk-secret-upstream-key")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
if resp != nil {
resp.Body.Close()
}
resp.Body.Close()
if gotAPIKey != "" {
t.Fatalf("x-api-key leaked across ports on the same hostname: %q", gotAPIKey)
if !errors.Is(err, errCrossOriginRedirect) {
t.Fatalf("request error = %v, want %v", err, errCrossOriginRedirect)
}
if targetCalls != 0 {
t.Fatalf("cross-port redirect target received %d requests", targetCalls)
}
}
func TestSensitiveHeadersStrippedOnHTTPSDowngrade(t *testing.T) {
func TestHTTPSDowngradeRedirectIsRejected(t *testing.T) {
previous, err := http.NewRequest(http.MethodGet, "https://upstream.example/v1", nil)
if err != nil {
t.Fatalf("create previous request: %v", err)
@@ -142,13 +179,8 @@ func TestSensitiveHeadersStrippedOnHTTPSDowngrade(t *testing.T) {
if err != nil {
t.Fatalf("create redirected request: %v", err)
}
redirected.Header.Set("Authorization", "Bearer secret")
redirected.Header.Set("x-api-key", "secret")
if err := stripSensitiveOnCrossHostRedirect(redirected, []*http.Request{previous}); err != nil {
t.Fatalf("redirect policy error: %v", err)
}
if redirected.Header.Get("Authorization") != "" || redirected.Header.Get("x-api-key") != "" {
t.Fatalf("sensitive headers survived HTTPS downgrade: %#v", redirected.Header)
if err := rejectCrossOriginRedirect(redirected, []*http.Request{previous}); !errors.Is(err, errCrossOriginRedirect) {
t.Fatalf("redirect policy error = %v, want %v", err, errCrossOriginRedirect)
}
}
+1 -1
View File
@@ -1,3 +1,3 @@
package version
var Version = "1.0.0"
var Version = "2.0.0-dev"
@@ -0,0 +1,9 @@
package version
import "testing"
func TestDefaultVersionIdentifiesV2DevelopmentBuild(t *testing.T) {
if Version != "2.0.0-dev" {
t.Fatalf("Version = %q, want 2.0.0-dev", Version)
}
}
+54 -30
View File
@@ -58,45 +58,69 @@ func AutoMigrate(db *gorm.DB) error {
}
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.AutoMigrate(
&models.Group{},
&models.UpstreamKey{},
&models.AccessKey{},
&models.RequestLog{},
&models.UsageStat{},
&models.ModelPrice{},
&models.SystemSetting{},
&models.Job{},
&schemaInfo{},
); err != nil {
return fmt.Errorf("auto-migrate SQLite schema: %w", err)
}
var count int64
if err := tx.Model(&schemaInfo{}).Count(&count).Error; err != nil {
return fmt.Errorf("count schema_info rows: %w", err)
}
if count == 0 {
if err := tx.Create(&schemaInfo{Version: CurrentSchemaVersion}).Error; err != nil {
return fmt.Errorf("initialize schema_info: %w", err)
if tx.Migrator().HasTable(&schemaInfo{}) {
if err := validateSchemaVersion(tx); err != nil {
return err
}
return nil
}
if count != 1 {
return fmt.Errorf("schema_info contains %d rows, want exactly one", count)
return migrateCurrentSchema(tx)
}
var info schemaInfo
if err := tx.First(&info).Error; err != nil {
return fmt.Errorf("read schema_info: %w", err)
tables, err := tx.Migrator().GetTables()
if err != nil {
return fmt.Errorf("list SQLite tables: %w", err)
}
if info.Version != CurrentSchemaVersion {
return fmt.Errorf("unsupported schema version %d, want %d", info.Version, CurrentSchemaVersion)
for _, table := range tables {
if !strings.HasPrefix(table, "sqlite_") {
return fmt.Errorf("initialize SQLite schema: non-empty database without schema_info")
}
}
if err := migrateCurrentSchema(tx); err != nil {
return err
}
if err := tx.Create(&schemaInfo{Version: CurrentSchemaVersion}).Error; err != nil {
return fmt.Errorf("initialize schema_info: %w", err)
}
return nil
})
}
func migrateCurrentSchema(db *gorm.DB) error {
if err := db.AutoMigrate(
&models.Group{},
&models.UpstreamKey{},
&models.AccessKey{},
&models.RequestLog{},
&models.UsageStat{},
&models.ModelPrice{},
&models.SystemSetting{},
&models.Job{},
&schemaInfo{},
); err != nil {
return fmt.Errorf("auto-migrate SQLite schema: %w", err)
}
return nil
}
func validateSchemaVersion(db *gorm.DB) error {
var count int64
if err := db.Model(&schemaInfo{}).Count(&count).Error; err != nil {
return fmt.Errorf("count schema_info rows: %w", err)
}
if count != 1 {
return fmt.Errorf("schema_info contains %d rows, want exactly one", count)
}
var info schemaInfo
if err := db.First(&info).Error; err != nil {
return fmt.Errorf("read schema_info: %w", err)
}
if info.Version != CurrentSchemaVersion {
return fmt.Errorf("unsupported schema version %d, want %d", info.Version, CurrentSchemaVersion)
}
return nil
}
func withForeignKeysEnabled(dsn string) string {
separator := "?"
if strings.Contains(dsn, "?") {
+105
View File
@@ -2,6 +2,7 @@ package storage_test
import (
"path/filepath"
"strings"
"testing"
"time"
@@ -11,6 +12,74 @@ import (
"gpt-load/internal/storage/models"
)
func TestAutoMigrateKeepsUpstreamRuntimeFailuresOutOfDatabase(t *testing.T) {
t.Parallel()
db := openMigratedDatabase(t)
type columnInfo struct {
Name string
DefaultValue *string `gorm:"column:dflt_value"`
}
var columns []columnInfo
if err := db.Raw("PRAGMA table_info('upstream_keys')").Scan(&columns).Error; err != nil {
t.Fatalf("inspect upstream_keys columns: %v", err)
}
var statusDefault string
for _, column := range columns {
if column.Name == "failure_count" {
t.Fatal("upstream_keys contains failure_count; runtime failure state must not be persisted")
}
if column.Name == "status" && column.DefaultValue != nil {
statusDefault = strings.Trim(*column.DefaultValue, "'\"")
}
}
if statusDefault != string(models.UpstreamKeyStatusActive) {
t.Fatalf("upstream_keys status default = %q, want %q", statusDefault, models.UpstreamKeyStatusActive)
}
}
func TestUpstreamKeyStatusAcceptsOnlyDurableOperatorStates(t *testing.T) {
t.Parallel()
db := openMigratedDatabase(t)
group := models.Group{
Name: "status-parent",
UpstreamURL: "https://status.example.com",
Signature: "status-parent-signature",
Protocols: models.JSON(`["openai"]`),
Models: models.JSON(`[]`),
}
if err := db.Create(&group).Error; err != nil {
t.Fatalf("create parent group: %v", err)
}
for index, status := range []models.UpstreamKeyStatus{
models.UpstreamKeyStatusActive,
models.UpstreamKeyStatusDisabled,
} {
key := models.UpstreamKey{
GroupID: group.ID,
KeyValue: "ciphertext",
KeyHash: "allowed-status-" + string(rune('a'+index)),
Status: status,
}
if err := db.Create(&key).Error; err != nil {
t.Fatalf("create upstream key with status %q: %v", status, err)
}
}
invalid := models.UpstreamKey{
GroupID: group.ID,
KeyValue: "ciphertext",
KeyHash: "invalid-status",
Status: models.UpstreamKeyStatus("blacklisted"),
}
if err := db.Create(&invalid).Error; err == nil {
t.Fatal("create upstream key with runtime-only blacklisted status error = nil, want constraint error")
}
}
func TestOpenRejectsInvalidDSN(t *testing.T) {
t.Parallel()
@@ -99,6 +168,42 @@ func TestAutoMigrateCreatesNineTablesAndSchemaVersion(t *testing.T) {
}
}
func TestAutoMigrateRejectsNonEmptyDatabaseWithoutSchemaInfo(t *testing.T) {
t.Parallel()
db, err := storage.Open(":memory:")
if err != nil {
t.Fatalf("Open(:memory:) error = %v", err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("db.DB() error = %v", err)
}
t.Cleanup(func() {
if err := sqlDB.Close(); err != nil {
t.Errorf("close database: %v", err)
}
})
if err := db.Exec("CREATE TABLE legacy_data (id INTEGER PRIMARY KEY)").Error; err != nil {
t.Fatalf("create legacy table: %v", err)
}
err = storage.AutoMigrate(db)
if err == nil {
t.Fatal("AutoMigrate() error = nil, want rejection for an unversioned non-empty database")
}
if !strings.Contains(err.Error(), "non-empty database without schema_info") {
t.Fatalf("AutoMigrate() error = %q, want unversioned non-empty database error", err)
}
if db.Migrator().HasTable("groups") {
t.Fatal("AutoMigrate() created groups in an unversioned non-empty database")
}
if !db.Migrator().HasTable("legacy_data") {
t.Fatal("AutoMigrate() removed the pre-existing legacy table")
}
}
func TestAutoMigrateCreatesCriticalUniqueConstraints(t *testing.T) {
t.Parallel()
+14 -6
View File
@@ -2,6 +2,15 @@ package models
import "time"
// UpstreamKeyStatus is the durable operator-controlled state of a credential.
// Runtime cooldown and failure state belongs to state.KeyRegistry.
type UpstreamKeyStatus string
const (
UpstreamKeyStatusActive UpstreamKeyStatus = "active"
UpstreamKeyStatusDisabled UpstreamKeyStatus = "disabled"
)
// Group is the persisted configuration for an upstream service group.
// API DTOs and runtime state views must be defined outside the storage package.
type Group struct {
@@ -23,13 +32,12 @@ type Group struct {
// UpstreamKey is an encrypted provider credential that belongs to one group.
type UpstreamKey struct {
ID uint `gorm:"primaryKey;autoIncrement"`
GroupID uint `gorm:"not null;uniqueIndex:idx_upstream_keys_group_hash,priority:1"`
KeyValue string `gorm:"type:text;not null"`
KeyHash string `gorm:"type:varchar(128);not null;uniqueIndex:idx_upstream_keys_group_hash,priority:2"`
Status string `gorm:"type:varchar(32);not null;default:'active';index"`
ID uint `gorm:"primaryKey;autoIncrement"`
GroupID uint `gorm:"not null;uniqueIndex:idx_upstream_keys_group_hash,priority:1"`
KeyValue string `gorm:"type:text;not null"`
KeyHash string `gorm:"type:varchar(128);not null;uniqueIndex:idx_upstream_keys_group_hash,priority:2"`
Status UpstreamKeyStatus `gorm:"type:varchar(32);not null;default:'active';index;check:chk_upstream_key_status,status IN ('active','disabled')"`
WeightManual *int
FailureCount int64 `gorm:"not null;default:0"`
RequestCount int64 `gorm:"not null;default:0"`
TokensTotal int64 `gorm:"not null;default:0"`
CostTotal float64 `gorm:"not null;default:0"`
@@ -204,6 +204,22 @@ func TestServerStreamsGoldenSSEAndFlushesEveryEvent(t *testing.T) {
}
}
func TestAnthropicStreamFixtureHasCompleteContentBlockLifecycle(t *testing.T) {
want := []string{
"message_start",
"content_block_start",
"content_block_delta",
"content_block_stop",
"message_delta",
"message_stop",
}
got := sseEventTypes(readFixture(t, "anthropic", "stream.sse"))
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("Anthropic stream event types = %v, want %v", got, want)
}
}
func TestServerSupportsDelay(t *testing.T) {
const delay = 80 * time.Millisecond
server := New(Step{
@@ -412,3 +428,18 @@ func countSSEEvents(data []byte) int {
}
return len(strings.Split(strings.ReplaceAll(trimmed, "\r\n", "\n"), "\n\n"))
}
func sseEventTypes(data []byte) []string {
normalized := strings.ReplaceAll(strings.TrimSpace(string(data)), "\r\n", "\n")
events := strings.Split(normalized, "\n\n")
eventTypes := make([]string, 0, len(events))
for _, event := range events {
for _, line := range strings.Split(event, "\n") {
if eventType, found := strings.CutPrefix(line, "event:"); found {
eventTypes = append(eventTypes, strings.TrimSpace(eventType))
break
}
}
}
return eventTypes
}
@@ -1,12 +1,17 @@
event: message_start
data: {"type":"message_start","message":{"id":"msg_test","type":"message","role":"assistant","model":"claude-3-5-sonnet-20241022","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"output_tokens":0}}}
event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"pong"}}
event: content_block_stop
data: {"type":"content_block_stop","index":0}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":2}}
event: message_stop
data: {"type":"message_stop"}
+9 -12
View File
@@ -3,7 +3,7 @@ package main
import (
"context"
"embed"
"errors"
"fmt"
"io"
"os"
@@ -19,14 +19,6 @@ import (
"github.com/sirupsen/logrus"
)
// The 1.x UI remains embedded until M3 replaces its pages.
//
//go:embed web/dist
var buildFS embed.FS
//go:embed web/dist/index.html
var indexPage []byte
func main() {
if len(os.Args) > 1 {
os.Exit(dispatchCommand(os.Args[1:], os.Stdout, os.Stderr))
@@ -101,7 +93,12 @@ func runServer() error {
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(quit)
<-quit
var serveErr error
select {
case <-quit:
case serveErr = <-application.ServeErrors():
}
shutdownCtx, cancel := context.WithTimeout(
context.Background(),
@@ -109,7 +106,7 @@ func runServer() error {
)
defer cancel()
if err := application.Stop(shutdownCtx); err != nil {
return fmt.Errorf("stop application: %w", err)
return errors.Join(serveErr, fmt.Errorf("stop application: %w", err))
}
return nil
return serveErr
}
+1 -1
View File
@@ -28,7 +28,7 @@
"clean": "rm -rf dist node_modules/.vite",
"test:unit": "echo 'No tests specified' && exit 0",
"prepare": "echo 'Prepare script for potential Git hooks'",
"check-all": "npm lint:check && npm format:check && npm type-check"
"check-all": "npm run lint:check && npm run format:check && npm run type-check"
},
"dependencies": {
"@vicons/ionicons5": "^0.13.0",
+1 -1
View File
@@ -17,7 +17,7 @@ import { useI18n } from "vue-i18n";
const { t } = useI18n();
const versionInfo = ref<VersionInfo>({
currentVersion: "0.1.0",
currentVersion: "2.0.0-dev",
latestVersion: null,
isLatest: false,
hasUpdate: false,
+1 -1
View File
@@ -24,7 +24,7 @@ class VersionService {
private currentVersion: string;
constructor() {
this.currentVersion = import.meta.env.VITE_VERSION || "1.0.0";
this.currentVersion = import.meta.env.VITE_VERSION || "2.0.0-dev";
}
/**