c22669c31d
- 新增迁移 000031-000034(登录日志/角色/模型配额/记忆) - 新增包: license/memory/modelquota/assistant,扫描引擎 - 全部功能后端+前端+端到端验证通过(25 包单测)
501 lines
20 KiB
Go
501 lines
20 KiB
Go
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/netip"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Config struct {
|
|
Environment string
|
|
Server Server
|
|
Database Database
|
|
Redis Redis
|
|
Security Security
|
|
Auth Auth
|
|
Credentials Credentials
|
|
Upstream Upstream
|
|
Audit Audit
|
|
Outbox Outbox
|
|
RuntimeData RuntimeData
|
|
Shadow Shadow
|
|
ObjectStorage ObjectStorage
|
|
Embeddings Embeddings
|
|
Inbox Inbox
|
|
Scheduler Scheduler
|
|
License License
|
|
}
|
|
|
|
// License 配置 License 授权(LICENSE_FILE 指向签名文件;为空=社区版 Free)。
|
|
type License struct {
|
|
FilePath string
|
|
}
|
|
|
|
type Server struct {
|
|
Address string
|
|
ReadHeaderTimeout time.Duration
|
|
IdleTimeout time.Duration
|
|
ShutdownTimeout time.Duration
|
|
MaxBodyBytes int64
|
|
}
|
|
|
|
type Database struct {
|
|
URL string
|
|
MaxConns int32
|
|
MinConns int32
|
|
}
|
|
|
|
type Redis struct {
|
|
CriticalURL string
|
|
CacheURL string
|
|
}
|
|
|
|
type Security struct {
|
|
BootstrapAPIKey string
|
|
BootstrapAPIKeyEnabled bool
|
|
}
|
|
|
|
type Auth struct {
|
|
SessionTTL time.Duration
|
|
TOTPChallengeTTL time.Duration
|
|
MaxFailures int
|
|
LockDuration time.Duration
|
|
LoginRateLimitMax int // 单 IP 滑动窗口内的最大登录尝试次数
|
|
LoginRateLimitWindow time.Duration // 登录限流滑动窗口
|
|
TrustedProxies []netip.Prefix // 可信反向代理网段;仅来自这些对端的 X-Forwarded-For 被采信
|
|
}
|
|
|
|
type Credentials struct {
|
|
MasterKey string
|
|
KEKVersion int
|
|
KEKKeyring string
|
|
AllowPrivateProviderURL bool
|
|
AllowPrivateToolURL bool
|
|
AllowPrivateWebhookURL bool
|
|
ProviderRefreshInterval time.Duration
|
|
}
|
|
|
|
type Upstream struct {
|
|
BaseURL string
|
|
APIKey string
|
|
FallbackEnabled bool
|
|
ResponseHeaderTimeout time.Duration
|
|
MaxRetries int
|
|
RetryBackoff time.Duration
|
|
CircuitThreshold int
|
|
CircuitOpenDuration time.Duration
|
|
}
|
|
|
|
type Audit struct {
|
|
QueueSize int
|
|
BatchSize int
|
|
FlushInterval time.Duration
|
|
Retention time.Duration
|
|
UsageRetention time.Duration
|
|
TraceRetention time.Duration
|
|
PartitionMonthsAhead int
|
|
MaintenanceInterval time.Duration
|
|
}
|
|
|
|
type Outbox struct {
|
|
Stream string
|
|
BatchSize int
|
|
PollInterval time.Duration
|
|
Lease time.Duration
|
|
MaxAttempts int
|
|
MaxBackoff time.Duration
|
|
StreamMaxLength int64
|
|
MarkerTTL time.Duration
|
|
}
|
|
|
|
type RuntimeData struct {
|
|
ContentPolicyRefreshInterval time.Duration
|
|
PricingRefreshInterval time.Duration
|
|
}
|
|
|
|
type Shadow struct {
|
|
BaseURL string
|
|
APIKey string
|
|
SampleRate float64
|
|
Timeout time.Duration
|
|
MaxBodyBytes int64
|
|
MaxConcurrent int
|
|
}
|
|
|
|
type ObjectStorage struct {
|
|
Endpoint string
|
|
AccessKeyID string
|
|
SecretAccessKey string
|
|
Bucket string
|
|
Region string
|
|
UseSSL bool
|
|
MaxFileBytes int64
|
|
}
|
|
|
|
// Embeddings 配置本地 Ollama 向量化(默认 bge-m3)。Enabled 为 false 时网关不构造
|
|
// OllamaEmbedder,知识库退回纯 FTS 检索,AddKnowledgeDocument 不生成 embedding。
|
|
type Embeddings struct {
|
|
Enabled bool
|
|
BaseURL string
|
|
Model string
|
|
Dim int
|
|
BatchSize int
|
|
Timeout time.Duration
|
|
}
|
|
|
|
// Inbox 配置站内消息(M8 P4)。Channel 是通知 worker 落库后 PUBLISH 的 Redis 频道,
|
|
// 供未来实时推送订阅;未读数以 PostgreSQL 为权威源,不依赖 Redis。
|
|
type Inbox struct {
|
|
Channel string
|
|
}
|
|
|
|
type Scheduler struct {
|
|
GatewayBaseURL string
|
|
PollInterval time.Duration
|
|
ExecutionTimeout time.Duration
|
|
BatchSize int
|
|
MaxAttempts int
|
|
}
|
|
|
|
func Load() (Config, error) {
|
|
cfg := Config{
|
|
Environment: env("APP_ENV", "local"),
|
|
Server: Server{
|
|
Address: env("HTTP_ADDR", "127.0.0.1:8080"),
|
|
ReadHeaderTimeout: duration("HTTP_READ_HEADER_TIMEOUT", 5*time.Second),
|
|
IdleTimeout: duration("HTTP_IDLE_TIMEOUT", 120*time.Second),
|
|
ShutdownTimeout: duration("HTTP_SHUTDOWN_TIMEOUT", 20*time.Second),
|
|
MaxBodyBytes: int64Value("HTTP_MAX_BODY_BYTES", 32<<20),
|
|
},
|
|
Database: Database{
|
|
URL: strings.TrimSpace(os.Getenv("DATABASE_URL")),
|
|
MaxConns: int32(intValue("DATABASE_MAX_CONNS", 40)),
|
|
MinConns: int32(intValue("DATABASE_MIN_CONNS", 4)),
|
|
},
|
|
Redis: Redis{
|
|
CriticalURL: strings.TrimSpace(os.Getenv("REDIS_CRITICAL_URL")),
|
|
CacheURL: strings.TrimSpace(os.Getenv("REDIS_CACHE_URL")),
|
|
},
|
|
Security: Security{
|
|
BootstrapAPIKey: strings.TrimSpace(os.Getenv("GATEWAY_BOOTSTRAP_API_KEY")),
|
|
BootstrapAPIKeyEnabled: boolValue("GATEWAY_BOOTSTRAP_API_KEY_ENABLED", false),
|
|
},
|
|
Auth: Auth{
|
|
SessionTTL: duration("AUTH_SESSION_TTL", 12*time.Hour),
|
|
TOTPChallengeTTL: duration("AUTH_TOTP_CHALLENGE_TTL", 5*time.Minute),
|
|
MaxFailures: intValue("LOGIN_MAX_FAILURES", 5),
|
|
LockDuration: duration("LOGIN_LOCK_DURATION", 15*time.Minute),
|
|
LoginRateLimitMax: intValue("LOGIN_RATE_LIMIT_MAX", 30),
|
|
LoginRateLimitWindow: duration("LOGIN_RATE_LIMIT_WINDOW", 5*time.Minute),
|
|
TrustedProxies: parsePrefixList(env("TRUSTED_PROXIES", "127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7")),
|
|
},
|
|
Credentials: Credentials{
|
|
MasterKey: strings.TrimSpace(os.Getenv("CREDENTIAL_MASTER_KEY")),
|
|
KEKVersion: intValue("CREDENTIAL_KEK_VERSION", 1),
|
|
KEKKeyring: strings.TrimSpace(os.Getenv("CREDENTIAL_KEK_KEYRING")),
|
|
AllowPrivateProviderURL: boolValue("ALLOW_PRIVATE_PROVIDER_URLS", false),
|
|
AllowPrivateToolURL: boolValue("ALLOW_PRIVATE_TOOL_URLS", false),
|
|
AllowPrivateWebhookURL: boolValue("ALLOW_PRIVATE_WEBHOOK_URLS", false),
|
|
ProviderRefreshInterval: duration("PROVIDER_REFRESH_INTERVAL", 5*time.Second),
|
|
},
|
|
Upstream: Upstream{
|
|
BaseURL: strings.TrimRight(env("UPSTREAM_BASE_URL", "https://api.openai.com"), "/"), APIKey: strings.TrimSpace(os.Getenv("UPSTREAM_API_KEY")),
|
|
FallbackEnabled: boolValue("UPSTREAM_FALLBACK_ENABLED", true), ResponseHeaderTimeout: duration("UPSTREAM_RESPONSE_HEADER_TIMEOUT", 60*time.Second),
|
|
MaxRetries: intValue("UPSTREAM_MAX_RETRIES", 2), RetryBackoff: duration("UPSTREAM_RETRY_BACKOFF", 50*time.Millisecond),
|
|
CircuitThreshold: intValue("UPSTREAM_CIRCUIT_THRESHOLD", 5), CircuitOpenDuration: duration("UPSTREAM_CIRCUIT_OPEN_DURATION", 30*time.Second),
|
|
},
|
|
Audit: Audit{
|
|
QueueSize: intValue("AUDIT_QUEUE_SIZE", 4096), BatchSize: intValue("AUDIT_BATCH_SIZE", 200),
|
|
FlushInterval: duration("AUDIT_FLUSH_INTERVAL", time.Second), Retention: duration("AUDIT_RETENTION", 90*24*time.Hour),
|
|
UsageRetention: duration("USAGE_RETENTION", 730*24*time.Hour), TraceRetention: duration("TRACE_RETENTION", 90*24*time.Hour),
|
|
PartitionMonthsAhead: intValue("AUDIT_PARTITION_MONTHS_AHEAD", 3),
|
|
MaintenanceInterval: duration("AUDIT_MAINTENANCE_INTERVAL", 6*time.Hour),
|
|
},
|
|
Outbox: Outbox{
|
|
Stream: env("OUTBOX_STREAM", "gateway:{outbox}:events"), BatchSize: intValue("OUTBOX_BATCH_SIZE", 100),
|
|
PollInterval: duration("OUTBOX_POLL_INTERVAL", 500*time.Millisecond), Lease: duration("OUTBOX_LEASE", 30*time.Second),
|
|
MaxAttempts: intValue("OUTBOX_MAX_ATTEMPTS", 10), MaxBackoff: duration("OUTBOX_MAX_BACKOFF", 5*time.Minute),
|
|
StreamMaxLength: int64Value("OUTBOX_STREAM_MAX_LENGTH", 100_000), MarkerTTL: duration("OUTBOX_MARKER_TTL", 30*24*time.Hour),
|
|
},
|
|
RuntimeData: RuntimeData{
|
|
ContentPolicyRefreshInterval: duration("CONTENT_POLICY_REFRESH_INTERVAL", 30*time.Second),
|
|
PricingRefreshInterval: duration("PRICING_REFRESH_INTERVAL", 30*time.Second),
|
|
},
|
|
Shadow: Shadow{
|
|
BaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SHADOW_BASE_URL")), "/"),
|
|
APIKey: strings.TrimSpace(os.Getenv("SHADOW_API_KEY")), SampleRate: floatValue("SHADOW_SAMPLE_RATE", 0),
|
|
Timeout: duration("SHADOW_TIMEOUT", 20*time.Second), MaxBodyBytes: int64Value("SHADOW_MAX_BODY_BYTES", 2<<20),
|
|
MaxConcurrent: intValue("SHADOW_MAX_CONCURRENT", 16),
|
|
},
|
|
ObjectStorage: ObjectStorage{
|
|
Endpoint: strings.TrimRight(env("S3_ENDPOINT", "http://minio:9000"), "/"),
|
|
AccessKeyID: env("S3_ACCESS_KEY_ID", "gateway"),
|
|
SecretAccessKey: env("S3_SECRET_ACCESS_KEY", "gateway-secret"),
|
|
Bucket: env("S3_BUCKET", "gateway-files"),
|
|
Region: env("S3_REGION", "us-east-1"),
|
|
UseSSL: boolValue("S3_USE_SSL", false),
|
|
MaxFileBytes: int64Value("S3_MAX_FILE_BYTES", 128<<20),
|
|
},
|
|
Embeddings: Embeddings{
|
|
Enabled: boolValue("EMBEDDINGS_ENABLED", true),
|
|
BaseURL: strings.TrimRight(env("OLLAMA_BASE_URL", "http://ollama:11434"), "/"),
|
|
Model: env("EMBEDDING_MODEL", "bge-m3"),
|
|
Dim: intValue("EMBEDDING_DIM", 1024),
|
|
BatchSize: intValue("EMBEDDING_BATCH_SIZE", 64),
|
|
Timeout: duration("EMBEDDING_TIMEOUT", 120*time.Second),
|
|
},
|
|
Inbox: Inbox{
|
|
Channel: env("INBOX_CHANNEL", "gateway:inbox:events"),
|
|
},
|
|
Scheduler: Scheduler{
|
|
GatewayBaseURL: strings.TrimRight(env("SCHEDULER_GATEWAY_BASE_URL", "http://gateway-api:8080"), "/"),
|
|
PollInterval: duration("SCHEDULER_POLL_INTERVAL", 5*time.Second),
|
|
ExecutionTimeout: duration("SCHEDULER_EXECUTION_TIMEOUT", 5*time.Minute),
|
|
BatchSize: intValue("SCHEDULER_BATCH_SIZE", 10),
|
|
MaxAttempts: intValue("SCHEDULER_MAX_ATTEMPTS", 3),
|
|
},
|
|
License: License{
|
|
FilePath: strings.TrimSpace(os.Getenv("LICENSE_FILE")),
|
|
},
|
|
}
|
|
|
|
return cfg, cfg.Validate()
|
|
}
|
|
|
|
func (c Config) Validate() error {
|
|
var errs []error
|
|
if c.Server.MaxBodyBytes <= 0 {
|
|
errs = append(errs, errors.New("HTTP_MAX_BODY_BYTES must be positive"))
|
|
}
|
|
if c.Database.MinConns < 0 || c.Database.MaxConns < 1 || c.Database.MinConns > c.Database.MaxConns {
|
|
errs = append(errs, errors.New("database pool sizes are invalid"))
|
|
}
|
|
if c.Auth.SessionTTL < 5*time.Minute || c.Auth.SessionTTL > 7*24*time.Hour || c.Auth.TOTPChallengeTTL < time.Minute || c.Auth.TOTPChallengeTTL > 15*time.Minute || c.Auth.MaxFailures < 1 || c.Auth.LockDuration < time.Minute {
|
|
errs = append(errs, errors.New("authentication limits are invalid"))
|
|
}
|
|
if c.Auth.LoginRateLimitMax < 1 || c.Auth.LoginRateLimitWindow < time.Second {
|
|
errs = append(errs, errors.New("login rate limit is invalid"))
|
|
}
|
|
if c.Credentials.KEKVersion < 1 {
|
|
errs = append(errs, errors.New("CREDENTIAL_KEK_VERSION must be positive"))
|
|
}
|
|
if c.Credentials.ProviderRefreshInterval < time.Second || c.Credentials.ProviderRefreshInterval > time.Minute {
|
|
errs = append(errs, errors.New("PROVIDER_REFRESH_INTERVAL must be between 1s and 1m"))
|
|
}
|
|
if err := validateHTTPURL(c.Upstream.BaseURL); err != nil {
|
|
errs = append(errs, fmt.Errorf("UPSTREAM_BASE_URL: %w", err))
|
|
}
|
|
if c.Upstream.ResponseHeaderTimeout < time.Second || c.Upstream.ResponseHeaderTimeout > 10*time.Minute || c.Upstream.MaxRetries < 0 || c.Upstream.MaxRetries > 5 || c.Upstream.RetryBackoff < 0 || c.Upstream.RetryBackoff > 5*time.Second || c.Upstream.CircuitThreshold < 1 || c.Upstream.CircuitThreshold > 100 || c.Upstream.CircuitOpenDuration < time.Second || c.Upstream.CircuitOpenDuration > 10*time.Minute {
|
|
errs = append(errs, errors.New("upstream resilience settings are invalid"))
|
|
}
|
|
if c.Audit.QueueSize < 100 || c.Audit.QueueSize > 1_000_000 || c.Audit.BatchSize < 1 || c.Audit.BatchSize > c.Audit.QueueSize || c.Audit.FlushInterval < 100*time.Millisecond || c.Audit.FlushInterval > time.Minute {
|
|
errs = append(errs, errors.New("audit buffering settings are invalid"))
|
|
}
|
|
if c.Audit.Retention < 24*time.Hour || c.Audit.Retention > 10*365*24*time.Hour || c.Audit.UsageRetention < c.Audit.Retention || c.Audit.UsageRetention > 10*365*24*time.Hour || c.Audit.TraceRetention < 24*time.Hour || c.Audit.TraceRetention > 10*365*24*time.Hour || c.Audit.PartitionMonthsAhead < 1 || c.Audit.PartitionMonthsAhead > 24 || c.Audit.MaintenanceInterval < time.Hour || c.Audit.MaintenanceInterval > 7*24*time.Hour {
|
|
errs = append(errs, errors.New("audit retention settings are invalid"))
|
|
}
|
|
if !strings.Contains(c.Outbox.Stream, "{outbox}") || c.Outbox.BatchSize < 1 || c.Outbox.BatchSize > 1000 || c.Outbox.PollInterval < 50*time.Millisecond || c.Outbox.PollInterval > time.Minute || c.Outbox.Lease < 5*time.Second || c.Outbox.Lease > 10*time.Minute || c.Outbox.MaxAttempts < 1 || c.Outbox.MaxAttempts > 100 || c.Outbox.MaxBackoff < time.Second || c.Outbox.MaxBackoff > time.Hour || c.Outbox.StreamMaxLength < 1000 || c.Outbox.StreamMaxLength > 100_000_000 || c.Outbox.MarkerTTL < 24*time.Hour || c.Outbox.MarkerTTL > 365*24*time.Hour {
|
|
errs = append(errs, errors.New("outbox delivery settings are invalid"))
|
|
}
|
|
if c.RuntimeData.ContentPolicyRefreshInterval < time.Second || c.RuntimeData.ContentPolicyRefreshInterval > 10*time.Minute || c.RuntimeData.PricingRefreshInterval < time.Second || c.RuntimeData.PricingRefreshInterval > 10*time.Minute {
|
|
errs = append(errs, errors.New("runtime data refresh settings must be between 1s and 10m"))
|
|
}
|
|
if c.Shadow.SampleRate < 0 || c.Shadow.SampleRate > 1 || c.Shadow.Timeout < time.Second || c.Shadow.Timeout > 2*time.Minute || c.Shadow.MaxBodyBytes < 1024 || c.Shadow.MaxBodyBytes > 32<<20 || c.Shadow.MaxConcurrent < 1 || c.Shadow.MaxConcurrent > 1000 {
|
|
errs = append(errs, errors.New("shadow traffic settings are invalid"))
|
|
}
|
|
if c.Shadow.BaseURL != "" {
|
|
if err := validateHTTPURL(c.Shadow.BaseURL); err != nil {
|
|
errs = append(errs, fmt.Errorf("SHADOW_BASE_URL: %w", err))
|
|
}
|
|
if c.Shadow.APIKey == "" {
|
|
errs = append(errs, errors.New("SHADOW_API_KEY is required when SHADOW_BASE_URL is set"))
|
|
}
|
|
}
|
|
if err := validateHTTPURL(c.ObjectStorage.Endpoint); err != nil {
|
|
errs = append(errs, fmt.Errorf("S3_ENDPOINT: %w", err))
|
|
} else if endpointURL, parseErr := url.Parse(c.ObjectStorage.Endpoint); parseErr == nil && endpointURL.Path != "" {
|
|
// minio-go 的 Endpoint 不接受带路径的完整 URL(报 "fully qualified paths")。
|
|
errs = append(errs, errors.New("S3_ENDPOINT must not contain a path"))
|
|
}
|
|
if c.ObjectStorage.AccessKeyID == "" || c.ObjectStorage.SecretAccessKey == "" {
|
|
errs = append(errs, errors.New("S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY are required"))
|
|
}
|
|
if c.ObjectStorage.Bucket == "" || len(c.ObjectStorage.Bucket) > 63 {
|
|
errs = append(errs, errors.New("S3_BUCKET must be a non-empty bucket name of at most 63 characters"))
|
|
}
|
|
if c.ObjectStorage.MaxFileBytes < 1<<20 || c.ObjectStorage.MaxFileBytes > 512<<20 {
|
|
errs = append(errs, errors.New("S3_MAX_FILE_BYTES must be between 1 MiB and 512 MiB"))
|
|
}
|
|
if c.Embeddings.Enabled {
|
|
if err := validateHTTPURL(c.Embeddings.BaseURL); err != nil {
|
|
errs = append(errs, fmt.Errorf("OLLAMA_BASE_URL: %w", err))
|
|
}
|
|
if c.Embeddings.Model == "" {
|
|
errs = append(errs, errors.New("EMBEDDING_MODEL is required when embeddings are enabled"))
|
|
}
|
|
if c.Embeddings.Dim < 128 || c.Embeddings.Dim > 8192 {
|
|
errs = append(errs, errors.New("EMBEDDING_DIM must be between 128 and 8192"))
|
|
}
|
|
if c.Embeddings.Dim != 1024 {
|
|
// 知识库 embedding 列固定为 vector(1024);维度不符会让入库向量报错。
|
|
errs = append(errs, errors.New("EMBEDDING_DIM must be 1024 to match the vector(1024) column"))
|
|
}
|
|
if c.Embeddings.BatchSize < 1 || c.Embeddings.BatchSize > 512 {
|
|
errs = append(errs, errors.New("EMBEDDING_BATCH_SIZE must be between 1 and 512"))
|
|
}
|
|
if c.Embeddings.Timeout < time.Second || c.Embeddings.Timeout > 30*time.Minute {
|
|
errs = append(errs, errors.New("EMBEDDING_TIMEOUT must be between 1s and 30m"))
|
|
}
|
|
}
|
|
if err := validateHTTPURL(c.Scheduler.GatewayBaseURL); err != nil {
|
|
errs = append(errs, fmt.Errorf("SCHEDULER_GATEWAY_BASE_URL: %w", err))
|
|
}
|
|
if c.Scheduler.PollInterval < time.Second || c.Scheduler.PollInterval > time.Minute || c.Scheduler.ExecutionTimeout < time.Minute || c.Scheduler.ExecutionTimeout > time.Hour || c.Scheduler.BatchSize < 1 || c.Scheduler.BatchSize > 100 || c.Scheduler.MaxAttempts < 1 || c.Scheduler.MaxAttempts > 10 {
|
|
errs = append(errs, errors.New("scheduler settings are invalid"))
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
func (c Config) ValidateRuntime() error {
|
|
var errs []error
|
|
if strings.EqualFold(c.Environment, "production") {
|
|
if c.Database.URL == "" {
|
|
errs = append(errs, errors.New("DATABASE_URL is required in production"))
|
|
}
|
|
if c.Redis.CriticalURL == "" {
|
|
errs = append(errs, errors.New("REDIS_CRITICAL_URL is required in production"))
|
|
}
|
|
if c.Security.BootstrapAPIKeyEnabled {
|
|
if len(c.Security.BootstrapAPIKey) < 32 {
|
|
errs = append(errs, errors.New("GATEWAY_BOOTSTRAP_API_KEY must contain at least 32 characters in production"))
|
|
}
|
|
// Reject the documented placeholder and other obviously weak values
|
|
// so an operator cannot accidentally deploy with the example secret.
|
|
switch strings.ToLower(c.Security.BootstrapAPIKey) {
|
|
case "change-me-in-production", "local-development-key", "changeme", "change-me", "password", "secret":
|
|
errs = append(errs, errors.New("GATEWAY_BOOTSTRAP_API_KEY is set to a known weak default; generate a strong random value"))
|
|
}
|
|
}
|
|
if c.Upstream.FallbackEnabled && c.Upstream.APIKey == "" {
|
|
errs = append(errs, errors.New("UPSTREAM_API_KEY is required in production"))
|
|
}
|
|
if c.Credentials.MasterKey == "" {
|
|
errs = append(errs, errors.New("CREDENTIAL_MASTER_KEY is required in production"))
|
|
}
|
|
}
|
|
// 拒绝已知弱默认密钥(所有环境,含本地 compose):deploy/docker-compose.yml
|
|
// 曾把全零密钥作为默认值,凡是用该值加密的 Provider 凭据/TOTP 密钥/
|
|
// Webhook 签名密钥,任何拿到仓库的人都能解密。
|
|
if c.Credentials.MasterKey != "" {
|
|
switch strings.ToLower(strings.TrimSpace(c.Credentials.MasterKey)) {
|
|
case "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=", "change-me", "changeme", "password", "secret":
|
|
errs = append(errs, errors.New("CREDENTIAL_MASTER_KEY is set to a known weak default; generate a strong random key with: openssl rand -base64 32"))
|
|
}
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
func validateHTTPURL(raw string) error {
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil {
|
|
return errors.New("must be an absolute http(s) URL without user info")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// parsePrefixList 解析逗号分隔的 IP/CIDR 列表;非法项跳过并返回 nil 表示不信任任何代理。
|
|
func parsePrefixList(raw string) []netip.Prefix {
|
|
var prefixes []netip.Prefix
|
|
for _, part := range strings.Split(raw, ",") {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
prefix, err := netip.ParsePrefix(part)
|
|
if err != nil {
|
|
if addr, addrErr := netip.ParseAddr(part); addrErr == nil {
|
|
prefix = netip.PrefixFrom(addr, addr.BitLen())
|
|
} else {
|
|
continue
|
|
}
|
|
}
|
|
prefixes = append(prefixes, prefix.Masked())
|
|
}
|
|
return prefixes
|
|
}
|
|
|
|
func env(key, fallback string) string {
|
|
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func duration(key string, fallback time.Duration) time.Duration {
|
|
value := strings.TrimSpace(os.Getenv(key))
|
|
if value == "" {
|
|
return fallback
|
|
}
|
|
parsed, err := time.ParseDuration(value)
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func intValue(key string, fallback int) int {
|
|
value := strings.TrimSpace(os.Getenv(key))
|
|
if value == "" {
|
|
return fallback
|
|
}
|
|
parsed, err := strconv.Atoi(value)
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func int64Value(key string, fallback int64) int64 {
|
|
value := strings.TrimSpace(os.Getenv(key))
|
|
if value == "" {
|
|
return fallback
|
|
}
|
|
parsed, err := strconv.ParseInt(value, 10, 64)
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func floatValue(key string, fallback float64) float64 {
|
|
value := strings.TrimSpace(os.Getenv(key))
|
|
if value == "" {
|
|
return fallback
|
|
}
|
|
parsed, err := strconv.ParseFloat(value, 64)
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func boolValue(key string, fallback bool) bool {
|
|
value := strings.TrimSpace(os.Getenv(key))
|
|
if value == "" {
|
|
return fallback
|
|
}
|
|
parsed, err := strconv.ParseBool(value)
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
return parsed
|
|
}
|