400 lines
17 KiB
Go
400 lines
17 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"errors"
|
|
"log/slog"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
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
|
|
OIDCSecurityEventsEnabled bool
|
|
OIDCSecurityEventsTransmitterIssuer string
|
|
OIDCSecurityEventsReceiverAudience string
|
|
OIDCSecurityEventsBearerSecret string
|
|
OIDCSecurityEventsBearerSecretNext string
|
|
OIDCSecurityEventsPublicEndpoint string
|
|
OIDCSecurityEventsStreamID string
|
|
OIDCSecurityEventsManagementTokenURL string
|
|
OIDCSecurityEventsManagementClientID string
|
|
OIDCSecurityEventsManagementClientSecret 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")
|
|
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: strings.TrimRight(env("OIDC_ISSUER", ""), "/"),
|
|
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: env("OIDC_INTROSPECTION_CLIENT_ID", ""),
|
|
OIDCIntrospectionClientSecret: env("OIDC_INTROSPECTION_CLIENT_SECRET", ""),
|
|
OIDCSecurityEventsEnabled: env("OIDC_SECURITY_EVENTS_ENABLED", "false") == "true",
|
|
OIDCSecurityEventsTransmitterIssuer: strings.TrimRight(env("OIDC_SECURITY_EVENTS_TRANSMITTER_ISSUER", ""), "/"),
|
|
OIDCSecurityEventsReceiverAudience: env("OIDC_SECURITY_EVENTS_RECEIVER_AUDIENCE", ""),
|
|
OIDCSecurityEventsBearerSecret: env("OIDC_SECURITY_EVENTS_BEARER_SECRET", ""),
|
|
OIDCSecurityEventsBearerSecretNext: env("OIDC_SECURITY_EVENTS_BEARER_SECRET_NEXT", ""),
|
|
OIDCSecurityEventsPublicEndpoint: env("OIDC_SECURITY_EVENTS_PUBLIC_ENDPOINT", ""),
|
|
OIDCSecurityEventsStreamID: env("OIDC_SECURITY_EVENTS_STREAM_ID", ""),
|
|
OIDCSecurityEventsManagementTokenURL: env("OIDC_SECURITY_EVENTS_MANAGEMENT_TOKEN_URL", ""),
|
|
OIDCSecurityEventsManagementClientID: env("OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_ID", ""),
|
|
OIDCSecurityEventsManagementClientSecret: env("OIDC_SECURITY_EVENTS_MANAGEMENT_CLIENT_SECRET", ""),
|
|
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 {
|
|
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")
|
|
}
|
|
}
|
|
}
|
|
if c.OIDCSecurityEventsEnabled {
|
|
if !c.OIDCEnabled {
|
|
return errors.New("OIDC_ENABLED must be true when OIDC security events are enabled")
|
|
}
|
|
if !validServiceURL(c.OIDCSecurityEventsTransmitterIssuer, c.AppEnv) ||
|
|
!validSecurityEventReceiverURL(c.OIDCSecurityEventsPublicEndpoint, c.AppEnv) ||
|
|
!validServiceURL(c.OIDCSecurityEventsManagementTokenURL, c.AppEnv) {
|
|
return errors.New("OIDC security event issuer, public endpoint, and management token URL must be HTTPS URLs")
|
|
}
|
|
if _, err := uuid.Parse(c.OIDCTenantID); err != nil {
|
|
return errors.New("OIDC_TENANT_ID must be a UUID when OIDC security events are enabled")
|
|
}
|
|
applicationID := strings.TrimPrefix(c.OIDCSecurityEventsReceiverAudience, "urn:easyai:ssf:receiver:")
|
|
if applicationID == c.OIDCSecurityEventsReceiverAudience {
|
|
return errors.New("OIDC security event receiver audience and stream ID are required")
|
|
}
|
|
if _, err := uuid.Parse(applicationID); err != nil {
|
|
return errors.New("OIDC security event receiver audience must contain an application UUID")
|
|
}
|
|
if _, err := uuid.Parse(c.OIDCSecurityEventsStreamID); err != nil {
|
|
return errors.New("OIDC security event stream ID must be a UUID")
|
|
}
|
|
if !validBearerSecret(c.OIDCSecurityEventsBearerSecret) ||
|
|
c.OIDCSecurityEventsBearerSecretNext != "" && !validBearerSecret(c.OIDCSecurityEventsBearerSecretNext) {
|
|
return errors.New("OIDC security event bearer secrets must be 16-4096 printable non-space ASCII characters")
|
|
}
|
|
if c.OIDCSecurityEventsManagementClientID == "" || c.OIDCSecurityEventsManagementClientSecret == "" {
|
|
return errors.New("OIDC security event management machine client credentials are required")
|
|
}
|
|
if c.OIDCIntrospectionClientID == "" || c.OIDCIntrospectionClientSecret == "" {
|
|
return errors.New("RFC 7662 client credentials are required when OIDC security events are enabled")
|
|
}
|
|
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")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validBearerSecret(value string) bool {
|
|
if len(value) < 16 || len(value) > 4096 {
|
|
return false
|
|
}
|
|
for _, character := range []byte(value) {
|
|
if character < 0x21 || character > 0x7e {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
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 validServiceURL(raw, appEnv string) bool {
|
|
parsed, err := url.Parse(strings.TrimSpace(raw))
|
|
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" {
|
|
return false
|
|
}
|
|
if parsed.Scheme == "https" {
|
|
return true
|
|
}
|
|
return isLocalEnvironment(appEnv) && parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1")
|
|
}
|
|
|
|
func validSecurityEventReceiverURL(raw, appEnv string) bool {
|
|
if !validServiceURL(raw, appEnv) {
|
|
return false
|
|
}
|
|
parsed, err := url.Parse(strings.TrimSpace(raw))
|
|
return err == nil && parsed.EscapedPath() == "/api/v1/security-events/ssf" && parsed.RawQuery == ""
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|