diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index f08e0dea..7210b258 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -9,6 +9,7 @@ import ( "net/url" "os" "strings" + "sync/atomic" "time" "github.com/spf13/viper" @@ -573,11 +574,13 @@ type CORSConfig struct { } type SecurityConfig struct { - URLAllowlist URLAllowlistConfig `mapstructure:"url_allowlist"` - ResponseHeaders ResponseHeaderConfig `mapstructure:"response_headers"` - CSP CSPConfig `mapstructure:"csp"` - ProxyFallback ProxyFallbackConfig `mapstructure:"proxy_fallback"` - ProxyProbe ProxyProbeConfig `mapstructure:"proxy_probe"` + URLAllowlist URLAllowlistConfig `mapstructure:"url_allowlist"` + ResponseHeaders ResponseHeaderConfig `mapstructure:"response_headers"` + CSP CSPConfig `mapstructure:"csp"` + ProxyFallback ProxyFallbackConfig `mapstructure:"proxy_fallback"` + ProxyProbe ProxyProbeConfig `mapstructure:"proxy_probe"` + TrustForwardedIPForAPIKeyACL bool `mapstructure:"trust_forwarded_ip_for_api_key_acl"` + TrustForwardedIPForAPIKeyACLLive atomic.Bool `mapstructure:"-"` } type URLAllowlistConfig struct { @@ -1363,6 +1366,7 @@ func load(allowMissingJWTSecret bool) (*Config, error) { 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) + cfg.Security.TrustForwardedIPForAPIKeyACLLive.Store(cfg.Security.TrustForwardedIPForAPIKeyACL) cfg.Log.Level = strings.ToLower(strings.TrimSpace(cfg.Log.Level)) cfg.Log.Format = strings.ToLower(strings.TrimSpace(cfg.Log.Format)) cfg.Log.ServiceName = strings.TrimSpace(cfg.Log.ServiceName) @@ -1507,6 +1511,7 @@ func setDefaults() { viper.SetDefault("security.csp.enabled", true) viper.SetDefault("security.csp.policy", DefaultCSPPolicy) viper.SetDefault("security.proxy_probe.insecure_skip_verify", false) + viper.SetDefault("security.trust_forwarded_ip_for_api_key_acl", false) // Security - disable direct fallback on proxy error viper.SetDefault("security.proxy_fallback.allow_direct_on_error", false) diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 14f5dce0..4f192ba9 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -142,6 +142,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { TurnstileEnabled: settings.TurnstileEnabled, TurnstileSiteKey: settings.TurnstileSiteKey, TurnstileSecretKeyConfigured: settings.TurnstileSecretKeyConfigured, + APIKeyACLTrustForwardedIP: settings.APIKeyACLTrustForwardedIP, LinuxDoConnectEnabled: settings.LinuxDoConnectEnabled, LinuxDoConnectClientID: settings.LinuxDoConnectClientID, LinuxDoConnectClientSecretConfigured: settings.LinuxDoConnectClientSecretConfigured, @@ -399,6 +400,9 @@ type UpdateSettingsRequest struct { TurnstileSiteKey string `json:"turnstile_site_key"` TurnstileSecretKey string `json:"turnstile_secret_key"` + // API Key IP 访问控制设置 + APIKeyACLTrustForwardedIP *bool `json:"api_key_acl_trust_forwarded_ip"` + // LinuxDo Connect OAuth 登录 LinuxDoConnectEnabled bool `json:"linuxdo_connect_enabled"` LinuxDoConnectClientID string `json:"linuxdo_connect_client_id"` @@ -1432,28 +1436,34 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } settings := &service.SystemSettings{ - RegistrationEnabled: req.RegistrationEnabled, - EmailVerifyEnabled: req.EmailVerifyEnabled, - RegistrationEmailSuffixWhitelist: req.RegistrationEmailSuffixWhitelist, - PromoCodeEnabled: req.PromoCodeEnabled, - PasswordResetEnabled: req.PasswordResetEnabled, - FrontendURL: req.FrontendURL, - InvitationCodeEnabled: req.InvitationCodeEnabled, - TotpEnabled: req.TotpEnabled, - LoginAgreementEnabled: req.LoginAgreementEnabled, - LoginAgreementMode: loginAgreementMode, - LoginAgreementUpdatedAt: loginAgreementUpdatedAt, - LoginAgreementDocuments: loginAgreementDocuments, - SMTPHost: req.SMTPHost, - SMTPPort: req.SMTPPort, - SMTPUsername: req.SMTPUsername, - SMTPPassword: req.SMTPPassword, - SMTPFrom: req.SMTPFrom, - SMTPFromName: req.SMTPFromName, - SMTPUseTLS: req.SMTPUseTLS, - TurnstileEnabled: req.TurnstileEnabled, - TurnstileSiteKey: req.TurnstileSiteKey, - TurnstileSecretKey: req.TurnstileSecretKey, + RegistrationEnabled: req.RegistrationEnabled, + EmailVerifyEnabled: req.EmailVerifyEnabled, + RegistrationEmailSuffixWhitelist: req.RegistrationEmailSuffixWhitelist, + PromoCodeEnabled: req.PromoCodeEnabled, + PasswordResetEnabled: req.PasswordResetEnabled, + FrontendURL: req.FrontendURL, + InvitationCodeEnabled: req.InvitationCodeEnabled, + TotpEnabled: req.TotpEnabled, + LoginAgreementEnabled: req.LoginAgreementEnabled, + LoginAgreementMode: loginAgreementMode, + LoginAgreementUpdatedAt: loginAgreementUpdatedAt, + LoginAgreementDocuments: loginAgreementDocuments, + SMTPHost: req.SMTPHost, + SMTPPort: req.SMTPPort, + SMTPUsername: req.SMTPUsername, + SMTPPassword: req.SMTPPassword, + SMTPFrom: req.SMTPFrom, + SMTPFromName: req.SMTPFromName, + SMTPUseTLS: req.SMTPUseTLS, + TurnstileEnabled: req.TurnstileEnabled, + TurnstileSiteKey: req.TurnstileSiteKey, + TurnstileSecretKey: req.TurnstileSecretKey, + APIKeyACLTrustForwardedIP: func() bool { + if req.APIKeyACLTrustForwardedIP != nil { + return *req.APIKeyACLTrustForwardedIP + } + return previousSettings.APIKeyACLTrustForwardedIP + }(), LinuxDoConnectEnabled: req.LinuxDoConnectEnabled, LinuxDoConnectClientID: req.LinuxDoConnectClientID, LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret, @@ -1869,6 +1879,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { TurnstileEnabled: updatedSettings.TurnstileEnabled, TurnstileSiteKey: updatedSettings.TurnstileSiteKey, TurnstileSecretKeyConfigured: updatedSettings.TurnstileSecretKeyConfigured, + APIKeyACLTrustForwardedIP: updatedSettings.APIKeyACLTrustForwardedIP, LinuxDoConnectEnabled: updatedSettings.LinuxDoConnectEnabled, LinuxDoConnectClientID: updatedSettings.LinuxDoConnectClientID, LinuxDoConnectClientSecretConfigured: updatedSettings.LinuxDoConnectClientSecretConfigured, @@ -2145,6 +2156,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if req.TurnstileSecretKey != "" { changed = append(changed, "turnstile_secret_key") } + if before.APIKeyACLTrustForwardedIP != after.APIKeyACLTrustForwardedIP { + changed = append(changed, "api_key_acl_trust_forwarded_ip") + } if before.LinuxDoConnectEnabled != after.LinuxDoConnectEnabled { changed = append(changed, "linuxdo_connect_enabled") } diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index bdad5572..e21b8c38 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -50,6 +50,7 @@ type SystemSettings struct { TurnstileEnabled bool `json:"turnstile_enabled"` TurnstileSiteKey string `json:"turnstile_site_key"` TurnstileSecretKeyConfigured bool `json:"turnstile_secret_key_configured"` + APIKeyACLTrustForwardedIP bool `json:"api_key_acl_trust_forwarded_ip"` LinuxDoConnectEnabled bool `json:"linuxdo_connect_enabled"` LinuxDoConnectClientID string `json:"linuxdo_connect_client_id"` diff --git a/backend/internal/server/middleware/api_key_auth.go b/backend/internal/server/middleware/api_key_auth.go index ee439cda..f6ec53ab 100644 --- a/backend/internal/server/middleware/api_key_auth.go +++ b/backend/internal/server/middleware/api_key_auth.go @@ -90,6 +90,9 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti // 注意:错误信息故意模糊,避免暴露具体的 IP 限制机制 if len(apiKey.IPWhitelist) > 0 || len(apiKey.IPBlacklist) > 0 { clientIP := ip.GetTrustedClientIP(c) + if cfg != nil && cfg.Security.TrustForwardedIPForAPIKeyACLLive.Load() { + clientIP = ip.GetClientIP(c) + } allowed, _ := ip.CheckIPRestrictionWithCompiledRules(clientIP, apiKey.CompiledIPWhitelist, apiKey.CompiledIPBlacklist) if !allowed { service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonIPRestriction) diff --git a/backend/internal/server/middleware/api_key_auth_test.go b/backend/internal/server/middleware/api_key_auth_test.go index a00f70c7..19d1919b 100644 --- a/backend/internal/server/middleware/api_key_auth_test.go +++ b/backend/internal/server/middleware/api_key_auth_test.go @@ -398,7 +398,7 @@ func TestAPIKeyAuthRejectsUnavailableGroup(t *testing.T) { } } -func TestAPIKeyAuthIPRestrictionDoesNotTrustSpoofedForwardHeaders(t *testing.T) { +func TestAPIKeyAuthIPRestrictionDoesNotTrustForwardedClientIPByDefault(t *testing.T) { gin.SetMode(gin.TestMode) user := &service.User{ @@ -460,6 +460,58 @@ func TestAPIKeyAuthIPRestrictionDoesNotTrustSpoofedForwardHeaders(t *testing.T) require.Equal(t, service.OpsClientBusinessLimitedReasonIPRestriction, businessLimitedReason) } +func TestAPIKeyAuthIPRestrictionCanTrustForwardedClientIPForReverseProxy(t *testing.T) { + gin.SetMode(gin.TestMode) + + user := &service.User{ + ID: 7, + Role: service.RoleUser, + Status: service.StatusActive, + Balance: 10, + Concurrency: 3, + } + apiKey := &service.APIKey{ + ID: 100, + UserID: user.ID, + Key: "test-key", + Status: service.StatusActive, + User: user, + IPWhitelist: []string{"1.2.3.4"}, + } + + apiKeyRepo := &stubApiKeyRepo{ + getByKey: func(ctx context.Context, key string) (*service.APIKey, error) { + if key != apiKey.Key { + return nil, service.ErrAPIKeyNotFound + } + clone := *apiKey + return &clone, nil + }, + } + + cfg := &config.Config{RunMode: config.RunModeSimple} + cfg.Security.TrustForwardedIPForAPIKeyACL = true + cfg.Security.TrustForwardedIPForAPIKeyACLLive.Store(true) + apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg) + router := gin.New() + require.NoError(t, router.SetTrustedProxies(nil)) + router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddleware(apiKeyService, nil, cfg))) + router.GET("/t", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/t", nil) + req.RemoteAddr = "9.9.9.9:12345" + req.Header.Set("x-api-key", apiKey.Key) + req.Header.Set("X-Forwarded-For", "1.2.3.4") + req.Header.Set("X-Real-IP", "1.2.3.4") + req.Header.Set("CF-Connecting-IP", "1.2.3.4") + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) +} + func TestAPIKeyAuthTouchesLastUsedOnSuccess(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/server/middleware/logger.go b/backend/internal/server/middleware/logger.go index b14a3a21..6cec1c1a 100644 --- a/backend/internal/server/middleware/logger.go +++ b/backend/internal/server/middleware/logger.go @@ -4,6 +4,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/gin-gonic/gin" "go.uber.org/zap" @@ -31,7 +32,7 @@ func Logger() gin.HandlerFunc { method := c.Request.Method statusCode := c.Writer.Status() - clientIP := c.ClientIP() + clientIP := ip.GetClientIP(c) protocol := c.Request.Proto accountID, hasAccountID := c.Request.Context().Value(ctxkey.AccountID).(int64) platform, _ := c.Request.Context().Value(ctxkey.Platform).(string) diff --git a/backend/internal/server/middleware/request_access_logger_test.go b/backend/internal/server/middleware/request_access_logger_test.go index fec3ed22..bf3666f2 100644 --- a/backend/internal/server/middleware/request_access_logger_test.go +++ b/backend/internal/server/middleware/request_access_logger_test.go @@ -180,6 +180,37 @@ func TestLogger_AccessLogIncludesCoreFields(t *testing.T) { } } +func TestLogger_AccessLogUsesForwardedClientIP(t *testing.T) { + gin.SetMode(gin.TestMode) + sink := initMiddlewareTestLogger(t) + + r := gin.New() + r.Use(Logger()) + r.GET("/api/test", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/test", nil) + req.RemoteAddr = "104.23.251.120:443" + req.Header.Set("CF-Connecting-IP", "203.0.113.42") + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status=%d", w.Code) + } + + for _, event := range sink.list() { + if event == nil || event.Message != "http request completed" { + continue + } + if got := event.Fields["client_ip"]; got != "203.0.113.42" { + t.Fatalf("client_ip=%q, want real forwarded ip", got) + } + return + } + t.Fatalf("access log event not found") +} + func TestLogger_HealthPathSkipped(t *testing.T) { gin.SetMode(gin.TestMode) sink := initMiddlewareTestLogger(t) diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index e697f459..55f5e509 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -131,6 +131,9 @@ const ( SettingKeyTurnstileSiteKey = "turnstile_site_key" // Turnstile Site Key SettingKeyTurnstileSecretKey = "turnstile_secret_key" // Turnstile Secret Key + // API Key IP 访问控制设置 + SettingKeyAPIKeyACLTrustForwardedIP = "api_key_acl_trust_forwarded_ip" // API Key IP 白/黑名单是否信任转发 IP + // TOTP 双因素认证设置 SettingKeyTotpEnabled = "totp_enabled" // 是否启用 TOTP 2FA 功能 diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index bd99e341..acdb2804 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -597,6 +597,24 @@ func (s *SettingService) SetProxyRepository(repo ProxyRepository) { s.proxyRepo = repo } +func (s *SettingService) LoadAPIKeyACLTrustForwardedIPSetting(ctx context.Context) error { + if s == nil || s.cfg == nil || s.settingRepo == nil { + return nil + } + value, err := s.settingRepo.GetValue(ctx, SettingKeyAPIKeyACLTrustForwardedIP) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + s.cfg.Security.TrustForwardedIPForAPIKeyACLLive.Store(s.cfg.Security.TrustForwardedIPForAPIKeyACL) + return nil + } + return fmt.Errorf("get api key acl forwarded ip setting: %w", err) + } + enabled := value == "true" + s.cfg.Security.TrustForwardedIPForAPIKeyACL = enabled + s.cfg.Security.TrustForwardedIPForAPIKeyACLLive.Store(enabled) + return nil +} + // GetAllSettings 获取所有系统设置 func (s *SettingService) GetAllSettings(ctx context.Context) (*SystemSettings, error) { settings, err := s.settingRepo.GetAll(ctx) @@ -633,6 +651,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyLoginAgreementDocuments, SettingKeyTurnstileEnabled, SettingKeyTurnstileSiteKey, + SettingKeyAPIKeyACLTrustForwardedIP, SettingKeySiteName, SettingKeySiteLogo, SettingKeySiteSubtitle, @@ -1568,6 +1587,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting if settings.TurnstileSecretKey != "" { updates[SettingKeyTurnstileSecretKey] = settings.TurnstileSecretKey } + updates[SettingKeyAPIKeyACLTrustForwardedIP] = strconv.FormatBool(settings.APIKeyACLTrustForwardedIP) // LinuxDo Connect OAuth 登录 updates[SettingKeyLinuxDoConnectEnabled] = strconv.FormatBool(settings.LinuxDoConnectEnabled) @@ -1867,6 +1887,10 @@ func (s *SettingService) refreshCachedSettings(settings *SystemSettings) { enabled: settings.OpenAIAdvancedSchedulerEnabled, expiresAt: time.Now().Add(openAIAdvancedSchedulerSettingCacheTTL).UnixNano(), }) + if s.cfg != nil { + s.cfg.Security.TrustForwardedIPForAPIKeyACL = settings.APIKeyACLTrustForwardedIP + s.cfg.Security.TrustForwardedIPForAPIKeyACLLive.Store(settings.APIKeyACLTrustForwardedIP) + } if s.onUpdate != nil { s.onUpdate() // Invalidate cache after settings update } @@ -2463,6 +2487,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyLoginAgreementMode: defaultLoginAgreementMode, SettingKeyLoginAgreementUpdatedAt: defaultLoginAgreementDate, SettingKeyLoginAgreementDocuments: loginAgreementDocumentsJSON, + SettingKeyAPIKeyACLTrustForwardedIP: "false", SettingKeySiteName: "Sub2API", SettingKeySiteLogo: "", SettingKeyPurchaseSubscriptionEnabled: "false", @@ -2622,6 +2647,12 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin if loginAgreementUpdatedAt == "" { loginAgreementUpdatedAt = defaultLoginAgreementDate } + apiKeyACLTrustForwardedIP := false + if value, ok := settings[SettingKeyAPIKeyACLTrustForwardedIP]; ok { + apiKeyACLTrustForwardedIP = value == "true" + } else if s != nil && s.cfg != nil { + apiKeyACLTrustForwardedIP = s.cfg.Security.TrustForwardedIPForAPIKeyACL + } result := &SystemSettings{ RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", EmailVerifyEnabled: emailVerifyEnabled, @@ -2644,6 +2675,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], TurnstileSecretKeyConfigured: settings[SettingKeyTurnstileSecretKey] != "", + APIKeyACLTrustForwardedIP: apiKeyACLTrustForwardedIP, SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), SiteLogo: settings[SettingKeySiteLogo], SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"), diff --git a/backend/internal/service/setting_service_update_test.go b/backend/internal/service/setting_service_update_test.go index d6b6b6cd..863edb7e 100644 --- a/backend/internal/service/setting_service_update_test.go +++ b/backend/internal/service/setting_service_update_test.go @@ -290,6 +290,30 @@ func TestSettingService_UpdateSettings_AntigravityUserAgentVersion(t *testing.T) require.Equal(t, "1.23.2", repo.updates[SettingKeyAntigravityUserAgentVersion]) } +func TestSettingService_UpdateSettings_APIKeyACLTrustForwardedIPRefreshesConfig(t *testing.T) { + repo := &settingUpdateRepoStub{} + cfg := &config.Config{} + svc := NewSettingService(repo, cfg) + + err := svc.UpdateSettings(context.Background(), &SystemSettings{ + APIKeyACLTrustForwardedIP: true, + }) + require.NoError(t, err) + require.Equal(t, "true", repo.updates[SettingKeyAPIKeyACLTrustForwardedIP]) + require.True(t, cfg.Security.TrustForwardedIPForAPIKeyACL) + require.True(t, cfg.Security.TrustForwardedIPForAPIKeyACLLive.Load()) +} + +func TestSettingService_ParseSettings_APIKeyACLTrustForwardedIPFallsBackToConfigWhenMissing(t *testing.T) { + cfg := &config.Config{} + cfg.Security.TrustForwardedIPForAPIKeyACL = true + svc := NewSettingService(&settingUpdateRepoStub{}, cfg) + + got := svc.parseSettings(map[string]string{}) + + require.True(t, got.APIKeyACLTrustForwardedIP) +} + func TestSettingService_GetAntigravityUserAgentVersion_Precedence(t *testing.T) { t.Run("后台设置优先", func(t *testing.T) { svc := NewSettingService(&settingAntigravityUARepoStub{values: map[string]string{ diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 1e5e8b1c..9314862e 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -38,6 +38,7 @@ type SystemSettings struct { TurnstileSiteKey string TurnstileSecretKey string TurnstileSecretKeyConfigured bool + APIKeyACLTrustForwardedIP bool // LinuxDo Connect OAuth 登录 LinuxDoConnectEnabled bool diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 2bd5812b..e28c2c46 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -397,6 +397,9 @@ func ProvideSettingService(settingRepo SettingRepository, groupRepo GroupReposit svc := NewSettingService(settingRepo, cfg) svc.SetDefaultSubscriptionGroupReader(groupRepo) svc.SetProxyRepository(proxyRepo) + if err := svc.LoadAPIKeyACLTrustForwardedIPSetting(context.Background()); err != nil { + logger.LegacyPrintf("service.setting", "Warning: load api key acl forwarded ip setting failed: %v", err) + } antigravity.SetUserAgentVersionResolver(svc.GetAntigravityUserAgentVersion) return svc } diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 882ab83b..148ce844 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -396,6 +396,7 @@ export interface SystemSettings { turnstile_enabled: boolean; turnstile_site_key: string; turnstile_secret_key_configured: boolean; + api_key_acl_trust_forwarded_ip: boolean; // LinuxDo Connect OAuth settings linuxdo_connect_enabled: boolean; @@ -637,6 +638,7 @@ export interface UpdateSettingsRequest { turnstile_enabled?: boolean; turnstile_site_key?: string; turnstile_secret_key?: string; + api_key_acl_trust_forwarded_ip?: boolean; linuxdo_connect_enabled?: boolean; linuxdo_connect_client_id?: string; linuxdo_connect_client_secret?: string; diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index f10ac21e..13a52c14 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -5307,7 +5307,15 @@ export default { siteKeyHint: 'Get this from your Cloudflare Dashboard', cloudflareDashboard: 'Cloudflare Dashboard', secretKeyHint: 'Server-side verification key (keep this secret)', - secretKeyConfiguredHint: 'Secret key configured. Leave empty to keep the current value.' }, + secretKeyConfiguredHint: 'Secret key configured. Leave empty to keep the current value.' + }, + apiKeyAcl: { + title: 'API Key IP Access Control', + description: 'Choose which client IP is used by API Key allowlists and denylists', + trustForwardedIp: 'Trust forwarded client IP', + trustForwardedIpHint: + 'Disabled by default. Enable only when the origin is reachable only through Cloudflare or Nginx reverse proxy. When enabled, API Key IP allowlists and denylists use CF-Connecting-IP, X-Real-IP, or X-Forwarded-For, matching the request IP shown in usage records.' + }, linuxdo: { title: 'LinuxDo Connect Login', description: 'Configure LinuxDo Connect OAuth for Sub2API end-user login', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 286fc28d..251f6c04 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -5472,6 +5472,13 @@ export default { secretKeyHint: '服务端验证密钥(请保密)', secretKeyConfiguredHint: '密钥已配置,留空以保留当前值。' }, + apiKeyAcl: { + title: 'API Key IP 访问控制', + description: '控制 API Key 白名单和黑名单使用哪个客户端 IP 判断', + trustForwardedIp: '信任反代传递的客户端 IP', + trustForwardedIpHint: + '默认关闭。仅在源站只允许 Cloudflare 或 Nginx 反代访问时开启;开启后 API Key IP 白/黑名单会使用 CF-Connecting-IP、X-Real-IP 或 X-Forwarded-For,与使用记录中的请求 IP 保持一致。' + }, linuxdo: { title: 'LinuxDo Connect 登录', description: '配置 LinuxDo Connect OAuth,用于 Sub2API 用户登录', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index ac3c3cf3..a66c1fda 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1564,6 +1564,33 @@ + +
+
+

+ {{ t("admin.settings.apiKeyAcl.title") }} +

+

+ {{ t("admin.settings.apiKeyAcl.description") }} +

+
+
+
+
+ +

+ {{ t("admin.settings.apiKeyAcl.trustForwardedIpHint") }} +

+
+ +
+
+
+
({ turnstile_site_key: "", turnstile_secret_key: "", turnstile_secret_key_configured: false, + api_key_acl_trust_forwarded_ip: false, // LinuxDo Connect OAuth 登录 linuxdo_connect_enabled: false, linuxdo_connect_client_id: "", @@ -7972,6 +8000,7 @@ async function saveSettings() { turnstile_enabled: form.turnstile_enabled, turnstile_site_key: form.turnstile_site_key, turnstile_secret_key: form.turnstile_secret_key || undefined, + api_key_acl_trust_forwarded_ip: form.api_key_acl_trust_forwarded_ip, linuxdo_connect_enabled: form.linuxdo_connect_enabled, linuxdo_connect_client_id: form.linuxdo_connect_client_id, linuxdo_connect_client_secret: