mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-27 16:07:02 +00:00
用户可高频刷面板接口(usage/dashboard 等重聚合查询)直接打爆数据库:
现有限流器只覆盖登录/注册等公开认证入口,登录后的全部面板端点无任何限流。
三层防护(阈值均可在后台可视化配置,panel_rate_limit_settings):
1. 认证面板接口按「用户 ID」限流,与来源 IP 无关——反向代理/NAT 共享出口
(所有请求源地址坍缩为 127.0.0.1 等)不会互相误伤:
- Global 档(默认 240 rpm/账号):user/auth/payment/admin 全部登录后路由
- Heavy 档(默认 60 rpm/账号):/usage、/usage/dashboard/*、
/user/api-keys/:id/usage/daily 等重 SQL 聚合端点叠加计数
- 管理员默认豁免(可关闭)
2. 无认证公开接口(/api/v1/settings/*,每次请求都查 DB)按安全客户端 IP
限流(默认 300 rpm/IP);回环/私网/链路本地地址(反代内部转发地址)
一律跳过计数,杜绝把整条反代链路合并进同一个桶造成大面积误拦截。
3. 修复既有隐患:auth 入口限流的 IP 取值从 c.ClientIP() 切换到与审计日志/
会话绑定/API Key ACL 同源的安全客户端 IP 解析(尊重后台「信任反代转发
IP」开关快照)。原实现下默认反代部署(未配置 server.trusted_proxies)
所有用户共享同一个登录限流桶,既会全员误拦也可被单人恶意占满形成登录
DoS;开关关闭时行为与原来完全一致。
工程约束:
- 配置热路径走进程内缓存(atomic.Value + singleflight,60s TTL),
限流中间件零 DB 访问;保存后当前节点立即生效
- 面板限流 Redis 故障 fail-open(auth 入口保持原有 fail-close)
- 429 响应携带 Retry-After;错误码 RATE_LIMITED
- 支付 webhook / 公开支付回调有意不挂限流
- 新增 GET/PUT /api/v1/admin/settings/panel-rate-limit;设置页安全 tab
新增「面板接口限流」卡片(zh/en i18n 全量)
测试:rate_limiter/panel_rate_limit/setting_panel_rate_limit 单测全绿;
routes、handler/admin、-tags unit 契约测试通过;前端 vue-tsc/ESLint/
SettingsView spec(26/26,含新增交互用例)/i18n 守卫全部通过。
315 lines
13 KiB
Go
315 lines
13 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sync/atomic"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
|
"golang.org/x/sync/singleflight"
|
|
)
|
|
|
|
var (
|
|
ErrRegistrationDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled")
|
|
ErrSettingNotFound = infraerrors.NotFound("SETTING_NOT_FOUND", "setting not found")
|
|
ErrDefaultSubGroupInvalid = infraerrors.BadRequest(
|
|
"DEFAULT_SUBSCRIPTION_GROUP_INVALID",
|
|
"default subscription group must exist and be subscription type",
|
|
)
|
|
ErrDefaultSubGroupDuplicate = infraerrors.BadRequest(
|
|
"DEFAULT_SUBSCRIPTION_GROUP_DUPLICATE",
|
|
"default subscription group cannot be duplicated",
|
|
)
|
|
)
|
|
|
|
type SettingRepository interface {
|
|
Get(ctx context.Context, key string) (*Setting, error)
|
|
GetValue(ctx context.Context, key string) (string, error)
|
|
Set(ctx context.Context, key, value string) error
|
|
GetMultiple(ctx context.Context, keys []string) (map[string]string, error)
|
|
SetMultiple(ctx context.Context, settings map[string]string) error
|
|
GetAll(ctx context.Context) (map[string]string, error)
|
|
Delete(ctx context.Context, key string) error
|
|
}
|
|
|
|
// DefaultSubscriptionGroupReader validates group references used by default subscriptions.
|
|
type DefaultSubscriptionGroupReader interface {
|
|
GetByID(ctx context.Context, id int64) (*Group, error)
|
|
}
|
|
|
|
// WebSearchManagerBuilder creates a websearch.Manager from config (injected by infra layer).
|
|
// proxyURLs maps proxy ID to resolved URL for provider-level proxy support.
|
|
type WebSearchManagerBuilder func(cfg *WebSearchEmulationConfig, proxyURLs map[int64]string)
|
|
|
|
// SettingService 系统设置服务
|
|
type SettingService struct {
|
|
settingRepo SettingRepository
|
|
defaultSubGroupReader DefaultSubscriptionGroupReader
|
|
proxyRepo ProxyRepository // for resolving websearch provider proxy URLs
|
|
cfg *config.Config
|
|
onUpdate func() // Callback when settings are updated (for cache invalidation)
|
|
version string // Application version
|
|
webSearchManagerBuilder WebSearchManagerBuilder
|
|
antigravityUAVersionCache atomic.Value // *cachedAntigravityUserAgentVersion
|
|
antigravityUAVersionSF singleflight.Group
|
|
openAICodexUACache atomic.Value // *cachedOpenAICodexUserAgent
|
|
openAICodexUASF singleflight.Group
|
|
codexRestrictionPolicyCache atomic.Value // *cachedCodexRestrictionPolicy
|
|
codexRestrictionPolicySF singleflight.Group
|
|
|
|
cyberSessionBlockRuntimeCache atomic.Value // *cachedCyberSessionBlockRuntime
|
|
cyberSessionBlockRuntimeSF singleflight.Group
|
|
|
|
// panelRateLimitCache 面板 API 限流配置进程内缓存(*cachedPanelRateLimitSettings)。
|
|
// 面板每个认证请求都会读取,禁止在热路径上直接访问 DB。
|
|
panelRateLimitCache atomic.Value
|
|
panelRateLimitSF singleflight.Group
|
|
|
|
// openAIQuotaAutoPauseSettingsCache holds the most recently observed quota auto-pause
|
|
// settings. GetOpenAIQuotaAutoPauseSettings reads this atomic.Value on the request hot
|
|
// path without ever blocking on the DB; when the cached entry expires, a background
|
|
// goroutine refreshes it via openAIQuotaAutoPauseSettingsSF (stale-while-revalidate).
|
|
// This per-service field also gives tests natural isolation — each SettingService
|
|
// instance owns its own cache, no shared package-level state.
|
|
openAIQuotaAutoPauseSettingsCache atomic.Value // *cachedOpenAIQuotaAutoPauseSettings
|
|
openAIQuotaAutoPauseSettingsSF singleflight.Group
|
|
}
|
|
|
|
// DefaultPlatformQuotaSetting 单 platform 三档限额(nil = 沿用上层;0 = 显式禁用;>0 = 上限)
|
|
type DefaultPlatformQuotaSetting struct {
|
|
DailyLimitUSD *float64 `json:"daily"`
|
|
WeeklyLimitUSD *float64 `json:"weekly"`
|
|
MonthlyLimitUSD *float64 `json:"monthly"`
|
|
}
|
|
|
|
type ProviderDefaultGrantSettings struct {
|
|
Balance float64
|
|
Concurrency int
|
|
Subscriptions []DefaultSubscriptionSetting
|
|
GrantOnSignup bool
|
|
GrantOnFirstBind bool
|
|
PlatformQuotas map[string]*DefaultPlatformQuotaSetting // key = platform name
|
|
}
|
|
|
|
type AuthSourceDefaultSettings struct {
|
|
Email ProviderDefaultGrantSettings
|
|
LinuxDo ProviderDefaultGrantSettings
|
|
OIDC ProviderDefaultGrantSettings
|
|
WeChat ProviderDefaultGrantSettings
|
|
GitHub ProviderDefaultGrantSettings
|
|
Google ProviderDefaultGrantSettings
|
|
DingTalk ProviderDefaultGrantSettings
|
|
ForceEmailOnThirdPartySignup bool
|
|
}
|
|
|
|
type authSourceDefaultKeySet struct {
|
|
// source 是 auth source 标识(如 "email"、"github"),仅用于 parse 时
|
|
// slog.Warn 诊断输出,不再参与 key 拼接(platformQuotas 字段已存完整 key)。
|
|
source string
|
|
balance string
|
|
concurrency string
|
|
subscriptions string
|
|
grantOnSignup string
|
|
grantOnFirstBind string
|
|
platformQuotas string // SettingKeyAuthSourcePlatformQuotas(source)
|
|
}
|
|
|
|
var (
|
|
emailAuthSourceDefaultKeys = authSourceDefaultKeySet{
|
|
source: "email",
|
|
balance: SettingKeyAuthSourceDefaultEmailBalance,
|
|
concurrency: SettingKeyAuthSourceDefaultEmailConcurrency,
|
|
subscriptions: SettingKeyAuthSourceDefaultEmailSubscriptions,
|
|
grantOnSignup: SettingKeyAuthSourceDefaultEmailGrantOnSignup,
|
|
grantOnFirstBind: SettingKeyAuthSourceDefaultEmailGrantOnFirstBind,
|
|
platformQuotas: SettingKeyAuthSourcePlatformQuotas("email"),
|
|
}
|
|
linuxDoAuthSourceDefaultKeys = authSourceDefaultKeySet{
|
|
source: "linuxdo",
|
|
balance: SettingKeyAuthSourceDefaultLinuxDoBalance,
|
|
concurrency: SettingKeyAuthSourceDefaultLinuxDoConcurrency,
|
|
subscriptions: SettingKeyAuthSourceDefaultLinuxDoSubscriptions,
|
|
grantOnSignup: SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup,
|
|
grantOnFirstBind: SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind,
|
|
platformQuotas: SettingKeyAuthSourcePlatformQuotas("linuxdo"),
|
|
}
|
|
oidcAuthSourceDefaultKeys = authSourceDefaultKeySet{
|
|
source: "oidc",
|
|
balance: SettingKeyAuthSourceDefaultOIDCBalance,
|
|
concurrency: SettingKeyAuthSourceDefaultOIDCConcurrency,
|
|
subscriptions: SettingKeyAuthSourceDefaultOIDCSubscriptions,
|
|
grantOnSignup: SettingKeyAuthSourceDefaultOIDCGrantOnSignup,
|
|
grantOnFirstBind: SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind,
|
|
platformQuotas: SettingKeyAuthSourcePlatformQuotas("oidc"),
|
|
}
|
|
weChatAuthSourceDefaultKeys = authSourceDefaultKeySet{
|
|
source: "wechat",
|
|
balance: SettingKeyAuthSourceDefaultWeChatBalance,
|
|
concurrency: SettingKeyAuthSourceDefaultWeChatConcurrency,
|
|
subscriptions: SettingKeyAuthSourceDefaultWeChatSubscriptions,
|
|
grantOnSignup: SettingKeyAuthSourceDefaultWeChatGrantOnSignup,
|
|
grantOnFirstBind: SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind,
|
|
platformQuotas: SettingKeyAuthSourcePlatformQuotas("wechat"),
|
|
}
|
|
gitHubAuthSourceDefaultKeys = authSourceDefaultKeySet{
|
|
source: "github",
|
|
balance: SettingKeyAuthSourceDefaultGitHubBalance,
|
|
concurrency: SettingKeyAuthSourceDefaultGitHubConcurrency,
|
|
subscriptions: SettingKeyAuthSourceDefaultGitHubSubscriptions,
|
|
grantOnSignup: SettingKeyAuthSourceDefaultGitHubGrantOnSignup,
|
|
grantOnFirstBind: SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind,
|
|
platformQuotas: SettingKeyAuthSourcePlatformQuotas("github"),
|
|
}
|
|
googleAuthSourceDefaultKeys = authSourceDefaultKeySet{
|
|
source: "google",
|
|
balance: SettingKeyAuthSourceDefaultGoogleBalance,
|
|
concurrency: SettingKeyAuthSourceDefaultGoogleConcurrency,
|
|
subscriptions: SettingKeyAuthSourceDefaultGoogleSubscriptions,
|
|
grantOnSignup: SettingKeyAuthSourceDefaultGoogleGrantOnSignup,
|
|
grantOnFirstBind: SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind,
|
|
platformQuotas: SettingKeyAuthSourcePlatformQuotas("google"),
|
|
}
|
|
dingTalkAuthSourceDefaultKeys = authSourceDefaultKeySet{
|
|
source: "dingtalk",
|
|
balance: SettingKeyAuthSourceDefaultDingTalkBalance,
|
|
concurrency: SettingKeyAuthSourceDefaultDingTalkConcurrency,
|
|
subscriptions: SettingKeyAuthSourceDefaultDingTalkSubscriptions,
|
|
grantOnSignup: SettingKeyAuthSourceDefaultDingTalkGrantOnSignup,
|
|
grantOnFirstBind: SettingKeyAuthSourceDefaultDingTalkGrantOnFirstBind,
|
|
platformQuotas: SettingKeyAuthSourcePlatformQuotas("dingtalk"),
|
|
}
|
|
)
|
|
|
|
const (
|
|
defaultAuthSourceBalance = 0
|
|
defaultAuthSourceConcurrency = 5
|
|
defaultWeChatConnectMode = "open"
|
|
defaultWeChatConnectScopes = "snsapi_login"
|
|
defaultWeChatConnectFrontend = "/auth/wechat/callback"
|
|
defaultGitHubOAuthAuthorize = "https://github.com/login/oauth/authorize"
|
|
defaultGitHubOAuthToken = "https://github.com/login/oauth/access_token"
|
|
defaultGitHubOAuthUserInfo = "https://api.github.com/user"
|
|
defaultGitHubOAuthEmails = "https://api.github.com/user/emails"
|
|
defaultGitHubOAuthScopes = "read:user user:email"
|
|
defaultGitHubOAuthFrontend = "/auth/oauth/callback"
|
|
defaultGoogleOAuthAuthorize = "https://accounts.google.com/o/oauth2/v2/auth"
|
|
defaultGoogleOAuthToken = "https://oauth2.googleapis.com/token"
|
|
defaultGoogleOAuthUserInfo = "https://openidconnect.googleapis.com/v1/userinfo"
|
|
defaultGoogleOAuthScopes = "openid email profile"
|
|
defaultGoogleOAuthFrontend = "/auth/oauth/callback"
|
|
defaultLoginAgreementMode = "modal"
|
|
defaultLoginAgreementDate = "2026-03-31"
|
|
)
|
|
|
|
// NewSettingService 创建系统设置服务实例
|
|
func NewSettingService(settingRepo SettingRepository, cfg *config.Config) *SettingService {
|
|
return &SettingService{
|
|
settingRepo: settingRepo,
|
|
cfg: cfg,
|
|
}
|
|
}
|
|
|
|
// SetDefaultSubscriptionGroupReader injects an optional group reader for default subscription validation.
|
|
func (s *SettingService) SetDefaultSubscriptionGroupReader(reader DefaultSubscriptionGroupReader) {
|
|
s.defaultSubGroupReader = reader
|
|
}
|
|
|
|
// SetProxyRepository injects a proxy repo for resolving websearch provider proxy URLs.
|
|
func (s *SettingService) SetProxyRepository(repo ProxyRepository) {
|
|
s.proxyRepo = repo
|
|
}
|
|
|
|
func (s *SettingService) LoadForwardedClientIPSettings(ctx context.Context) error {
|
|
if s == nil || s.cfg == nil || s.settingRepo == nil {
|
|
return nil
|
|
}
|
|
|
|
values, err := s.settingRepo.GetMultiple(ctx, []string{
|
|
SettingKeyAPIKeyACLTrustForwardedIP,
|
|
SettingKeyForwardedClientIPHeaders,
|
|
settingKeyForwardedClientIPModeV2,
|
|
})
|
|
if err != nil {
|
|
s.cfg.SetForwardedClientIPSettings(false, nil)
|
|
return fmt.Errorf("get forwarded client ip settings: %w", err)
|
|
}
|
|
|
|
enabled := s.cfg.Security.TrustForwardedIPForAPIKeyACL
|
|
headers := s.cfg.ForwardedClientIPSettings().Headers
|
|
storedValue, hasStoredValue := values[SettingKeyAPIKeyACLTrustForwardedIP]
|
|
if hasStoredValue {
|
|
enabled = storedValue == "true"
|
|
}
|
|
|
|
var headersErr error
|
|
if storedHeaders, ok := values[SettingKeyForwardedClientIPHeaders]; ok {
|
|
headers, headersErr = parseForwardedClientIPHeadersSetting(storedHeaders)
|
|
if headersErr != nil {
|
|
enabled = false
|
|
headers = []string{}
|
|
headersErr = fmt.Errorf("load forwarded client ip headers: %w", headersErr)
|
|
}
|
|
}
|
|
|
|
updates := make(map[string]string)
|
|
if _, hasStoredHeaders := values[SettingKeyForwardedClientIPHeaders]; !hasStoredHeaders {
|
|
headersJSON, marshalErr := json.Marshal(headers)
|
|
if marshalErr != nil {
|
|
headers = []string{}
|
|
headersErr = errors.Join(headersErr, fmt.Errorf("marshal forwarded client ip headers: %w", marshalErr))
|
|
headersJSON = []byte("[]")
|
|
}
|
|
updates[SettingKeyForwardedClientIPHeaders] = string(headersJSON)
|
|
}
|
|
if values[settingKeyForwardedClientIPModeV2] != "true" {
|
|
updates[settingKeyForwardedClientIPModeV2] = "true"
|
|
// Before this migration, new installations persisted false by default.
|
|
// Restore compatibility only when no trusted-proxy policy was configured.
|
|
if headersErr == nil && hasStoredValue && !enabled && !s.cfg.Server.TrustedProxiesConfigured {
|
|
enabled = true
|
|
updates[SettingKeyAPIKeyACLTrustForwardedIP] = "true"
|
|
}
|
|
}
|
|
if len(updates) > 0 {
|
|
if err := s.settingRepo.SetMultiple(ctx, updates); err != nil {
|
|
s.cfg.SetForwardedClientIPSettings(enabled, headers)
|
|
return errors.Join(headersErr, fmt.Errorf("migrate forwarded client ip setting: %w", err))
|
|
}
|
|
}
|
|
|
|
s.cfg.SetForwardedClientIPSettings(enabled, headers)
|
|
return headersErr
|
|
}
|
|
|
|
// GetAllSettings 获取所有系统设置
|
|
func (s *SettingService) GetAllSettings(ctx context.Context) (*SystemSettings, error) {
|
|
settings, err := s.settingRepo.GetAll(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get all settings: %w", err)
|
|
}
|
|
|
|
return s.parseSettings(settings), nil
|
|
}
|
|
|
|
// SetOnUpdateCallback sets a callback function to be called when settings are updated
|
|
// This is used for cache invalidation (e.g., HTML cache in frontend server)
|
|
func (s *SettingService) SetOnUpdateCallback(callback func()) {
|
|
s.onUpdate = callback
|
|
}
|
|
|
|
// SetVersion sets the application version for injection into public settings
|
|
func (s *SettingService) SetVersion(version string) {
|
|
s.version = version
|
|
}
|
|
|
|
// getStringOrDefault 获取字符串值或默认值
|
|
func (s *SettingService) getStringOrDefault(settings map[string]string, key, defaultValue string) string {
|
|
if value, ok := settings[key]; ok && value != "" {
|
|
return value
|
|
}
|
|
return defaultValue
|
|
}
|