Files
fire-safety-ymd/internal/config/config.go
T
2026-09-06 00:25:25 +08:00

667 lines
23 KiB
Go

// Package config loads and validates process configuration.
package config
import (
"fmt"
"net/url"
"os"
"strconv"
"strings"
"time"
"unicode"
)
const (
// HTTPAddressEnv is the environment variable used to configure the listen address.
HTTPAddressEnv = "FIRE_SAFETY_HTTP_ADDR"
SuperAgentEnabledEnv = "FIRE_SAFETY_SUPERAGENT_ENABLED"
SuperAgentBaseURLEnv = "FIRE_SAFETY_SUPERAGENT_BASE_URL"
SuperAgentOpenAPIKeyEnv = "FIRE_SAFETY_SUPERAGENT_OPEN_API_KEY"
SuperAgentConnectTimeoutEnv = "FIRE_SAFETY_SUPERAGENT_CONNECT_TIMEOUT"
SuperAgentRecoveryMaxAttemptsEnv = "FIRE_SAFETY_SUPERAGENT_RECOVERY_MAX_ATTEMPTS"
SuperAgentRecoveryInitialBackoffEnv = "FIRE_SAFETY_SUPERAGENT_RECOVERY_INITIAL_BACKOFF"
SuperAgentMaxMessageBytesEnv = "FIRE_SAFETY_SUPERAGENT_MAX_MESSAGE_BYTES"
SuperAgentProbeSubjectIDEnv = "FIRE_SAFETY_SUPERAGENT_PROBE_SUBJECT_ID"
SuperAgentProbeTimeoutEnv = "FIRE_SAFETY_SUPERAGENT_PROBE_TIMEOUT"
ChatEnabledEnv = "FIRE_SAFETY_CHAT_ENABLED"
ChatPageEnabledEnv = "FIRE_SAFETY_CHAT_PAGE_ENABLED"
ChatAuthTokenEnv = "FIRE_SAFETY_CHAT_AUTH_TOKEN"
ChatAllowLegacyShortTokenEnv = "FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN"
ChatSubjectIDEnv = "FIRE_SAFETY_CHAT_SUBJECT_ID"
ChatAllowedOriginsEnv = "FIRE_SAFETY_CHAT_ALLOWED_ORIGINS"
ChatMaxBodyBytesEnv = "FIRE_SAFETY_CHAT_MAX_BODY_BYTES"
ChatRunTimeoutEnv = "FIRE_SAFETY_CHAT_RUN_TIMEOUT"
ChatSessionTTLEnv = "FIRE_SAFETY_CHAT_SESSION_TTL"
ChatMaxSessionsEnv = "FIRE_SAFETY_CHAT_MAX_SESSIONS"
ChatCompatAppIDEnv = "FIRE_SAFETY_CHAT_COMPAT_APP_ID"
MCPEnabledEnv = "FIRE_SAFETY_MCP_ENABLED"
MCPAuthTokenEnv = "FIRE_SAFETY_MCP_AUTH_TOKEN"
MCPScopeModeEnv = "FIRE_SAFETY_MCP_SCOPE_MODE"
MCPAllowedTownsEnv = "FIRE_SAFETY_MCP_ALLOWED_TOWNS"
MCPMaxBodyBytesEnv = "FIRE_SAFETY_MCP_MAX_BODY_BYTES"
MCPToolTimeoutEnv = "FIRE_SAFETY_MCP_TOOL_TIMEOUT"
PostGISEnabledEnv = "FIRE_SAFETY_POSTGIS_ENABLED"
PostGISDSNEnv = "FIRE_SAFETY_POSTGIS_DSN"
PostGISExpectedSRIDEnv = "FIRE_SAFETY_POSTGIS_EXPECTED_SRID"
PostGISConnectTimeoutEnv = "FIRE_SAFETY_POSTGIS_CONNECT_TIMEOUT"
PostGISQueryTimeoutEnv = "FIRE_SAFETY_POSTGIS_QUERY_TIMEOUT"
PostGISMaxConnsEnv = "FIRE_SAFETY_POSTGIS_MAX_CONNS"
defaultHTTPAddress = ":8080"
defaultSuperAgentConnectTimeout = 15 * time.Second
defaultSuperAgentRecoveryMaxAttempts = 5
maximumSuperAgentRecoveryAttempts = 20
defaultSuperAgentRecoveryBackoff = 250 * time.Millisecond
defaultSuperAgentMaxMessageBytes int64 = 64 * 1024
maximumSuperAgentMessageBytes int64 = 16 * 1024 * 1024
defaultSuperAgentProbeSubjectID = "fire-safety-ymd-connectivity-probe"
defaultSuperAgentProbeTimeout = 10 * time.Minute
defaultChatSubjectID = "fire-safety-ymd-chat-test-subject"
defaultChatMaxBodyBytes int64 = 128 * 1024
maximumChatBodyBytes int64 = 1024 * 1024
defaultChatRunTimeout = 10 * time.Minute
maximumChatRunTimeout = 30 * time.Minute
defaultChatSessionTTL = 30 * time.Minute
maximumChatSessionTTL = 24 * time.Hour
defaultChatMaxSessions int64 = 1000
maximumChatMaxSessions int64 = 10_000
maximumChatAllowedOrigins = 50
defaultMCPMaxBodyBytes int64 = 256 * 1024
maximumMCPBodyBytes int64 = 1024 * 1024
defaultMCPToolTimeout = 5 * time.Second
maximumMCPToolTimeout = 30 * time.Second
defaultMCPScopeMode = MCPScopeModeTownAllowlist
defaultPostGISConnectTimeout = 5 * time.Second
defaultPostGISQueryTimeout = 3 * time.Second
maximumPostGISTimeout = 30 * time.Second
defaultPostGISMaxConns int64 = 4
maximumPostGISMaxConns int64 = 20
requiredMCPSpatialSRID = 4326
)
// MCPScopeMode defines the trusted server-side data scope applied to every MCP query.
type MCPScopeMode string
const (
// MCPScopeModeTownAllowlist restricts results to configured town names.
MCPScopeModeTownAllowlist MCPScopeMode = "town_allowlist"
// MCPScopeModeAll permits every town in the MCP's fixed query tables.
MCPScopeModeAll MCPScopeMode = "all"
)
// Config contains immutable application configuration.
type Config struct {
HTTPAddress string
SuperAgent SuperAgentConfig
Chat ChatConfig
MCP MCPConfig
PostGIS PostGISConfig
}
// SuperAgentConfig contains outbound SuperAgent Open API settings.
type SuperAgentConfig struct {
Enabled bool
BaseURL string
OpenAPIKey string
ConnectTimeout time.Duration
RecoveryMaxAttempts int
RecoveryInitialBackoff time.Duration
MaxMessageBytes int64
ProbeSubjectID string
ProbeTimeout time.Duration
}
// ChatConfig contains the protected user-facing chat transport and in-memory
// conversation settings. The static credential is a test-stage boundary, not
// final end-user authentication.
type ChatConfig struct {
Enabled bool
PageEnabled bool
AuthToken string
AllowLegacyShortToken bool
SubjectID string
CompatAppID string
AllowedOrigins []string
MaxBodyBytes int64
RunTimeout time.Duration
SessionTTL time.Duration
MaxSessions int
}
// MCPConfig contains inbound SuperAgent MCP settings.
type MCPConfig struct {
Enabled bool
AuthToken string
ScopeMode MCPScopeMode
AllowedTowns []string
MaxBodyBytes int64
ToolTimeout time.Duration
}
// PostGISConfig contains PostgreSQL/PostGIS connection and query settings.
type PostGISConfig struct {
Enabled bool
DSN string
ExpectedSRID int
ConnectTimeout time.Duration
QueryTimeout time.Duration
MaxConns int32
}
// Load reads configuration from the environment, applies safe defaults, and validates it.
func Load() (Config, error) {
address := strings.TrimSpace(os.Getenv(HTTPAddressEnv))
if address == "" {
address = defaultHTTPAddress
}
enabled, err := parseBool(SuperAgentEnabledEnv, false)
if err != nil {
return Config{}, err
}
connectTimeout, err := parseDuration(SuperAgentConnectTimeoutEnv, defaultSuperAgentConnectTimeout)
if err != nil {
return Config{}, err
}
recoveryMaxAttempts, err := parseInt(SuperAgentRecoveryMaxAttemptsEnv, defaultSuperAgentRecoveryMaxAttempts)
if err != nil {
return Config{}, err
}
recoveryInitialBackoff, err := parseDuration(SuperAgentRecoveryInitialBackoffEnv, defaultSuperAgentRecoveryBackoff)
if err != nil {
return Config{}, err
}
maxMessageBytes, err := parseInt64(SuperAgentMaxMessageBytesEnv, defaultSuperAgentMaxMessageBytes)
if err != nil {
return Config{}, err
}
probeTimeout, err := parseDuration(SuperAgentProbeTimeoutEnv, defaultSuperAgentProbeTimeout)
if err != nil {
return Config{}, err
}
superAgent := SuperAgentConfig{
Enabled: enabled,
BaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv(SuperAgentBaseURLEnv)), "/"),
OpenAPIKey: strings.TrimSpace(os.Getenv(SuperAgentOpenAPIKeyEnv)),
ConnectTimeout: connectTimeout,
RecoveryMaxAttempts: recoveryMaxAttempts,
RecoveryInitialBackoff: recoveryInitialBackoff,
MaxMessageBytes: maxMessageBytes,
ProbeSubjectID: valueOrDefault(SuperAgentProbeSubjectIDEnv, defaultSuperAgentProbeSubjectID),
ProbeTimeout: probeTimeout,
}
if err := validateSuperAgent(superAgent); err != nil {
return Config{}, err
}
chat, err := loadChatConfig()
if err != nil {
return Config{}, err
}
mcp, err := loadMCPConfig()
if err != nil {
return Config{}, err
}
postGIS, err := loadPostGISConfig()
if err != nil {
return Config{}, err
}
if err := validateMCPDependencies(mcp, postGIS, superAgent); err != nil {
return Config{}, err
}
if err := validateChatDependencies(chat, superAgent, mcp); err != nil {
return Config{}, err
}
return Config{
HTTPAddress: address,
SuperAgent: superAgent,
Chat: chat,
MCP: mcp,
PostGIS: postGIS,
}, nil
}
func loadChatConfig() (ChatConfig, error) {
enabled, err := parseBool(ChatEnabledEnv, false)
if err != nil {
return ChatConfig{}, err
}
pageEnabled, err := parseBool(ChatPageEnabledEnv, false)
if err != nil {
return ChatConfig{}, err
}
allowLegacyShortToken, err := parseBool(ChatAllowLegacyShortTokenEnv, false)
if err != nil {
return ChatConfig{}, err
}
maxBodyBytes, err := parseInt64(ChatMaxBodyBytesEnv, defaultChatMaxBodyBytes)
if err != nil {
return ChatConfig{}, err
}
runTimeout, err := parseDuration(ChatRunTimeoutEnv, defaultChatRunTimeout)
if err != nil {
return ChatConfig{}, err
}
sessionTTL, err := parseDuration(ChatSessionTTLEnv, defaultChatSessionTTL)
if err != nil {
return ChatConfig{}, err
}
maxSessions, err := parseInt64(ChatMaxSessionsEnv, defaultChatMaxSessions)
if err != nil {
return ChatConfig{}, err
}
allowedOrigins, err := normalizeChatOrigins(parseList(os.Getenv(ChatAllowedOriginsEnv)))
if err != nil {
return ChatConfig{}, err
}
cfg := ChatConfig{
Enabled: enabled,
PageEnabled: pageEnabled,
AuthToken: os.Getenv(ChatAuthTokenEnv),
AllowLegacyShortToken: allowLegacyShortToken,
SubjectID: valueOrDefault(ChatSubjectIDEnv, defaultChatSubjectID),
CompatAppID: strings.TrimSpace(os.Getenv(ChatCompatAppIDEnv)),
AllowedOrigins: allowedOrigins,
MaxBodyBytes: maxBodyBytes,
RunTimeout: runTimeout,
SessionTTL: sessionTTL,
MaxSessions: int(maxSessions),
}
if cfg.MaxBodyBytes <= 0 || cfg.MaxBodyBytes > maximumChatBodyBytes {
return ChatConfig{}, fmt.Errorf("%s must be between 1 and %d", ChatMaxBodyBytesEnv, maximumChatBodyBytes)
}
if cfg.RunTimeout <= 0 || cfg.RunTimeout > maximumChatRunTimeout {
return ChatConfig{}, fmt.Errorf("%s must be greater than zero and not exceed %s", ChatRunTimeoutEnv, maximumChatRunTimeout)
}
if cfg.SessionTTL <= 0 || cfg.SessionTTL > maximumChatSessionTTL {
return ChatConfig{}, fmt.Errorf("%s must be greater than zero and not exceed %s", ChatSessionTTLEnv, maximumChatSessionTTL)
}
if maxSessions <= 0 || maxSessions > maximumChatMaxSessions {
return ChatConfig{}, fmt.Errorf("%s must be between 1 and %d", ChatMaxSessionsEnv, maximumChatMaxSessions)
}
if strings.TrimSpace(cfg.SubjectID) == "" || len(cfg.SubjectID) > 512 || containsControlCharacter(cfg.SubjectID) {
return ChatConfig{}, fmt.Errorf("%s must be a non-empty value of at most 512 bytes without control characters", ChatSubjectIDEnv)
}
if cfg.CompatAppID != "" && !validChatCompatAppID(cfg.CompatAppID) {
return ChatConfig{}, fmt.Errorf("%s must contain 1 to 128 ASCII letters, digits, underscores, or hyphens", ChatCompatAppIDEnv)
}
if cfg.Enabled && !validChatAuthToken(cfg.AuthToken, cfg.AllowLegacyShortToken) {
if cfg.AllowLegacyShortToken {
return ChatConfig{}, fmt.Errorf("%s must contain 1 to 4096 printable ASCII characters when %s is true", ChatAuthTokenEnv, ChatAllowLegacyShortTokenEnv)
}
return ChatConfig{}, fmt.Errorf("%s must contain 32 to 4096 printable ASCII characters", ChatAuthTokenEnv)
}
return cfg, nil
}
func validChatAuthToken(value string, allowLegacyShortToken bool) bool {
if !validSecretHeaderValue(value) {
return false
}
return allowLegacyShortToken || len(value) >= 32
}
func validChatCompatAppID(value string) bool {
if value == "" || len(value) > 128 {
return false
}
for _, character := range value {
if !(character >= 'a' && character <= 'z') &&
!(character >= 'A' && character <= 'Z') &&
!(character >= '0' && character <= '9') &&
character != '_' && character != '-' {
return false
}
}
return true
}
func normalizeChatOrigins(values []string) ([]string, error) {
if len(values) > maximumChatAllowedOrigins {
return nil, fmt.Errorf("%s must contain no more than %d values", ChatAllowedOriginsEnv, maximumChatAllowedOrigins)
}
normalized := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
parsed, err := url.Parse(value)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") ||
parsed.User != nil || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" ||
(parsed.Path != "" && parsed.Path != "/") || value == "*" {
return nil, fmt.Errorf("%s must contain only exact HTTP(S) origins", ChatAllowedOriginsEnv)
}
origin := parsed.Scheme + "://" + parsed.Host
if _, exists := seen[origin]; exists {
continue
}
seen[origin] = struct{}{}
normalized = append(normalized, origin)
}
return normalized, nil
}
func loadMCPConfig() (MCPConfig, error) {
enabled, err := parseBool(MCPEnabledEnv, false)
if err != nil {
return MCPConfig{}, err
}
scopeMode, err := parseMCPScopeMode(os.Getenv(MCPScopeModeEnv))
if err != nil {
return MCPConfig{}, err
}
maxBodyBytes, err := parseInt64(MCPMaxBodyBytesEnv, defaultMCPMaxBodyBytes)
if err != nil {
return MCPConfig{}, err
}
toolTimeout, err := parseDuration(MCPToolTimeoutEnv, defaultMCPToolTimeout)
if err != nil {
return MCPConfig{}, err
}
cfg := MCPConfig{
Enabled: enabled,
AuthToken: strings.TrimSpace(os.Getenv(MCPAuthTokenEnv)),
ScopeMode: scopeMode,
AllowedTowns: parseList(os.Getenv(MCPAllowedTownsEnv)),
MaxBodyBytes: maxBodyBytes,
ToolTimeout: toolTimeout,
}
if cfg.MaxBodyBytes <= 0 || cfg.MaxBodyBytes > maximumMCPBodyBytes {
return MCPConfig{}, fmt.Errorf("%s must be between 1 and %d", MCPMaxBodyBytesEnv, maximumMCPBodyBytes)
}
if cfg.ToolTimeout <= 0 || cfg.ToolTimeout > maximumMCPToolTimeout {
return MCPConfig{}, fmt.Errorf("%s must be greater than zero and not exceed %s", MCPToolTimeoutEnv, maximumMCPToolTimeout)
}
if !cfg.Enabled {
return cfg, nil
}
if len(cfg.AuthToken) < 32 || !validSecretHeaderValue(cfg.AuthToken) {
return MCPConfig{}, fmt.Errorf("%s must contain at least 32 printable ASCII characters", MCPAuthTokenEnv)
}
switch cfg.ScopeMode {
case MCPScopeModeAll:
if len(cfg.AllowedTowns) > 0 {
return MCPConfig{}, fmt.Errorf("%s must be empty when %s is %s", MCPAllowedTownsEnv, MCPScopeModeEnv, MCPScopeModeAll)
}
case MCPScopeModeTownAllowlist:
if len(cfg.AllowedTowns) == 0 {
return MCPConfig{}, fmt.Errorf("%s is required when %s is %s", MCPAllowedTownsEnv, MCPScopeModeEnv, MCPScopeModeTownAllowlist)
}
if len(cfg.AllowedTowns) > 100 {
return MCPConfig{}, fmt.Errorf("%s must contain no more than 100 values", MCPAllowedTownsEnv)
}
for _, town := range cfg.AllowedTowns {
if len([]rune(town)) > 100 || containsControlCharacter(town) {
return MCPConfig{}, fmt.Errorf("%s contains an invalid town value", MCPAllowedTownsEnv)
}
}
}
return cfg, nil
}
func parseMCPScopeMode(raw string) (MCPScopeMode, error) {
value := MCPScopeMode(strings.ToLower(strings.TrimSpace(raw)))
if value == "" {
return defaultMCPScopeMode, nil
}
switch value {
case MCPScopeModeTownAllowlist, MCPScopeModeAll:
return value, nil
default:
return "", fmt.Errorf("%s must be %q or %q", MCPScopeModeEnv, MCPScopeModeTownAllowlist, MCPScopeModeAll)
}
}
func containsControlCharacter(value string) bool {
for _, character := range value {
if unicode.IsControl(character) {
return true
}
}
return false
}
func loadPostGISConfig() (PostGISConfig, error) {
enabled, err := parseBool(PostGISEnabledEnv, false)
if err != nil {
return PostGISConfig{}, err
}
expectedSRID, err := parseInt(PostGISExpectedSRIDEnv, 0)
if err != nil {
return PostGISConfig{}, err
}
connectTimeout, err := parseDuration(PostGISConnectTimeoutEnv, defaultPostGISConnectTimeout)
if err != nil {
return PostGISConfig{}, err
}
queryTimeout, err := parseDuration(PostGISQueryTimeoutEnv, defaultPostGISQueryTimeout)
if err != nil {
return PostGISConfig{}, err
}
maxConns, err := parseInt64(PostGISMaxConnsEnv, defaultPostGISMaxConns)
if err != nil {
return PostGISConfig{}, err
}
cfg := PostGISConfig{
Enabled: enabled,
DSN: strings.TrimSpace(os.Getenv(PostGISDSNEnv)),
ExpectedSRID: expectedSRID,
ConnectTimeout: connectTimeout,
QueryTimeout: queryTimeout,
MaxConns: int32(maxConns),
}
if cfg.ConnectTimeout <= 0 || cfg.ConnectTimeout > maximumPostGISTimeout {
return PostGISConfig{}, fmt.Errorf("%s must be greater than zero and not exceed %s", PostGISConnectTimeoutEnv, maximumPostGISTimeout)
}
if cfg.QueryTimeout <= 0 || cfg.QueryTimeout > maximumPostGISTimeout {
return PostGISConfig{}, fmt.Errorf("%s must be greater than zero and not exceed %s", PostGISQueryTimeoutEnv, maximumPostGISTimeout)
}
if maxConns <= 0 || maxConns > maximumPostGISMaxConns {
return PostGISConfig{}, fmt.Errorf("%s must be between 1 and %d", PostGISMaxConnsEnv, maximumPostGISMaxConns)
}
if cfg.ExpectedSRID < 0 || cfg.ExpectedSRID > 999999 {
return PostGISConfig{}, fmt.Errorf("%s must be between 0 and 999999", PostGISExpectedSRIDEnv)
}
if !cfg.Enabled {
return cfg, nil
}
if cfg.DSN == "" {
return PostGISConfig{}, fmt.Errorf("%s is required when %s is true", PostGISDSNEnv, PostGISEnabledEnv)
}
if !validPostGISDSN(cfg.DSN) {
return PostGISConfig{}, fmt.Errorf("%s must be an absolute postgresql:// or postgres:// URL", PostGISDSNEnv)
}
return cfg, nil
}
func validateMCPDependencies(mcp MCPConfig, postGIS PostGISConfig, superAgent SuperAgentConfig) error {
if !mcp.Enabled {
return nil
}
if !postGIS.Enabled {
return fmt.Errorf("%s must be true when %s is true", PostGISEnabledEnv, MCPEnabledEnv)
}
if postGIS.ExpectedSRID != requiredMCPSpatialSRID {
return fmt.Errorf("%s must be explicitly set to %d when %s is true", PostGISExpectedSRIDEnv, requiredMCPSpatialSRID, MCPEnabledEnv)
}
if superAgent.OpenAPIKey != "" && mcp.AuthToken == superAgent.OpenAPIKey {
return fmt.Errorf("%s must not reuse %s", MCPAuthTokenEnv, SuperAgentOpenAPIKeyEnv)
}
return nil
}
func validateChatDependencies(chat ChatConfig, superAgent SuperAgentConfig, mcp MCPConfig) error {
if chat.PageEnabled {
if !chat.Enabled {
return fmt.Errorf("%s must be true when %s is true", ChatEnabledEnv, ChatPageEnabledEnv)
}
if chat.CompatAppID == "" {
return fmt.Errorf("%s is required when %s is true", ChatCompatAppIDEnv, ChatPageEnabledEnv)
}
if len(chat.AllowedOrigins) == 0 {
return fmt.Errorf("%s must contain at least one exact HTTP(S) origin when %s is true", ChatAllowedOriginsEnv, ChatPageEnabledEnv)
}
}
if !chat.Enabled {
return nil
}
if !superAgent.Enabled {
return fmt.Errorf("%s must be true when %s is true", SuperAgentEnabledEnv, ChatEnabledEnv)
}
if chat.AuthToken == superAgent.OpenAPIKey {
return fmt.Errorf("%s must not reuse %s", ChatAuthTokenEnv, SuperAgentOpenAPIKeyEnv)
}
if mcp.AuthToken != "" && chat.AuthToken == mcp.AuthToken {
return fmt.Errorf("%s must not reuse %s", ChatAuthTokenEnv, MCPAuthTokenEnv)
}
return nil
}
func validateSuperAgent(cfg SuperAgentConfig) error {
if cfg.ConnectTimeout <= 0 {
return fmt.Errorf("%s must be greater than zero", SuperAgentConnectTimeoutEnv)
}
if cfg.RecoveryMaxAttempts < 0 {
return fmt.Errorf("%s must not be negative", SuperAgentRecoveryMaxAttemptsEnv)
}
if cfg.RecoveryMaxAttempts > maximumSuperAgentRecoveryAttempts {
return fmt.Errorf("%s must not exceed %d", SuperAgentRecoveryMaxAttemptsEnv, maximumSuperAgentRecoveryAttempts)
}
if cfg.RecoveryInitialBackoff <= 0 {
return fmt.Errorf("%s must be greater than zero", SuperAgentRecoveryInitialBackoffEnv)
}
if cfg.MaxMessageBytes <= 0 {
return fmt.Errorf("%s must be greater than zero", SuperAgentMaxMessageBytesEnv)
}
if cfg.MaxMessageBytes > maximumSuperAgentMessageBytes {
return fmt.Errorf("%s must not exceed %d", SuperAgentMaxMessageBytesEnv, maximumSuperAgentMessageBytes)
}
if cfg.ProbeTimeout <= 0 {
return fmt.Errorf("%s must be greater than zero", SuperAgentProbeTimeoutEnv)
}
if !cfg.Enabled {
return nil
}
if cfg.BaseURL == "" {
return fmt.Errorf("%s is required when %s is true", SuperAgentBaseURLEnv, SuperAgentEnabledEnv)
}
if cfg.OpenAPIKey == "" {
return fmt.Errorf("%s is required when %s is true", SuperAgentOpenAPIKeyEnv, SuperAgentEnabledEnv)
}
if !validSecretHeaderValue(cfg.OpenAPIKey) {
return fmt.Errorf("%s must be a valid header token", SuperAgentOpenAPIKeyEnv)
}
if strings.TrimSpace(cfg.ProbeSubjectID) == "" || len(cfg.ProbeSubjectID) > 512 {
return fmt.Errorf("%s is required when %s is true", SuperAgentProbeSubjectIDEnv, SuperAgentEnabledEnv)
}
parsed, err := url.Parse(cfg.BaseURL)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return fmt.Errorf("%s must be an absolute HTTP(S) URL", SuperAgentBaseURLEnv)
}
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return fmt.Errorf("%s must not include user information, query, or fragment", SuperAgentBaseURLEnv)
}
return nil
}
func validSecretHeaderValue(value string) bool {
if value == "" || len(value) > 4096 {
return false
}
for _, character := range value {
if character <= 0x20 || character >= 0x7f {
return false
}
}
return true
}
func validPostGISDSN(value string) bool {
parsed, err := url.Parse(value)
if err != nil || parsed.Host == "" {
return false
}
return parsed.Scheme == "postgres" || parsed.Scheme == "postgresql"
}
func parseList(raw string) []string {
seen := make(map[string]struct{})
values := make([]string, 0)
for _, item := range strings.Split(raw, ",") {
value := strings.TrimSpace(item)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
values = append(values, value)
}
return values
}
func parseBool(key string, fallback bool) (bool, error) {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return fallback, nil
}
value, err := strconv.ParseBool(raw)
if err != nil {
return false, fmt.Errorf("%s must be a boolean", key)
}
return value, nil
}
func parseDuration(key string, fallback time.Duration) (time.Duration, error) {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return fallback, nil
}
value, err := time.ParseDuration(raw)
if err != nil {
return 0, fmt.Errorf("%s must be a valid duration", key)
}
return value, nil
}
func parseInt(key string, fallback int) (int, error) {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return fallback, nil
}
value, err := strconv.Atoi(raw)
if err != nil {
return 0, fmt.Errorf("%s must be an integer", key)
}
return value, nil
}
func parseInt64(key string, fallback int64) (int64, error) {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return fallback, nil
}
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return 0, fmt.Errorf("%s must be an integer", key)
}
return value, nil
}
func valueOrDefault(key, fallback string) string {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
return value
}