easyai-ai-gateway/apps/api/internal/config/config.go
chengcheng 9efeb16fd1 feat(ssf): 实现安全事件一键连接与凭据托管
新增数据库驱动的 SecurityEventConnectionManager,复用 RFC 7662 机器 Client,自动完成 Discovery、Push Bearer 生成、Stream 创建、Verification、首次启用、零停机轮换和安全退役。

增加文件与 Kubernetes SecretStore、最小权限 RBAC、动态 Receiver/撤销水位/内省降级,以及系统设置管理页面。已通过全量 Go 测试、go vet、关键包竞态测试、27 个 Web 测试、类型检查、生产构建、Compose 和 Kubernetes dry-run。
2026-07-15 17:25:00 +08:00

346 lines
15 KiB
Go

package config
import (
"encoding/base64"
"errors"
"log/slog"
"net/url"
"os"
"strconv"
"strings"
)
const (
DefaultLocalGeneratedStorageDir = "data/static/generated"
DefaultLocalUploadedStorageDir = "data/static/uploaded"
)
type Config struct {
AppEnv string
HTTPAddr string
DatabaseURL string
IdentityMode string
JWTSecret string
ServerMainBaseURL string
ServerMainInternalToken string
ServerMainInternalKey string
ServerMainInternalSecret string
OIDCEnabled bool
OIDCIssuer string
OIDCAudience string
OIDCTenantID string
OIDCRolePrefix string
OIDCRequiredScopes []string
OIDCJWKSCacheTTLSeconds int
OIDCAcceptLegacyHS256 bool
OIDCIntrospectionEnabled bool
OIDCIntrospectionClientID string
OIDCIntrospectionClientSecret string
OIDCSecurityEventsSecretStore string
OIDCSecurityEventsSecretDir string
OIDCSecurityEventsKubernetesNamespace string
OIDCSecurityEventsKubernetesSecretName string
OIDCSecurityEventsKubernetesAPIServer string
OIDCSecurityEventsKubernetesTokenFile string
OIDCSecurityEventsKubernetesCAFile string
OIDCSecurityEventsHeartbeatIntervalSeconds int
OIDCSecurityEventsStaleAfterSeconds int
OIDCSecurityEventsClockSkewSeconds int
OIDCJITProvisioningEnabled bool
OIDCGatewayTenantKey string
OIDCBrowserSessionEnabled bool
OIDCSessionCookieSecure bool
OIDCClientID string
OIDCRedirectURI string
OIDCPostLogoutRedirectURI string
OIDCSessionEncryptionKey string
OIDCSessionIdleTTLSeconds int
OIDCSessionAbsoluteTTLSeconds int
OIDCSessionRefreshBeforeSeconds int
PublicBaseURL string
WebBaseURL string
LocalGeneratedStorageDir string
LocalUploadedStorageDir string
LocalTempAssetTTLHours int
TaskProgressCallbackEnabled bool
TaskProgressCallbackURL string
TaskProgressCallbackTimeoutMS string
TaskProgressCallbackMaxAttempts string
CORSAllowedOrigin string
GlobalHTTPProxy string
GlobalHTTPProxySource string
LogLevel slog.Level
}
func Load() Config {
globalProxy := LoadGlobalHTTPProxyStatus()
appEnv := env("APP_ENV", "development")
oidcIssuer := strings.TrimRight(env("OIDC_ISSUER", ""), "/")
introspectionClientID := env("OIDC_INTROSPECTION_CLIENT_ID", "")
introspectionClientSecret := env("OIDC_INTROSPECTION_CLIENT_SECRET", "")
return Config{
AppEnv: appEnv,
HTTPAddr: env("HTTP_ADDR", ":8088"),
DatabaseURL: gatewayDatabaseURL(),
IdentityMode: env("IDENTITY_MODE", "hybrid"),
JWTSecret: env("CONFIG_JWT_SECRET", "this is a very secret secret"),
ServerMainBaseURL: strings.TrimRight(
env("SERVER_MAIN_BASE_URL", "http://localhost:3000"),
"/",
),
ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""),
ServerMainInternalKey: env("SERVER_MAIN_INTERNAL_KEY", "gateway"),
ServerMainInternalSecret: env("SERVER_MAIN_INTERNAL_SECRET", env("SERVER_MAIN_INTERNAL_TOKEN", "")),
OIDCEnabled: env("OIDC_ENABLED", "false") == "true",
OIDCIssuer: oidcIssuer,
OIDCAudience: env("OIDC_AUDIENCE", ""),
OIDCTenantID: env("OIDC_TENANT_ID", ""),
OIDCRolePrefix: env("OIDC_ROLE_PREFIX", "gateway."),
OIDCRequiredScopes: splitCSV(env("OIDC_REQUIRED_SCOPES", "gateway.access")),
OIDCJWKSCacheTTLSeconds: envInt("OIDC_JWKS_CACHE_TTL_SECONDS", 300),
OIDCAcceptLegacyHS256: env("OIDC_ACCEPT_LEGACY_HS256", "true") == "true",
OIDCIntrospectionEnabled: env("OIDC_INTROSPECTION_ENABLED", "false") == "true",
OIDCIntrospectionClientID: introspectionClientID,
OIDCIntrospectionClientSecret: introspectionClientSecret,
OIDCSecurityEventsSecretStore: env("OIDC_SECURITY_EVENTS_SECRET_STORE", "file"),
OIDCSecurityEventsSecretDir: env("OIDC_SECURITY_EVENTS_SECRET_DIR", ".local-secrets/ssf"),
OIDCSecurityEventsKubernetesNamespace: env("OIDC_SECURITY_EVENTS_KUBERNETES_NAMESPACE", env("POD_NAMESPACE", "")),
OIDCSecurityEventsKubernetesSecretName: env("OIDC_SECURITY_EVENTS_KUBERNETES_SECRET_NAME", "easyai-gateway-security-events"),
OIDCSecurityEventsKubernetesAPIServer: env("OIDC_SECURITY_EVENTS_KUBERNETES_API_SERVER", "https://kubernetes.default.svc"),
OIDCSecurityEventsKubernetesTokenFile: env("OIDC_SECURITY_EVENTS_KUBERNETES_TOKEN_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/token"),
OIDCSecurityEventsKubernetesCAFile: env("OIDC_SECURITY_EVENTS_KUBERNETES_CA_FILE", "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"),
OIDCSecurityEventsHeartbeatIntervalSeconds: envInt("OIDC_SECURITY_EVENTS_HEARTBEAT_INTERVAL_SECONDS", 60),
OIDCSecurityEventsStaleAfterSeconds: envInt("OIDC_SECURITY_EVENTS_STALE_AFTER_SECONDS", 180),
OIDCSecurityEventsClockSkewSeconds: envInt("OIDC_SECURITY_EVENTS_CLOCK_SKEW_SECONDS", 60),
OIDCJITProvisioningEnabled: env("OIDC_JIT_PROVISIONING_ENABLED", "false") == "true",
OIDCGatewayTenantKey: env("OIDC_GATEWAY_TENANT_KEY", ""),
OIDCBrowserSessionEnabled: env("OIDC_BROWSER_SESSION_ENABLED", "true") == "true",
OIDCSessionCookieSecure: env("OIDC_SESSION_COOKIE_SECURE",
strconv.FormatBool(!isLocalEnvironment(appEnv)),
) == "true",
OIDCClientID: env("OIDC_CLIENT_ID", ""),
OIDCRedirectURI: env("OIDC_REDIRECT_URI", ""),
OIDCPostLogoutRedirectURI: env("OIDC_POST_LOGOUT_REDIRECT_URI", ""),
OIDCSessionEncryptionKey: env("OIDC_SESSION_ENCRYPTION_KEY", ""),
OIDCSessionIdleTTLSeconds: envInt("OIDC_SESSION_IDLE_TTL_SECONDS", 1800),
OIDCSessionAbsoluteTTLSeconds: envInt("OIDC_SESSION_ABSOLUTE_TTL_SECONDS", 28800),
OIDCSessionRefreshBeforeSeconds: envInt("OIDC_SESSION_REFRESH_BEFORE_SECONDS", 60),
PublicBaseURL: strings.TrimRight(env("AI_GATEWAY_PUBLIC_BASE_URL", env("PUBLIC_BASE_URL", "")), "/"),
WebBaseURL: strings.TrimRight(env("AI_GATEWAY_WEB_BASE_URL", env("GATEWAY_WEB_BASE_URL", env("PUBLIC_WEB_BASE_URL", ""))), "/"),
LocalGeneratedStorageDir: env("AI_GATEWAY_GENERATED_STORAGE_DIR", env("LOCAL_GENERATED_STORAGE_DIR", env("AI_GATEWAY_STATIC_STORAGE_DIR", DefaultLocalGeneratedStorageDir))),
LocalUploadedStorageDir: env("AI_GATEWAY_UPLOADED_STORAGE_DIR", env("LOCAL_UPLOADED_STORAGE_DIR", DefaultLocalUploadedStorageDir)),
LocalTempAssetTTLHours: envInt("AI_GATEWAY_LOCAL_TEMP_ASSET_TTL_HOURS", 24),
TaskProgressCallbackEnabled: env("TASK_PROGRESS_CALLBACK_ENABLED", "true") == "true",
TaskProgressCallbackURL: env("TASK_PROGRESS_CALLBACK_URL",
strings.TrimRight(env("SERVER_MAIN_BASE_URL", "http://localhost:3000"), "/")+"/internal/platform/task-progress-callbacks",
),
TaskProgressCallbackTimeoutMS: env("TASK_PROGRESS_CALLBACK_TIMEOUT_MS", "5000"),
TaskProgressCallbackMaxAttempts: env("TASK_PROGRESS_CALLBACK_MAX_ATTEMPTS", "10"),
CORSAllowedOrigin: env("CORS_ALLOWED_ORIGIN", "http://localhost:5178,http://127.0.0.1:5178"),
GlobalHTTPProxy: globalProxy.HTTPProxy,
GlobalHTTPProxySource: globalProxy.Source,
LogLevel: logLevel(env("LOG_LEVEL", "info")),
}
}
func (c Config) Validate() error {
switch strings.ToLower(strings.TrimSpace(c.OIDCSecurityEventsSecretStore)) {
case "":
case "file":
if strings.TrimSpace(c.OIDCSecurityEventsSecretDir) == "" {
return errors.New("OIDC_SECURITY_EVENTS_SECRET_DIR is required for the file SecretStore")
}
case "kubernetes":
if strings.TrimSpace(c.OIDCSecurityEventsKubernetesNamespace) == "" || strings.TrimSpace(c.OIDCSecurityEventsKubernetesSecretName) == "" {
return errors.New("Kubernetes security event SecretStore requires namespace and Secret name")
}
default:
return errors.New("OIDC_SECURITY_EVENTS_SECRET_STORE must be file or kubernetes")
}
if c.OIDCSecurityEventsHeartbeatIntervalSeconds != 0 || c.OIDCSecurityEventsStaleAfterSeconds != 0 || c.OIDCSecurityEventsClockSkewSeconds != 0 {
if c.OIDCSecurityEventsHeartbeatIntervalSeconds <= 0 ||
c.OIDCSecurityEventsStaleAfterSeconds < 2*c.OIDCSecurityEventsHeartbeatIntervalSeconds ||
c.OIDCSecurityEventsClockSkewSeconds < 0 || c.OIDCSecurityEventsClockSkewSeconds > 300 {
return errors.New("OIDC security event heartbeat, stale threshold, or clock skew is invalid")
}
}
if c.OIDCJITProvisioningEnabled && strings.TrimSpace(c.OIDCGatewayTenantKey) == "" {
return errors.New("OIDC_GATEWAY_TENANT_KEY is required when OIDC_JIT_PROVISIONING_ENABLED=true")
}
if c.OIDCEnabled && c.OIDCBrowserSessionEnabled {
for _, scope := range c.OIDCRequiredScopes {
if strings.EqualFold(strings.TrimSpace(scope), "offline_access") {
return errors.New("OIDC_REQUIRED_SCOPES cannot contain offline_access for browser sessions")
}
}
if strings.TrimSpace(c.OIDCClientID) == "" {
return errors.New("OIDC_CLIENT_ID is required when OIDC browser sessions are enabled")
}
if !validPublicRedirectURL(c.OIDCRedirectURI) {
return errors.New("OIDC_REDIRECT_URI must be an exact HTTPS URL (localhost HTTP is allowed for development)")
}
if !validPublicRedirectURL(c.OIDCPostLogoutRedirectURI) {
return errors.New("OIDC_POST_LOGOUT_REDIRECT_URI must be an exact HTTPS URL (localhost HTTP is allowed for development)")
}
if _, err := c.OIDCSessionEncryptionKeyBytes(); err != nil {
return err
}
if c.OIDCSessionIdleTTLSeconds <= 0 || c.OIDCSessionAbsoluteTTLSeconds <= c.OIDCSessionIdleTTLSeconds {
return errors.New("OIDC session idle TTL must be positive and shorter than the absolute TTL")
}
if c.OIDCSessionRefreshBeforeSeconds <= 0 || c.OIDCSessionRefreshBeforeSeconds >= c.OIDCSessionIdleTTLSeconds {
return errors.New("OIDC_SESSION_REFRESH_BEFORE_SECONDS must be positive and shorter than the idle TTL")
}
if !isLocalEnvironment(c.AppEnv) && !c.OIDCSessionCookieSecure {
return errors.New("OIDC_SESSION_COOKIE_SECURE must be true outside local development and tests")
}
for _, origin := range strings.Split(c.CORSAllowedOrigin, ",") {
if strings.TrimSpace(origin) == "*" {
return errors.New("CORS_ALLOWED_ORIGIN cannot contain * when OIDC browser sessions are enabled")
}
}
}
return nil
}
func (c Config) OIDCSessionEncryptionKeyBytes() ([]byte, error) {
raw := strings.TrimSpace(c.OIDCSessionEncryptionKey)
for _, encoding := range []*base64.Encoding{base64.RawStdEncoding, base64.StdEncoding, base64.RawURLEncoding, base64.URLEncoding} {
decoded, err := encoding.DecodeString(raw)
if err == nil && len(decoded) == 32 {
return decoded, nil
}
}
return nil, errors.New("OIDC_SESSION_ENCRYPTION_KEY must be a base64-encoded 32-byte random key")
}
func validPublicRedirectURL(raw string) bool {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return false
}
if parsed.Scheme == "https" {
return true
}
return parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1")
}
func isLocalEnvironment(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "development", "dev", "local", "test":
return true
default:
return false
}
}
type GlobalHTTPProxyStatus struct {
HTTPProxy string
Source string
}
func LoadGlobalHTTPProxyStatus() GlobalHTTPProxyStatus {
for _, key := range []string{
"AI_GATEWAY_GLOBAL_HTTP_PROXY",
"GLOBAL_HTTP_PROXY",
"HTTPS_PROXY",
"https_proxy",
"HTTP_PROXY",
"http_proxy",
"ALL_PROXY",
"all_proxy",
} {
if value := envValue(key); value != "" {
return GlobalHTTPProxyStatus{HTTPProxy: value, Source: key}
}
}
return GlobalHTTPProxyStatus{}
}
func gatewayDatabaseURL() string {
if value := envValue("AI_GATEWAY_DATABASE_URL"); value != "" {
return normalizePostgresURL(value)
}
if value := envValue("DATABASE_URL"); value != "" {
return normalizePostgresURL(value)
}
if memoryURL := envValue("MEMORY_DATABASE_URL"); memoryURL != "" {
return normalizePostgresURL(withDatabase(memoryURL, env("AI_GATEWAY_DATABASE_NAME", "easyai_ai_gateway")))
}
return normalizePostgresURL("postgresql://easyai:easyai2025@localhost:5432/easyai_ai_gateway?sslmode=disable")
}
func normalizePostgresURL(raw string) string {
parsed, err := url.Parse(raw)
if err != nil {
return raw
}
values := parsed.Query()
schema := values.Get("schema")
if schema == "" {
return raw
}
values.Del("schema")
if values.Get("search_path") == "" {
values.Set("search_path", schema)
}
parsed.RawQuery = values.Encode()
return parsed.String()
}
func splitCSV(value string) []string {
items := strings.Split(value, ",")
result := make([]string, 0, len(items))
for _, item := range items {
if trimmed := strings.TrimSpace(item); trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
func withDatabase(raw string, databaseName string) string {
parsed, err := url.Parse(raw)
if err != nil || databaseName == "" {
return raw
}
parsed.Path = "/" + databaseName
return parsed.String()
}
func envValue(key string) string {
return strings.TrimSpace(os.Getenv(key))
}
func env(key string, fallback string) string {
if value := envValue(key); value != "" {
return value
}
return fallback
}
func envInt(key string, fallback int) int {
value := envValue(key)
if value == "" {
return fallback
}
parsed, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return parsed
}
func logLevel(value string) slog.Level {
switch strings.ToLower(value) {
case "debug":
return slog.LevelDebug
case "warn", "warning":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}