fix(m0): 收口工程基建与运行时安全边界
This commit is contained in:
35 files changed
+955
-337
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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 ./...
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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, "")
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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
@@ -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, "?") {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user