diff --git a/.dockerignore b/.dockerignore index 78d065e6..6e9e200e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example index d3dc536d..032b3d0c 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5dc4680f..d5af46ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 ./... diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index 57ff08bd..fcf7b5c3 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -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: diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index 04c554c8..245dd609 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -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: diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index 373ae52a..e5c1ac0a 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -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: diff --git a/Dockerfile b/Dockerfile index ff0a55ec..a4078ab3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile index 5c58ace3..c3eb26b0 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index f7f4ec0a..22b2df0b 100644 --- a/README.md +++ b/README.md @@ -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:** diff --git a/README_CN.md b/README_CN.md index 34f6b482..a447f984 100644 --- a/README_CN.md +++ b/README_CN.md @@ -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` 下生成持久化密钥,静态加密不可关闭 | **数据库配置:** diff --git a/README_JP.md b/README_JP.md index 9098e7d2..cf5b6094 100644 --- a/README_JP.md +++ b/README_JP.md @@ -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` に永続キーを生成し、保存時暗号化は無効化できません | **データベース設定:** diff --git a/docker-compose.yml b/docker-compose.yml index e9557d0e..db75fe10 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/internal/app/app.go b/internal/app/app.go index d7b6beb8..dffdfa66 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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() diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 6dbddfeb..7c0bd34b 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -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:", diff --git a/internal/app/recovery.go b/internal/app/recovery.go new file mode 100644 index 00000000..5b788f57 --- /dev/null +++ b/internal/app/recovery.go @@ -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() + } +} diff --git a/internal/health/doc.go b/internal/health/doc.go new file mode 100644 index 00000000..f4b88ed4 --- /dev/null +++ b/internal/health/doc.go @@ -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 diff --git a/internal/platform/config/config.go b/internal/platform/config/config.go index d2f11f83..10d855be 100644 --- a/internal/platform/config/config.go +++ b/internal/platform/config/config.go @@ -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 == "" { diff --git a/internal/platform/config/config_test.go b/internal/platform/config/config_test.go index c16ce99c..641bb702 100644 --- a/internal/platform/config/config_test.go +++ b/internal/platform/config/config_test.go @@ -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, "") } diff --git a/internal/platform/encryption/encryption.go b/internal/platform/encryption/encryption.go index 09075a39..b12d4998 100644 --- a/internal/platform/encryption/encryption.go +++ b/internal/platform/encryption/encryption.go @@ -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)) } diff --git a/internal/platform/encryption/encryption_test.go b/internal/platform/encryption/encryption_test.go index 131fd601..969bcfec 100644 --- a/internal/platform/encryption/encryption_test.go +++ b/internal/platform/encryption/encryption_test.go @@ -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) - } -} diff --git a/internal/platform/encryption/keyfile_unix.go b/internal/platform/encryption/keyfile_unix.go index a332e25e..24ea1722 100644 --- a/internal/platform/encryption/keyfile_unix.go +++ b/internal/platform/encryption/keyfile_unix.go @@ -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 +} diff --git a/internal/platform/encryption/keyfile_windows.go b/internal/platform/encryption/keyfile_windows.go index a47fdfcb..0c8a5778 100644 --- a/internal/platform/encryption/keyfile_windows.go +++ b/internal/platform/encryption/keyfile_windows.go @@ -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 { diff --git a/internal/platform/httpclient/manager.go b/internal/platform/httpclient/manager.go index 9705a45a..0013f020 100644 --- a/internal/platform/httpclient/manager.go +++ b/internal/platform/httpclient/manager.go @@ -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. diff --git a/internal/platform/httpclient/manager_test.go b/internal/platform/httpclient/manager_test.go index 86040504..e5d41840 100644 --- a/internal/platform/httpclient/manager_test.go +++ b/internal/platform/httpclient/manager_test.go @@ -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) } } diff --git a/internal/platform/version/version.go b/internal/platform/version/version.go index 8773a742..a4228006 100644 --- a/internal/platform/version/version.go +++ b/internal/platform/version/version.go @@ -1,3 +1,3 @@ package version -var Version = "1.0.0" +var Version = "2.0.0-dev" diff --git a/internal/platform/version/version_test.go b/internal/platform/version/version_test.go new file mode 100644 index 00000000..09fd8155 --- /dev/null +++ b/internal/platform/version/version_test.go @@ -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) + } +} diff --git a/internal/storage/db.go b/internal/storage/db.go index 76230d95..94496e4c 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -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, "?") { diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go index f02bbe87..aebd42d7 100644 --- a/internal/storage/db_test.go +++ b/internal/storage/db_test.go @@ -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() diff --git a/internal/storage/models/group.go b/internal/storage/models/group.go index 219a3d7e..21d8a806 100644 --- a/internal/storage/models/group.go +++ b/internal/storage/models/group.go @@ -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"` diff --git a/internal/testutil/fakeupstream/server_test.go b/internal/testutil/fakeupstream/server_test.go index 6517d843..ce15f376 100644 --- a/internal/testutil/fakeupstream/server_test.go +++ b/internal/testutil/fakeupstream/server_test.go @@ -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 +} diff --git a/internal/testutil/fakeupstream/testdata/anthropic/stream.sse b/internal/testutil/fakeupstream/testdata/anthropic/stream.sse index 6ed45c0f..1ebb371b 100644 --- a/internal/testutil/fakeupstream/testdata/anthropic/stream.sse +++ b/internal/testutil/fakeupstream/testdata/anthropic/stream.sse @@ -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"} - diff --git a/main.go b/main.go index 762cab59..cbe552f6 100644 --- a/main.go +++ b/main.go @@ -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 } diff --git a/web/package.json b/web/package.json index d6dc1318..ef7695c3 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/components/AppFooter.vue b/web/src/components/AppFooter.vue index 53f92b5b..7fbe0fca 100644 --- a/web/src/components/AppFooter.vue +++ b/web/src/components/AppFooter.vue @@ -17,7 +17,7 @@ import { useI18n } from "vue-i18n"; const { t } = useI18n(); const versionInfo = ref({ - currentVersion: "0.1.0", + currentVersion: "2.0.0-dev", latestVersion: null, isLatest: false, hasUpdate: false, diff --git a/web/src/services/version.ts b/web/src/services/version.ts index 2dccf843..862b603f 100644 --- a/web/src/services/version.ts +++ b/web/src/services/version.ts @@ -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"; } /**