Commit 195e227c authored by song's avatar song
Browse files

merge: 合并 upstream/main 并保留本地图片计费功能

parents 6fa704d6 752882a0
......@@ -57,19 +57,24 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
cache: 'pnpm'
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install dependencies
run: npm ci
run: pnpm install --frozen-lockfile
working-directory: frontend
- name: Build frontend
run: npm run build
run: pnpm run build
working-directory: frontend
- name: Upload frontend artifact
......
......@@ -33,6 +33,7 @@ frontend/dist/
*.local
*.tsbuildinfo
vite.config.d.ts
vite.config.js.timestamp-*
# 日志
npm-debug.log*
......@@ -121,3 +122,4 @@ AGENTS.md
backend/cmd/server/server
deploy/docker-compose.override.yml
.gocache/
vite.config.js
......@@ -19,13 +19,16 @@ FROM ${NODE_IMAGE} AS frontend-builder
WORKDIR /app/frontend
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# Install dependencies first (better caching)
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/package.json frontend/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# Copy frontend source and build
COPY frontend/ ./
RUN npm run build
RUN pnpm run build
# -----------------------------------------------------------------------------
# Stage 2: Backend Builder
......
.PHONY: build build-backend build-frontend
.PHONY: build build-backend build-frontend test test-backend test-frontend
# 一键编译前后端
build: build-backend build-frontend
......@@ -10,3 +10,13 @@ build-backend:
# 编译前端(需要已安装依赖)
build-frontend:
@npm --prefix frontend run build
# 运行测试(后端 + 前端)
test: test-backend test-frontend
test-backend:
@$(MAKE) -C backend test
test-frontend:
@npm --prefix frontend run lint:check
@npm --prefix frontend run typecheck
......@@ -218,20 +218,23 @@ Build and run from source code for development or customization.
git clone https://github.com/Wei-Shaw/sub2api.git
cd sub2api
# 2. Build frontend
# 2. Install pnpm (if not already installed)
npm install -g pnpm
# 3. Build frontend
cd frontend
npm install
npm run build
pnpm install
pnpm run build
# Output will be in ../backend/internal/web/dist/
# 3. Build backend with embedded frontend
# 4. Build backend with embedded frontend
cd ../backend
go build -tags embed -o sub2api ./cmd/server
# 4. Create configuration file
# 5. Create configuration file
cp ../deploy/config.example.yaml ./config.yaml
# 5. Edit configuration
# 6. Edit configuration
nano config.yaml
```
......@@ -268,6 +271,24 @@ default:
rate_multiplier: 1.0
```
Additional security-related options are available in `config.yaml`:
- `cors.allowed_origins` for CORS allowlist
- `security.url_allowlist` for upstream/pricing/CRS host allowlists
- `security.url_allowlist.enabled` to disable URL validation (use with caution)
- `security.url_allowlist.allow_insecure_http` to allow http URLs when validation is disabled
- `security.response_headers.enabled` to enable configurable response header filtering (disabled uses default allowlist)
- `security.csp` to control Content-Security-Policy headers
- `billing.circuit_breaker` to fail closed on billing errors
- `server.trusted_proxies` to enable X-Forwarded-For parsing
- `turnstile.required` to require Turnstile in release mode
If you disable URL validation or response header filtering, harden your network layer:
- Enforce an egress allowlist for upstream domains/IPs
- Block private/loopback/link-local ranges
- Enforce TLS-only outbound traffic
- Strip sensitive upstream response headers at the proxy
```bash
# 6. Run the application
./sub2api
......@@ -282,7 +303,7 @@ go run ./cmd/server
# Frontend (with hot reload)
cd frontend
npm run dev
pnpm run dev
```
#### Code Generation
......
......@@ -218,20 +218,23 @@ docker-compose logs -f
git clone https://github.com/Wei-Shaw/sub2api.git
cd sub2api
# 2. 编译前端
# 2. 安装 pnpm(如果还没有安装)
npm install -g pnpm
# 3. 编译前端
cd frontend
npm install
npm run build
pnpm install
pnpm run build
# 构建产物输出到 ../backend/internal/web/dist/
# 3. 编译后端(嵌入前端)
# 4. 编译后端(嵌入前端)
cd ../backend
go build -tags embed -o sub2api ./cmd/server
# 4. 创建配置文件
# 5. 创建配置文件
cp ../deploy/config.example.yaml ./config.yaml
# 5. 编辑配置
# 6. 编辑配置
nano config.yaml
```
......@@ -268,6 +271,24 @@ default:
rate_multiplier: 1.0
```
`config.yaml` 还支持以下安全相关配置:
- `cors.allowed_origins` 配置 CORS 白名单
- `security.url_allowlist` 配置上游/价格数据/CRS 主机白名单
- `security.url_allowlist.enabled` 可关闭 URL 校验(慎用)
- `security.url_allowlist.allow_insecure_http` 关闭校验时允许 http URL
- `security.response_headers.enabled` 可启用可配置响应头过滤(关闭时使用默认白名单)
- `security.csp` 配置 Content-Security-Policy
- `billing.circuit_breaker` 计费异常时 fail-closed
- `server.trusted_proxies` 启用可信代理解析 X-Forwarded-For
- `turnstile.required` 在 release 模式强制启用 Turnstile
如关闭 URL 校验或响应头过滤,请加强网络层防护:
- 出站访问白名单限制上游域名/IP
- 阻断私网/回环/链路本地地址
- 强制仅允许 TLS 出站
- 在反向代理层移除敏感响应头
```bash
# 6. 运行应用
./sub2api
......@@ -282,7 +303,7 @@ go run ./cmd/server
# 前端(支持热重载)
cd frontend
npm run dev
pnpm run dev
```
#### 代码生成
......
.PHONY: build test-unit test-integration test-e2e
.PHONY: build test test-unit test-integration test-e2e
build:
go build -o bin/server ./cmd/server
test:
go test ./...
golangci-lint run ./...
test-unit:
go test -tags=unit ./...
......
......@@ -86,7 +86,8 @@ func main() {
func runSetupServer() {
r := gin.New()
r.Use(middleware.Recovery())
r.Use(middleware.CORS())
r.Use(middleware.CORS(config.CORSConfig{}))
r.Use(middleware.SecurityHeaders(config.CSPConfig{Enabled: true, Policy: config.DefaultCSPPolicy}))
// Register setup routes
setup.RegisterRoutes(r)
......
......@@ -76,7 +76,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
dashboardHandler := admin.NewDashboardHandler(dashboardService)
accountRepository := repository.NewAccountRepository(client, db)
proxyRepository := repository.NewProxyRepository(client, db)
proxyExitInfoProber := repository.NewProxyExitInfoProber()
proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig)
adminService := service.NewAdminService(userRepository, groupRepository, accountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, billingCacheService, proxyExitInfoProber)
adminUserHandler := admin.NewUserHandler(adminService)
groupHandler := admin.NewGroupHandler(adminService)
......@@ -101,10 +101,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
antigravityTokenProvider := service.NewAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService)
httpUpstream := repository.NewHTTPUpstream(configConfig)
antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, antigravityTokenProvider, rateLimitService, httpUpstream, settingService)
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, antigravityGatewayService, httpUpstream)
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, antigravityGatewayService, httpUpstream, configConfig)
concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig)
concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig)
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService)
concurrencyService := service.NewConcurrencyService(concurrencyCache)
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService)
oAuthHandler := admin.NewOAuthHandler(oAuthService)
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService)
......@@ -125,7 +125,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
userAttributeService := service.NewUserAttributeService(userAttributeDefinitionRepository, userAttributeValueRepository)
userAttributeHandler := admin.NewUserAttributeHandler(userAttributeService)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, settingHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler)
pricingRemoteClient := repository.NewPricingRemoteClient()
pricingRemoteClient := repository.NewPricingRemoteClient(configConfig)
pricingService, err := service.ProvidePricingService(configConfig, pricingRemoteClient)
if err != nil {
return nil, err
......@@ -136,10 +136,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
timingWheelService := service.ProvideTimingWheelService()
deferredService := service.ProvideDeferredService(accountRepository, timingWheelService)
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService)
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService)
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService)
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService, configConfig)
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, configConfig)
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService)
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService)
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, configConfig)
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo)
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler)
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService)
......
......@@ -27,6 +27,8 @@ type Account struct {
DeletedAt *time.Time `json:"deleted_at,omitempty"`
// Name holds the value of the "name" field.
Name string `json:"name,omitempty"`
// Notes holds the value of the "notes" field.
Notes *string `json:"notes,omitempty"`
// Platform holds the value of the "platform" field.
Platform string `json:"platform,omitempty"`
// Type holds the value of the "type" field.
......@@ -131,7 +133,7 @@ func (*Account) scanValues(columns []string) ([]any, error) {
values[i] = new(sql.NullBool)
case account.FieldID, account.FieldProxyID, account.FieldConcurrency, account.FieldPriority:
values[i] = new(sql.NullInt64)
case account.FieldName, account.FieldPlatform, account.FieldType, account.FieldStatus, account.FieldErrorMessage, account.FieldSessionWindowStatus:
case account.FieldName, account.FieldNotes, account.FieldPlatform, account.FieldType, account.FieldStatus, account.FieldErrorMessage, account.FieldSessionWindowStatus:
values[i] = new(sql.NullString)
case account.FieldCreatedAt, account.FieldUpdatedAt, account.FieldDeletedAt, account.FieldLastUsedAt, account.FieldRateLimitedAt, account.FieldRateLimitResetAt, account.FieldOverloadUntil, account.FieldSessionWindowStart, account.FieldSessionWindowEnd:
values[i] = new(sql.NullTime)
......@@ -181,6 +183,13 @@ func (_m *Account) assignValues(columns []string, values []any) error {
} else if value.Valid {
_m.Name = value.String
}
case account.FieldNotes:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field notes", values[i])
} else if value.Valid {
_m.Notes = new(string)
*_m.Notes = value.String
}
case account.FieldPlatform:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field platform", values[i])
......@@ -366,6 +375,11 @@ func (_m *Account) String() string {
builder.WriteString("name=")
builder.WriteString(_m.Name)
builder.WriteString(", ")
if v := _m.Notes; v != nil {
builder.WriteString("notes=")
builder.WriteString(*v)
}
builder.WriteString(", ")
builder.WriteString("platform=")
builder.WriteString(_m.Platform)
builder.WriteString(", ")
......
......@@ -23,6 +23,8 @@ const (
FieldDeletedAt = "deleted_at"
// FieldName holds the string denoting the name field in the database.
FieldName = "name"
// FieldNotes holds the string denoting the notes field in the database.
FieldNotes = "notes"
// FieldPlatform holds the string denoting the platform field in the database.
FieldPlatform = "platform"
// FieldType holds the string denoting the type field in the database.
......@@ -102,6 +104,7 @@ var Columns = []string{
FieldUpdatedAt,
FieldDeletedAt,
FieldName,
FieldNotes,
FieldPlatform,
FieldType,
FieldCredentials,
......@@ -203,6 +206,11 @@ func ByName(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldName, opts...).ToFunc()
}
// ByNotes orders the results by the notes field.
func ByNotes(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldNotes, opts...).ToFunc()
}
// ByPlatform orders the results by the platform field.
func ByPlatform(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldPlatform, opts...).ToFunc()
......
......@@ -75,6 +75,11 @@ func Name(v string) predicate.Account {
return predicate.Account(sql.FieldEQ(FieldName, v))
}
// Notes applies equality check predicate on the "notes" field. It's identical to NotesEQ.
func Notes(v string) predicate.Account {
return predicate.Account(sql.FieldEQ(FieldNotes, v))
}
// Platform applies equality check predicate on the "platform" field. It's identical to PlatformEQ.
func Platform(v string) predicate.Account {
return predicate.Account(sql.FieldEQ(FieldPlatform, v))
......@@ -345,6 +350,81 @@ func NameContainsFold(v string) predicate.Account {
return predicate.Account(sql.FieldContainsFold(FieldName, v))
}
// NotesEQ applies the EQ predicate on the "notes" field.
func NotesEQ(v string) predicate.Account {
return predicate.Account(sql.FieldEQ(FieldNotes, v))
}
// NotesNEQ applies the NEQ predicate on the "notes" field.
func NotesNEQ(v string) predicate.Account {
return predicate.Account(sql.FieldNEQ(FieldNotes, v))
}
// NotesIn applies the In predicate on the "notes" field.
func NotesIn(vs ...string) predicate.Account {
return predicate.Account(sql.FieldIn(FieldNotes, vs...))
}
// NotesNotIn applies the NotIn predicate on the "notes" field.
func NotesNotIn(vs ...string) predicate.Account {
return predicate.Account(sql.FieldNotIn(FieldNotes, vs...))
}
// NotesGT applies the GT predicate on the "notes" field.
func NotesGT(v string) predicate.Account {
return predicate.Account(sql.FieldGT(FieldNotes, v))
}
// NotesGTE applies the GTE predicate on the "notes" field.
func NotesGTE(v string) predicate.Account {
return predicate.Account(sql.FieldGTE(FieldNotes, v))
}
// NotesLT applies the LT predicate on the "notes" field.
func NotesLT(v string) predicate.Account {
return predicate.Account(sql.FieldLT(FieldNotes, v))
}
// NotesLTE applies the LTE predicate on the "notes" field.
func NotesLTE(v string) predicate.Account {
return predicate.Account(sql.FieldLTE(FieldNotes, v))
}
// NotesContains applies the Contains predicate on the "notes" field.
func NotesContains(v string) predicate.Account {
return predicate.Account(sql.FieldContains(FieldNotes, v))
}
// NotesHasPrefix applies the HasPrefix predicate on the "notes" field.
func NotesHasPrefix(v string) predicate.Account {
return predicate.Account(sql.FieldHasPrefix(FieldNotes, v))
}
// NotesHasSuffix applies the HasSuffix predicate on the "notes" field.
func NotesHasSuffix(v string) predicate.Account {
return predicate.Account(sql.FieldHasSuffix(FieldNotes, v))
}
// NotesIsNil applies the IsNil predicate on the "notes" field.
func NotesIsNil() predicate.Account {
return predicate.Account(sql.FieldIsNull(FieldNotes))
}
// NotesNotNil applies the NotNil predicate on the "notes" field.
func NotesNotNil() predicate.Account {
return predicate.Account(sql.FieldNotNull(FieldNotes))
}
// NotesEqualFold applies the EqualFold predicate on the "notes" field.
func NotesEqualFold(v string) predicate.Account {
return predicate.Account(sql.FieldEqualFold(FieldNotes, v))
}
// NotesContainsFold applies the ContainsFold predicate on the "notes" field.
func NotesContainsFold(v string) predicate.Account {
return predicate.Account(sql.FieldContainsFold(FieldNotes, v))
}
// PlatformEQ applies the EQ predicate on the "platform" field.
func PlatformEQ(v string) predicate.Account {
return predicate.Account(sql.FieldEQ(FieldPlatform, v))
......
......@@ -73,6 +73,20 @@ func (_c *AccountCreate) SetName(v string) *AccountCreate {
return _c
}
// SetNotes sets the "notes" field.
func (_c *AccountCreate) SetNotes(v string) *AccountCreate {
_c.mutation.SetNotes(v)
return _c
}
// SetNillableNotes sets the "notes" field if the given value is not nil.
func (_c *AccountCreate) SetNillableNotes(v *string) *AccountCreate {
if v != nil {
_c.SetNotes(*v)
}
return _c
}
// SetPlatform sets the "platform" field.
func (_c *AccountCreate) SetPlatform(v string) *AccountCreate {
_c.mutation.SetPlatform(v)
......@@ -501,6 +515,10 @@ func (_c *AccountCreate) createSpec() (*Account, *sqlgraph.CreateSpec) {
_spec.SetField(account.FieldName, field.TypeString, value)
_node.Name = value
}
if value, ok := _c.mutation.Notes(); ok {
_spec.SetField(account.FieldNotes, field.TypeString, value)
_node.Notes = &value
}
if value, ok := _c.mutation.Platform(); ok {
_spec.SetField(account.FieldPlatform, field.TypeString, value)
_node.Platform = value
......@@ -712,6 +730,24 @@ func (u *AccountUpsert) UpdateName() *AccountUpsert {
return u
}
// SetNotes sets the "notes" field.
func (u *AccountUpsert) SetNotes(v string) *AccountUpsert {
u.Set(account.FieldNotes, v)
return u
}
// UpdateNotes sets the "notes" field to the value that was provided on create.
func (u *AccountUpsert) UpdateNotes() *AccountUpsert {
u.SetExcluded(account.FieldNotes)
return u
}
// ClearNotes clears the value of the "notes" field.
func (u *AccountUpsert) ClearNotes() *AccountUpsert {
u.SetNull(account.FieldNotes)
return u
}
// SetPlatform sets the "platform" field.
func (u *AccountUpsert) SetPlatform(v string) *AccountUpsert {
u.Set(account.FieldPlatform, v)
......@@ -1076,6 +1112,27 @@ func (u *AccountUpsertOne) UpdateName() *AccountUpsertOne {
})
}
// SetNotes sets the "notes" field.
func (u *AccountUpsertOne) SetNotes(v string) *AccountUpsertOne {
return u.Update(func(s *AccountUpsert) {
s.SetNotes(v)
})
}
// UpdateNotes sets the "notes" field to the value that was provided on create.
func (u *AccountUpsertOne) UpdateNotes() *AccountUpsertOne {
return u.Update(func(s *AccountUpsert) {
s.UpdateNotes()
})
}
// ClearNotes clears the value of the "notes" field.
func (u *AccountUpsertOne) ClearNotes() *AccountUpsertOne {
return u.Update(func(s *AccountUpsert) {
s.ClearNotes()
})
}
// SetPlatform sets the "platform" field.
func (u *AccountUpsertOne) SetPlatform(v string) *AccountUpsertOne {
return u.Update(func(s *AccountUpsert) {
......@@ -1651,6 +1708,27 @@ func (u *AccountUpsertBulk) UpdateName() *AccountUpsertBulk {
})
}
// SetNotes sets the "notes" field.
func (u *AccountUpsertBulk) SetNotes(v string) *AccountUpsertBulk {
return u.Update(func(s *AccountUpsert) {
s.SetNotes(v)
})
}
// UpdateNotes sets the "notes" field to the value that was provided on create.
func (u *AccountUpsertBulk) UpdateNotes() *AccountUpsertBulk {
return u.Update(func(s *AccountUpsert) {
s.UpdateNotes()
})
}
// ClearNotes clears the value of the "notes" field.
func (u *AccountUpsertBulk) ClearNotes() *AccountUpsertBulk {
return u.Update(func(s *AccountUpsert) {
s.ClearNotes()
})
}
// SetPlatform sets the "platform" field.
func (u *AccountUpsertBulk) SetPlatform(v string) *AccountUpsertBulk {
return u.Update(func(s *AccountUpsert) {
......
......@@ -71,6 +71,26 @@ func (_u *AccountUpdate) SetNillableName(v *string) *AccountUpdate {
return _u
}
// SetNotes sets the "notes" field.
func (_u *AccountUpdate) SetNotes(v string) *AccountUpdate {
_u.mutation.SetNotes(v)
return _u
}
// SetNillableNotes sets the "notes" field if the given value is not nil.
func (_u *AccountUpdate) SetNillableNotes(v *string) *AccountUpdate {
if v != nil {
_u.SetNotes(*v)
}
return _u
}
// ClearNotes clears the value of the "notes" field.
func (_u *AccountUpdate) ClearNotes() *AccountUpdate {
_u.mutation.ClearNotes()
return _u
}
// SetPlatform sets the "platform" field.
func (_u *AccountUpdate) SetPlatform(v string) *AccountUpdate {
_u.mutation.SetPlatform(v)
......@@ -545,6 +565,12 @@ func (_u *AccountUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if value, ok := _u.mutation.Name(); ok {
_spec.SetField(account.FieldName, field.TypeString, value)
}
if value, ok := _u.mutation.Notes(); ok {
_spec.SetField(account.FieldNotes, field.TypeString, value)
}
if _u.mutation.NotesCleared() {
_spec.ClearField(account.FieldNotes, field.TypeString)
}
if value, ok := _u.mutation.Platform(); ok {
_spec.SetField(account.FieldPlatform, field.TypeString, value)
}
......@@ -814,6 +840,26 @@ func (_u *AccountUpdateOne) SetNillableName(v *string) *AccountUpdateOne {
return _u
}
// SetNotes sets the "notes" field.
func (_u *AccountUpdateOne) SetNotes(v string) *AccountUpdateOne {
_u.mutation.SetNotes(v)
return _u
}
// SetNillableNotes sets the "notes" field if the given value is not nil.
func (_u *AccountUpdateOne) SetNillableNotes(v *string) *AccountUpdateOne {
if v != nil {
_u.SetNotes(*v)
}
return _u
}
// ClearNotes clears the value of the "notes" field.
func (_u *AccountUpdateOne) ClearNotes() *AccountUpdateOne {
_u.mutation.ClearNotes()
return _u
}
// SetPlatform sets the "platform" field.
func (_u *AccountUpdateOne) SetPlatform(v string) *AccountUpdateOne {
_u.mutation.SetPlatform(v)
......@@ -1318,6 +1364,12 @@ func (_u *AccountUpdateOne) sqlSave(ctx context.Context) (_node *Account, err er
if value, ok := _u.mutation.Name(); ok {
_spec.SetField(account.FieldName, field.TypeString, value)
}
if value, ok := _u.mutation.Notes(); ok {
_spec.SetField(account.FieldNotes, field.TypeString, value)
}
if _u.mutation.NotesCleared() {
_spec.ClearField(account.FieldNotes, field.TypeString)
}
if value, ok := _u.mutation.Platform(); ok {
_spec.SetField(account.FieldPlatform, field.TypeString, value)
}
......
......@@ -70,6 +70,7 @@ var (
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "deleted_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "name", Type: field.TypeString, Size: 100},
{Name: "notes", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "text"}},
{Name: "platform", Type: field.TypeString, Size: 50},
{Name: "type", Type: field.TypeString, Size: 20},
{Name: "credentials", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
......@@ -96,7 +97,7 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "accounts_proxies_proxy",
Columns: []*schema.Column{AccountsColumns[21]},
Columns: []*schema.Column{AccountsColumns[22]},
RefColumns: []*schema.Column{ProxiesColumns[0]},
OnDelete: schema.SetNull,
},
......@@ -105,52 +106,52 @@ var (
{
Name: "account_platform",
Unique: false,
Columns: []*schema.Column{AccountsColumns[5]},
Columns: []*schema.Column{AccountsColumns[6]},
},
{
Name: "account_type",
Unique: false,
Columns: []*schema.Column{AccountsColumns[6]},
Columns: []*schema.Column{AccountsColumns[7]},
},
{
Name: "account_status",
Unique: false,
Columns: []*schema.Column{AccountsColumns[11]},
Columns: []*schema.Column{AccountsColumns[12]},
},
{
Name: "account_proxy_id",
Unique: false,
Columns: []*schema.Column{AccountsColumns[21]},
Columns: []*schema.Column{AccountsColumns[22]},
},
{
Name: "account_priority",
Unique: false,
Columns: []*schema.Column{AccountsColumns[10]},
Columns: []*schema.Column{AccountsColumns[11]},
},
{
Name: "account_last_used_at",
Unique: false,
Columns: []*schema.Column{AccountsColumns[13]},
Columns: []*schema.Column{AccountsColumns[14]},
},
{
Name: "account_schedulable",
Unique: false,
Columns: []*schema.Column{AccountsColumns[14]},
Columns: []*schema.Column{AccountsColumns[15]},
},
{
Name: "account_rate_limited_at",
Unique: false,
Columns: []*schema.Column{AccountsColumns[15]},
Columns: []*schema.Column{AccountsColumns[16]},
},
{
Name: "account_rate_limit_reset_at",
Unique: false,
Columns: []*schema.Column{AccountsColumns[16]},
Columns: []*schema.Column{AccountsColumns[17]},
},
{
Name: "account_overload_until",
Unique: false,
Columns: []*schema.Column{AccountsColumns[17]},
Columns: []*schema.Column{AccountsColumns[18]},
},
{
Name: "account_deleted_at",
......
......@@ -994,6 +994,7 @@ type AccountMutation struct {
updated_at *time.Time
deleted_at *time.Time
name *string
notes *string
platform *string
_type *string
credentials *map[string]interface{}
......@@ -1281,6 +1282,55 @@ func (m *AccountMutation) ResetName() {
m.name = nil
}
// SetNotes sets the "notes" field.
func (m *AccountMutation) SetNotes(s string) {
m.notes = &s
}
// Notes returns the value of the "notes" field in the mutation.
func (m *AccountMutation) Notes() (r string, exists bool) {
v := m.notes
if v == nil {
return
}
return *v, true
}
// OldNotes returns the old "notes" field's value of the Account entity.
// If the Account object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *AccountMutation) OldNotes(ctx context.Context) (v *string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldNotes is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldNotes requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldNotes: %w", err)
}
return oldValue.Notes, nil
}
// ClearNotes clears the value of the "notes" field.
func (m *AccountMutation) ClearNotes() {
m.notes = nil
m.clearedFields[account.FieldNotes] = struct{}{}
}
// NotesCleared returns if the "notes" field was cleared in this mutation.
func (m *AccountMutation) NotesCleared() bool {
_, ok := m.clearedFields[account.FieldNotes]
return ok
}
// ResetNotes resets all changes to the "notes" field.
func (m *AccountMutation) ResetNotes() {
m.notes = nil
delete(m.clearedFields, account.FieldNotes)
}
// SetPlatform sets the "platform" field.
func (m *AccountMutation) SetPlatform(s string) {
m.platform = &s
......@@ -2219,7 +2269,7 @@ func (m *AccountMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *AccountMutation) Fields() []string {
fields := make([]string, 0, 21)
fields := make([]string, 0, 22)
if m.created_at != nil {
fields = append(fields, account.FieldCreatedAt)
}
......@@ -2232,6 +2282,9 @@ func (m *AccountMutation) Fields() []string {
if m.name != nil {
fields = append(fields, account.FieldName)
}
if m.notes != nil {
fields = append(fields, account.FieldNotes)
}
if m.platform != nil {
fields = append(fields, account.FieldPlatform)
}
......@@ -2299,6 +2352,8 @@ func (m *AccountMutation) Field(name string) (ent.Value, bool) {
return m.DeletedAt()
case account.FieldName:
return m.Name()
case account.FieldNotes:
return m.Notes()
case account.FieldPlatform:
return m.Platform()
case account.FieldType:
......@@ -2350,6 +2405,8 @@ func (m *AccountMutation) OldField(ctx context.Context, name string) (ent.Value,
return m.OldDeletedAt(ctx)
case account.FieldName:
return m.OldName(ctx)
case account.FieldNotes:
return m.OldNotes(ctx)
case account.FieldPlatform:
return m.OldPlatform(ctx)
case account.FieldType:
......@@ -2421,6 +2478,13 @@ func (m *AccountMutation) SetField(name string, value ent.Value) error {
}
m.SetName(v)
return nil
case account.FieldNotes:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetNotes(v)
return nil
case account.FieldPlatform:
v, ok := value.(string)
if !ok {
......@@ -2600,6 +2664,9 @@ func (m *AccountMutation) ClearedFields() []string {
if m.FieldCleared(account.FieldDeletedAt) {
fields = append(fields, account.FieldDeletedAt)
}
if m.FieldCleared(account.FieldNotes) {
fields = append(fields, account.FieldNotes)
}
if m.FieldCleared(account.FieldProxyID) {
fields = append(fields, account.FieldProxyID)
}
......@@ -2644,6 +2711,9 @@ func (m *AccountMutation) ClearField(name string) error {
case account.FieldDeletedAt:
m.ClearDeletedAt()
return nil
case account.FieldNotes:
m.ClearNotes()
return nil
case account.FieldProxyID:
m.ClearProxyID()
return nil
......@@ -2691,6 +2761,9 @@ func (m *AccountMutation) ResetField(name string) error {
case account.FieldName:
m.ResetName()
return nil
case account.FieldNotes:
m.ResetNotes()
return nil
case account.FieldPlatform:
m.ResetPlatform()
return nil
......
......@@ -124,7 +124,7 @@ func init() {
}
}()
// accountDescPlatform is the schema descriptor for platform field.
accountDescPlatform := accountFields[1].Descriptor()
accountDescPlatform := accountFields[2].Descriptor()
// account.PlatformValidator is a validator for the "platform" field. It is called by the builders before save.
account.PlatformValidator = func() func(string) error {
validators := accountDescPlatform.Validators
......@@ -142,7 +142,7 @@ func init() {
}
}()
// accountDescType is the schema descriptor for type field.
accountDescType := accountFields[2].Descriptor()
accountDescType := accountFields[3].Descriptor()
// account.TypeValidator is a validator for the "type" field. It is called by the builders before save.
account.TypeValidator = func() func(string) error {
validators := accountDescType.Validators
......@@ -160,33 +160,33 @@ func init() {
}
}()
// accountDescCredentials is the schema descriptor for credentials field.
accountDescCredentials := accountFields[3].Descriptor()
accountDescCredentials := accountFields[4].Descriptor()
// account.DefaultCredentials holds the default value on creation for the credentials field.
account.DefaultCredentials = accountDescCredentials.Default.(func() map[string]interface{})
// accountDescExtra is the schema descriptor for extra field.
accountDescExtra := accountFields[4].Descriptor()
accountDescExtra := accountFields[5].Descriptor()
// account.DefaultExtra holds the default value on creation for the extra field.
account.DefaultExtra = accountDescExtra.Default.(func() map[string]interface{})
// accountDescConcurrency is the schema descriptor for concurrency field.
accountDescConcurrency := accountFields[6].Descriptor()
accountDescConcurrency := accountFields[7].Descriptor()
// account.DefaultConcurrency holds the default value on creation for the concurrency field.
account.DefaultConcurrency = accountDescConcurrency.Default.(int)
// accountDescPriority is the schema descriptor for priority field.
accountDescPriority := accountFields[7].Descriptor()
accountDescPriority := accountFields[8].Descriptor()
// account.DefaultPriority holds the default value on creation for the priority field.
account.DefaultPriority = accountDescPriority.Default.(int)
// accountDescStatus is the schema descriptor for status field.
accountDescStatus := accountFields[8].Descriptor()
accountDescStatus := accountFields[9].Descriptor()
// account.DefaultStatus holds the default value on creation for the status field.
account.DefaultStatus = accountDescStatus.Default.(string)
// account.StatusValidator is a validator for the "status" field. It is called by the builders before save.
account.StatusValidator = accountDescStatus.Validators[0].(func(string) error)
// accountDescSchedulable is the schema descriptor for schedulable field.
accountDescSchedulable := accountFields[11].Descriptor()
accountDescSchedulable := accountFields[12].Descriptor()
// account.DefaultSchedulable holds the default value on creation for the schedulable field.
account.DefaultSchedulable = accountDescSchedulable.Default.(bool)
// accountDescSessionWindowStatus is the schema descriptor for session_window_status field.
accountDescSessionWindowStatus := accountFields[17].Descriptor()
accountDescSessionWindowStatus := accountFields[18].Descriptor()
// account.SessionWindowStatusValidator is a validator for the "session_window_status" field. It is called by the builders before save.
account.SessionWindowStatusValidator = accountDescSessionWindowStatus.Validators[0].(func(string) error)
accountgroupFields := schema.AccountGroup{}.Fields()
......
......@@ -54,6 +54,11 @@ func (Account) Fields() []ent.Field {
field.String("name").
MaxLen(100).
NotEmpty(),
// notes: 管理员备注(可为空)
field.String("notes").
Optional().
Nillable().
SchemaType(map[string]string{dialect.Postgres: "text"}),
// platform: 所属平台,如 "claude", "gemini", "openai" 等
field.String("platform").
......
......@@ -2,7 +2,11 @@
package config
import (
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"os"
"strings"
"time"
......@@ -14,6 +18,8 @@ const (
RunModeSimple = "simple"
)
const DefaultCSPPolicy = "default-src 'self'; script-src 'self' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-src https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
// 连接池隔离策略常量
// 用于控制上游 HTTP 连接池的隔离粒度,影响连接复用和资源消耗
const (
......@@ -30,6 +36,10 @@ const (
type Config struct {
Server ServerConfig `mapstructure:"server"`
CORS CORSConfig `mapstructure:"cors"`
Security SecurityConfig `mapstructure:"security"`
Billing BillingConfig `mapstructure:"billing"`
Turnstile TurnstileConfig `mapstructure:"turnstile"`
Database DatabaseConfig `mapstructure:"database"`
Redis RedisConfig `mapstructure:"redis"`
JWT JWTConfig `mapstructure:"jwt"`
......@@ -37,6 +47,7 @@ type Config struct {
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
Pricing PricingConfig `mapstructure:"pricing"`
Gateway GatewayConfig `mapstructure:"gateway"`
Concurrency ConcurrencyConfig `mapstructure:"concurrency"`
TokenRefresh TokenRefreshConfig `mapstructure:"token_refresh"`
RunMode string `mapstructure:"run_mode" yaml:"run_mode"`
Timezone string `mapstructure:"timezone"` // e.g. "Asia/Shanghai", "UTC"
......@@ -95,11 +106,65 @@ type PricingConfig struct {
}
type ServerConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Mode string `mapstructure:"mode"` // debug/release
ReadHeaderTimeout int `mapstructure:"read_header_timeout"` // 读取请求头超时(秒)
IdleTimeout int `mapstructure:"idle_timeout"` // 空闲连接超时(秒)
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Mode string `mapstructure:"mode"` // debug/release
ReadHeaderTimeout int `mapstructure:"read_header_timeout"` // 读取请求头超时(秒)
IdleTimeout int `mapstructure:"idle_timeout"` // 空闲连接超时(秒)
TrustedProxies []string `mapstructure:"trusted_proxies"` // 可信代理列表(CIDR/IP)
}
type CORSConfig struct {
AllowedOrigins []string `mapstructure:"allowed_origins"`
AllowCredentials bool `mapstructure:"allow_credentials"`
}
type SecurityConfig struct {
URLAllowlist URLAllowlistConfig `mapstructure:"url_allowlist"`
ResponseHeaders ResponseHeaderConfig `mapstructure:"response_headers"`
CSP CSPConfig `mapstructure:"csp"`
ProxyProbe ProxyProbeConfig `mapstructure:"proxy_probe"`
}
type URLAllowlistConfig struct {
Enabled bool `mapstructure:"enabled"`
UpstreamHosts []string `mapstructure:"upstream_hosts"`
PricingHosts []string `mapstructure:"pricing_hosts"`
CRSHosts []string `mapstructure:"crs_hosts"`
AllowPrivateHosts bool `mapstructure:"allow_private_hosts"`
// 关闭 URL 白名单校验时,是否允许 http URL(默认只允许 https)
AllowInsecureHTTP bool `mapstructure:"allow_insecure_http"`
}
type ResponseHeaderConfig struct {
Enabled bool `mapstructure:"enabled"`
AdditionalAllowed []string `mapstructure:"additional_allowed"`
ForceRemove []string `mapstructure:"force_remove"`
}
type CSPConfig struct {
Enabled bool `mapstructure:"enabled"`
Policy string `mapstructure:"policy"`
}
type ProxyProbeConfig struct {
InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"`
}
type BillingConfig struct {
CircuitBreaker CircuitBreakerConfig `mapstructure:"circuit_breaker"`
}
type CircuitBreakerConfig struct {
Enabled bool `mapstructure:"enabled"`
FailureThreshold int `mapstructure:"failure_threshold"`
ResetTimeoutSeconds int `mapstructure:"reset_timeout_seconds"`
HalfOpenRequests int `mapstructure:"half_open_requests"`
}
type ConcurrencyConfig struct {
// PingInterval: 并发等待期间的 SSE ping 间隔(秒)
PingInterval int `mapstructure:"ping_interval"`
}
// GatewayConfig API网关相关配置
......@@ -134,6 +199,13 @@ type GatewayConfig struct {
// 应大于最长 LLM 请求时间,防止请求完成前槽位过期
ConcurrencySlotTTLMinutes int `mapstructure:"concurrency_slot_ttl_minutes"`
// StreamDataIntervalTimeout: 流数据间隔超时(秒),0表示禁用
StreamDataIntervalTimeout int `mapstructure:"stream_data_interval_timeout"`
// StreamKeepaliveInterval: 流式 keepalive 间隔(秒),0表示禁用
StreamKeepaliveInterval int `mapstructure:"stream_keepalive_interval"`
// MaxLineSize: 上游 SSE 单行最大字节数(0使用默认值)
MaxLineSize int `mapstructure:"max_line_size"`
// 是否记录上游错误响应体摘要(避免输出请求内容)
LogUpstreamErrorBody bool `mapstructure:"log_upstream_error_body"`
// 上游错误响应体记录最大字节数(超过会截断)
......@@ -237,6 +309,10 @@ type JWTConfig struct {
ExpireHour int `mapstructure:"expire_hour"`
}
type TurnstileConfig struct {
Required bool `mapstructure:"required"`
}
type DefaultConfig struct {
AdminEmail string `mapstructure:"admin_email"`
AdminPassword string `mapstructure:"admin_password"`
......@@ -263,8 +339,19 @@ func NormalizeRunMode(value string) string {
func Load() (*Config, error) {
viper.SetConfigName("config")
viper.SetConfigType("yaml")
// Add config paths in priority order
// 1. DATA_DIR environment variable (highest priority)
if dataDir := os.Getenv("DATA_DIR"); dataDir != "" {
viper.AddConfigPath(dataDir)
}
// 2. Docker data directory
viper.AddConfigPath("/app/data")
// 3. Current directory
viper.AddConfigPath(".")
// 4. Config subdirectory
viper.AddConfigPath("./config")
// 5. System config directory
viper.AddConfigPath("/etc/sub2api")
// 环境变量支持
......@@ -287,11 +374,46 @@ func Load() (*Config, error) {
}
cfg.RunMode = NormalizeRunMode(cfg.RunMode)
cfg.Server.Mode = strings.ToLower(strings.TrimSpace(cfg.Server.Mode))
if cfg.Server.Mode == "" {
cfg.Server.Mode = "debug"
}
cfg.JWT.Secret = strings.TrimSpace(cfg.JWT.Secret)
cfg.CORS.AllowedOrigins = normalizeStringSlice(cfg.CORS.AllowedOrigins)
cfg.Security.ResponseHeaders.AdditionalAllowed = normalizeStringSlice(cfg.Security.ResponseHeaders.AdditionalAllowed)
cfg.Security.ResponseHeaders.ForceRemove = normalizeStringSlice(cfg.Security.ResponseHeaders.ForceRemove)
cfg.Security.CSP.Policy = strings.TrimSpace(cfg.Security.CSP.Policy)
if cfg.JWT.Secret == "" {
secret, err := generateJWTSecret(64)
if err != nil {
return nil, fmt.Errorf("generate jwt secret error: %w", err)
}
cfg.JWT.Secret = secret
log.Println("Warning: JWT secret auto-generated. Consider setting a fixed secret for production.")
}
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("validate config error: %w", err)
}
if !cfg.Security.URLAllowlist.Enabled {
log.Println("Warning: security.url_allowlist.enabled=false; allowlist/SSRF checks disabled (minimal format validation only).")
}
if !cfg.Security.ResponseHeaders.Enabled {
log.Println("Warning: security.response_headers.enabled=false; configurable header filtering disabled (default allowlist only).")
}
if cfg.JWT.Secret != "" && isWeakJWTSecret(cfg.JWT.Secret) {
log.Println("Warning: JWT secret appears weak; use a 32+ character random secret in production.")
}
if len(cfg.Security.ResponseHeaders.AdditionalAllowed) > 0 || len(cfg.Security.ResponseHeaders.ForceRemove) > 0 {
log.Printf("AUDIT: response header policy configured additional_allowed=%v force_remove=%v",
cfg.Security.ResponseHeaders.AdditionalAllowed,
cfg.Security.ResponseHeaders.ForceRemove,
)
}
return &cfg, nil
}
......@@ -304,6 +426,45 @@ func setDefaults() {
viper.SetDefault("server.mode", "debug")
viper.SetDefault("server.read_header_timeout", 30) // 30秒读取请求头
viper.SetDefault("server.idle_timeout", 120) // 120秒空闲超时
viper.SetDefault("server.trusted_proxies", []string{})
// CORS
viper.SetDefault("cors.allowed_origins", []string{})
viper.SetDefault("cors.allow_credentials", true)
// Security
viper.SetDefault("security.url_allowlist.enabled", false)
viper.SetDefault("security.url_allowlist.upstream_hosts", []string{
"api.openai.com",
"api.anthropic.com",
"api.kimi.com",
"open.bigmodel.cn",
"api.minimaxi.com",
"generativelanguage.googleapis.com",
"cloudcode-pa.googleapis.com",
"*.openai.azure.com",
})
viper.SetDefault("security.url_allowlist.pricing_hosts", []string{
"raw.githubusercontent.com",
})
viper.SetDefault("security.url_allowlist.crs_hosts", []string{})
viper.SetDefault("security.url_allowlist.allow_private_hosts", false)
viper.SetDefault("security.url_allowlist.allow_insecure_http", false)
viper.SetDefault("security.response_headers.enabled", false)
viper.SetDefault("security.response_headers.additional_allowed", []string{})
viper.SetDefault("security.response_headers.force_remove", []string{})
viper.SetDefault("security.csp.enabled", true)
viper.SetDefault("security.csp.policy", DefaultCSPPolicy)
viper.SetDefault("security.proxy_probe.insecure_skip_verify", false)
// Billing
viper.SetDefault("billing.circuit_breaker.enabled", true)
viper.SetDefault("billing.circuit_breaker.failure_threshold", 5)
viper.SetDefault("billing.circuit_breaker.reset_timeout_seconds", 30)
viper.SetDefault("billing.circuit_breaker.half_open_requests", 3)
// Turnstile
viper.SetDefault("turnstile.required", false)
// Database
viper.SetDefault("database.host", "localhost")
......@@ -329,7 +490,7 @@ func setDefaults() {
viper.SetDefault("redis.min_idle_conns", 10)
// JWT
viper.SetDefault("jwt.secret", "change-me-in-production")
viper.SetDefault("jwt.secret", "")
viper.SetDefault("jwt.expire_hour", 24)
// Default
......@@ -357,7 +518,7 @@ func setDefaults() {
viper.SetDefault("timezone", "Asia/Shanghai")
// Gateway
viper.SetDefault("gateway.response_header_timeout", 300) // 300秒(5分钟)等待上游响应头,LLM高负载时可能排队较久
viper.SetDefault("gateway.response_header_timeout", 600) // 600秒(10分钟)等待上游响应头,LLM高负载时可能排队较久
viper.SetDefault("gateway.log_upstream_error_body", false)
viper.SetDefault("gateway.log_upstream_error_body_max_bytes", 2048)
viper.SetDefault("gateway.inject_beta_for_apikey", false)
......@@ -365,19 +526,23 @@ func setDefaults() {
viper.SetDefault("gateway.max_body_size", int64(100*1024*1024))
viper.SetDefault("gateway.connection_pool_isolation", ConnectionPoolIsolationAccountProxy)
// HTTP 上游连接池配置(针对 5000+ 并发用户优化)
viper.SetDefault("gateway.max_idle_conns", 240) // 最大空闲连接总数(HTTP/2 场景默认)
viper.SetDefault("gateway.max_idle_conns_per_host", 120) // 每主机最大空闲连接(HTTP/2 场景默认)
viper.SetDefault("gateway.max_conns_per_host", 240) // 每主机最大连接数(含活跃,HTTP/2 场景默认)
viper.SetDefault("gateway.idle_conn_timeout_seconds", 300) // 空闲连接超时(秒)
viper.SetDefault("gateway.max_idle_conns", 240) // 最大空闲连接总数(HTTP/2 场景默认)
viper.SetDefault("gateway.max_idle_conns_per_host", 120) // 每主机最大空闲连接(HTTP/2 场景默认)
viper.SetDefault("gateway.max_conns_per_host", 240) // 每主机最大连接数(含活跃,HTTP/2 场景默认)
viper.SetDefault("gateway.idle_conn_timeout_seconds", 90) // 空闲连接超时(秒)
viper.SetDefault("gateway.max_upstream_clients", 5000)
viper.SetDefault("gateway.client_idle_ttl_seconds", 900)
viper.SetDefault("gateway.concurrency_slot_ttl_minutes", 15) // 并发槽位过期时间(支持超长请求)
viper.SetDefault("gateway.concurrency_slot_ttl_minutes", 30) // 并发槽位过期时间(支持超长请求)
viper.SetDefault("gateway.stream_data_interval_timeout", 180)
viper.SetDefault("gateway.stream_keepalive_interval", 10)
viper.SetDefault("gateway.max_line_size", 10*1024*1024)
viper.SetDefault("gateway.scheduling.sticky_session_max_waiting", 3)
viper.SetDefault("gateway.scheduling.sticky_session_wait_timeout", 45*time.Second)
viper.SetDefault("gateway.scheduling.fallback_wait_timeout", 30*time.Second)
viper.SetDefault("gateway.scheduling.fallback_max_waiting", 100)
viper.SetDefault("gateway.scheduling.load_batch_enabled", true)
viper.SetDefault("gateway.scheduling.slot_cleanup_interval", 30*time.Second)
viper.SetDefault("concurrency.ping_interval", 10)
// TokenRefresh
viper.SetDefault("token_refresh.enabled", true)
......@@ -396,11 +561,28 @@ func setDefaults() {
}
func (c *Config) Validate() error {
if c.JWT.Secret == "" {
return fmt.Errorf("jwt.secret is required")
if c.JWT.ExpireHour <= 0 {
return fmt.Errorf("jwt.expire_hour must be positive")
}
if c.JWT.ExpireHour > 168 {
return fmt.Errorf("jwt.expire_hour must be <= 168 (7 days)")
}
if c.JWT.Secret == "change-me-in-production" && c.Server.Mode == "release" {
return fmt.Errorf("jwt.secret must be changed in production")
if c.JWT.ExpireHour > 24 {
log.Printf("Warning: jwt.expire_hour is %d hours (> 24). Consider shorter expiration for security.", c.JWT.ExpireHour)
}
if c.Security.CSP.Enabled && strings.TrimSpace(c.Security.CSP.Policy) == "" {
return fmt.Errorf("security.csp.policy is required when CSP is enabled")
}
if c.Billing.CircuitBreaker.Enabled {
if c.Billing.CircuitBreaker.FailureThreshold <= 0 {
return fmt.Errorf("billing.circuit_breaker.failure_threshold must be positive")
}
if c.Billing.CircuitBreaker.ResetTimeoutSeconds <= 0 {
return fmt.Errorf("billing.circuit_breaker.reset_timeout_seconds must be positive")
}
if c.Billing.CircuitBreaker.HalfOpenRequests <= 0 {
return fmt.Errorf("billing.circuit_breaker.half_open_requests must be positive")
}
}
if c.Database.MaxOpenConns <= 0 {
return fmt.Errorf("database.max_open_conns must be positive")
......@@ -458,6 +640,9 @@ func (c *Config) Validate() error {
if c.Gateway.IdleConnTimeoutSeconds <= 0 {
return fmt.Errorf("gateway.idle_conn_timeout_seconds must be positive")
}
if c.Gateway.IdleConnTimeoutSeconds > 180 {
log.Printf("Warning: gateway.idle_conn_timeout_seconds is %d (> 180). Consider 60-120 seconds for better connection reuse.", c.Gateway.IdleConnTimeoutSeconds)
}
if c.Gateway.MaxUpstreamClients <= 0 {
return fmt.Errorf("gateway.max_upstream_clients must be positive")
}
......@@ -467,6 +652,26 @@ func (c *Config) Validate() error {
if c.Gateway.ConcurrencySlotTTLMinutes <= 0 {
return fmt.Errorf("gateway.concurrency_slot_ttl_minutes must be positive")
}
if c.Gateway.StreamDataIntervalTimeout < 0 {
return fmt.Errorf("gateway.stream_data_interval_timeout must be non-negative")
}
if c.Gateway.StreamDataIntervalTimeout != 0 &&
(c.Gateway.StreamDataIntervalTimeout < 30 || c.Gateway.StreamDataIntervalTimeout > 300) {
return fmt.Errorf("gateway.stream_data_interval_timeout must be 0 or between 30-300 seconds")
}
if c.Gateway.StreamKeepaliveInterval < 0 {
return fmt.Errorf("gateway.stream_keepalive_interval must be non-negative")
}
if c.Gateway.StreamKeepaliveInterval != 0 &&
(c.Gateway.StreamKeepaliveInterval < 5 || c.Gateway.StreamKeepaliveInterval > 30) {
return fmt.Errorf("gateway.stream_keepalive_interval must be 0 or between 5-30 seconds")
}
if c.Gateway.MaxLineSize < 0 {
return fmt.Errorf("gateway.max_line_size must be non-negative")
}
if c.Gateway.MaxLineSize != 0 && c.Gateway.MaxLineSize < 1024*1024 {
return fmt.Errorf("gateway.max_line_size must be at least 1MB")
}
if c.Gateway.Scheduling.StickySessionMaxWaiting <= 0 {
return fmt.Errorf("gateway.scheduling.sticky_session_max_waiting must be positive")
}
......@@ -482,9 +687,57 @@ func (c *Config) Validate() error {
if c.Gateway.Scheduling.SlotCleanupInterval < 0 {
return fmt.Errorf("gateway.scheduling.slot_cleanup_interval must be non-negative")
}
if c.Concurrency.PingInterval < 5 || c.Concurrency.PingInterval > 30 {
return fmt.Errorf("concurrency.ping_interval must be between 5-30 seconds")
}
return nil
}
func normalizeStringSlice(values []string) []string {
if len(values) == 0 {
return values
}
normalized := make([]string, 0, len(values))
for _, v := range values {
trimmed := strings.TrimSpace(v)
if trimmed == "" {
continue
}
normalized = append(normalized, trimmed)
}
return normalized
}
func isWeakJWTSecret(secret string) bool {
lower := strings.ToLower(strings.TrimSpace(secret))
if lower == "" {
return true
}
weak := map[string]struct{}{
"change-me-in-production": {},
"changeme": {},
"secret": {},
"password": {},
"123456": {},
"12345678": {},
"admin": {},
"jwt-secret": {},
}
_, exists := weak[lower]
return exists
}
func generateJWTSecret(byteLength int) (string, error) {
if byteLength <= 0 {
byteLength = 32
}
buf := make([]byte, byteLength)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
// GetServerAddress returns the server address (host:port) from config file or environment variable.
// This is a lightweight function that can be used before full config validation,
// such as during setup wizard startup.
......
......@@ -68,3 +68,22 @@ func TestLoadSchedulingConfigFromEnv(t *testing.T) {
t.Fatalf("StickySessionMaxWaiting = %d, want 5", cfg.Gateway.Scheduling.StickySessionMaxWaiting)
}
}
func TestLoadDefaultSecurityToggles(t *testing.T) {
viper.Reset()
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Security.URLAllowlist.Enabled {
t.Fatalf("URLAllowlist.Enabled = true, want false")
}
if cfg.Security.URLAllowlist.AllowInsecureHTTP {
t.Fatalf("URLAllowlist.AllowInsecureHTTP = true, want false")
}
if cfg.Security.ResponseHeaders.Enabled {
t.Fatalf("ResponseHeaders.Enabled = true, want false")
}
}
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment