package config import ( "encoding/hex" "fmt" "net/url" "strings" "github.com/spf13/viper" ) const ( MinCredentialLeaseTTLSeconds = 5 MaxCredentialLeaseTTLSeconds = 300 MinCredentialCacheSize = 1 MaxCredentialCacheSize = 4096 ) func validateNodeCredentialPlane(cfg *NodeConfig) error { if cfg == nil || !cfg.CredentialPlane.Enabled { return nil } if !cfg.Transport.TLS.Enabled { return fmt.Errorf("credential_plane requires transport.tls") } if err := cfg.Transport.TLS.ValidateClient("edge"); err != nil { return fmt.Errorf("transport.tls: %w", err) } if strings.TrimSpace(cfg.CredentialPlane.RecipientKeyID) == "" || strings.TrimSpace(cfg.CredentialPlane.RecipientPrivateKey) == "" { return fmt.Errorf("credential_plane recipient_key_id and recipient_private_key are required") } if strings.TrimSpace(cfg.CredentialPlane.IssuerKeyID) == "" || strings.TrimSpace(cfg.CredentialPlane.IssuerPublicKey) == "" { return fmt.Errorf("credential_plane issuer_key_id and issuer_public_key are required") } if cfg.CredentialPlane.ReplayCacheSize < MinCredentialCacheSize || cfg.CredentialPlane.ReplayCacheSize > MaxCredentialCacheSize { return fmt.Errorf("credential_plane.replay_cache_size must be between %d and %d", MinCredentialCacheSize, MaxCredentialCacheSize) } return nil } func validateEdgeCredentialPlane(cfg *EdgeConfig) error { if cfg == nil || !cfg.CredentialPlane.Enabled { return nil } if cfg.CredentialPlane.LeaseTTLSeconds < MinCredentialLeaseTTLSeconds || cfg.CredentialPlane.LeaseTTLSeconds > MaxCredentialLeaseTTLSeconds { return fmt.Errorf("credential_plane.lease_ttl_seconds must be between %d and %d", MinCredentialLeaseTTLSeconds, MaxCredentialLeaseTTLSeconds) } if cfg.CredentialPlane.LeaseCacheSize < MinCredentialCacheSize || cfg.CredentialPlane.LeaseCacheSize > MaxCredentialCacheSize { return fmt.Errorf("credential_plane.lease_cache_size must be between %d and %d", MinCredentialCacheSize, MaxCredentialCacheSize) } if !cfg.TLS.Enabled { return fmt.Errorf("credential_plane requires edge-node tls") } if err := cfg.TLS.ValidateServer("node"); err != nil { return fmt.Errorf("tls: %w", err) } if !cfg.ControlPlane.Enabled || strings.TrimSpace(cfg.ControlPlane.WireAddr) == "" || !cfg.ControlPlane.TLS.Enabled { return fmt.Errorf("credential_plane requires an enabled TLS control_plane connector") } if err := cfg.ControlPlane.TLS.ValidateClient("control-plane"); err != nil { return fmt.Errorf("control_plane.tls: %w", err) } if cfg.OpenAI.Enabled { if !cfg.OpenAI.TLS.Enabled { return fmt.Errorf("credential_plane requires openai.tls when OpenAI ingress is enabled") } if err := cfg.OpenAI.TLS.ValidateHTTPServer(); err != nil { return fmt.Errorf("openai.tls: %w", err) } } if len(cfg.OpenAI.PrincipalTokens) != 0 || strings.TrimSpace(cfg.OpenAI.BearerToken) != "" || cfg.OpenAI.ProviderAuth.Enabled { return fmt.Errorf("credential_plane cannot be combined with legacy principal or provider auth sources") } if source := managedRawProviderCredentialSource(cfg); source != "" { return fmt.Errorf("credential_plane cannot be combined with raw provider credential source %s", source) } return nil } // managedRawProviderCredentialSource returns a safe config path for the first // static provider credential source that would compete with a managed lease. // It never includes a configured header or environment value. func managedRawProviderCredentialSource(cfg *EdgeConfig) string { for nodeIndex := range cfg.Nodes { node := &cfg.Nodes[nodeIndex] for providerIndex := range node.Providers { provider := &node.Providers[providerIndex] profile := provider.RuntimeProfile effectiveAuthHeader := "" if profile != nil { effectiveAuthHeader = strings.TrimSpace(profile.Auth.Header) } for header := range provider.Headers { if isCredentialHeaderName(header) || (effectiveAuthHeader != "" && strings.EqualFold(strings.TrimSpace(header), effectiveAuthHeader)) { return fmt.Sprintf("nodes[%d].providers[%d].headers[%q]", nodeIndex, providerIndex, header) } } for envIndex, entry := range provider.Env { if isCredentialEnvironmentEntry(entry) { return fmt.Sprintf("nodes[%d].providers[%d].env[%d]", nodeIndex, providerIndex, envIndex) } } for argIndex, arg := range provider.Args { if isCredentialArgument(arg) { return fmt.Sprintf("nodes[%d].providers[%d].args[%d]", nodeIndex, providerIndex, argIndex) } } if urlContainsUserInfo(provider.Endpoint) { return fmt.Sprintf("nodes[%d].providers[%d].endpoint", nodeIndex, providerIndex) } if urlContainsUserInfo(provider.BaseURL) { return fmt.Sprintf("nodes[%d].providers[%d].base_url", nodeIndex, providerIndex) } if profile != nil { if urlContainsUserInfo(profile.BaseURL) { return fmt.Sprintf("nodes[%d].providers[%d].profile[%q].base_url", nodeIndex, providerIndex, profile.ID) } for operation, rawURL := range profile.Operations { if urlContainsUserInfo(rawURL) { return fmt.Sprintf("nodes[%d].providers[%d].profile[%q].operations[%q]", nodeIndex, providerIndex, profile.ID, operation) } } } } effectiveProfileAuthHeaders := collectEffectiveProfileAuthHeaders(node.Providers) for header := range node.Adapters.OpenAICompat.Headers { if isCredentialHeaderName(header) || matchesEffectiveProfileAuthHeader(header, effectiveProfileAuthHeaders) { return fmt.Sprintf("nodes[%d].adapters.openai_compat.headers[%q]", nodeIndex, header) } } for instanceIndex := range node.Adapters.OpenAICompatInstances { instance := &node.Adapters.OpenAICompatInstances[instanceIndex] for header := range instance.Headers { if isCredentialHeaderName(header) || matchesEffectiveProfileAuthHeader(header, effectiveProfileAuthHeaders) { return fmt.Sprintf("nodes[%d].adapters.openai_compat_instances[%d].headers[%q]", nodeIndex, instanceIndex, header) } } if urlContainsUserInfo(instance.Endpoint) { return fmt.Sprintf("nodes[%d].adapters.openai_compat_instances[%d].endpoint", nodeIndex, instanceIndex) } } } return "" } func collectEffectiveProfileAuthHeaders(providers []NodeProviderConf) map[string]struct{} { headers := make(map[string]struct{}) for i := range providers { profile := providers[i].RuntimeProfile if profile != nil { h := strings.TrimSpace(profile.Auth.Header) if h != "" { headers[strings.ToLower(h)] = struct{}{} } } } return headers } func matchesEffectiveProfileAuthHeader(header string, effective map[string]struct{}) bool { if len(effective) == 0 { return false } _, ok := effective[strings.ToLower(strings.TrimSpace(header))] return ok } func isCredentialHeaderName(name string) bool { normalized := strings.ToLower(strings.TrimSpace(name)) switch normalized { case "authorization", "proxy-authorization", "x-api-key", "api-key", "x-auth", "x-auth-token": return true default: return strings.Contains(normalized, "credential") || strings.Contains(normalized, "secret") } } func isCredentialEnvironmentEntry(entry string) bool { name := entry if index := strings.IndexByte(name, '='); index >= 0 { name = name[:index] } return isCredentialName(name) } func isCredentialArgument(arg string) bool { name := strings.TrimLeft(strings.TrimSpace(arg), "-") if index := strings.IndexByte(name, '='); index >= 0 { name = name[:index] } return isCredentialName(name) } func isCredentialName(name string) bool { segments := strings.FieldsFunc(strings.ToUpper(strings.TrimSpace(name)), func(r rune) bool { return (r < 'A' || r > 'Z') && (r < '0' || r > '9') }) for index, segment := range segments { switch segment { case "APIKEY", "SECRET", "CREDENTIAL", "CREDENTIALS", "PASSWORD", "AUTHORIZATION": return true } if segment == "TOKEN" && index == len(segments)-1 { return true } if segment == "API" && index+1 < len(segments) && segments[index+1] == "KEY" { return true } } return false } func urlContainsUserInfo(raw string) bool { parsed, err := url.Parse(strings.TrimSpace(raw)) return err == nil && parsed.User != nil } // validateOpenAIRoutes rejects duplicate and empty model ids in the route catalog. func validateOpenAIRoutes(routes []OpenAIRouteEntry) error { seen := make(map[string]struct{}, len(routes)) for i, r := range routes { model := strings.TrimSpace(r.Model) if model == "" { return fmt.Errorf("openai.model_routes[%d]: model must not be empty", i) } if _, dup := seen[model]; dup { return fmt.Errorf("openai.model_routes: duplicate model %q", model) } seen[model] = struct{}{} if r.MaxQueue < 0 { return fmt.Errorf("openai.model_routes[%d].max_queue must be non-negative", i) } if r.QueueTimeoutMS < 0 { return fmt.Errorf("openai.model_routes[%d].queue_timeout_ms must be non-negative", i) } } return nil } // validateOpenAIAttributionBindings requires every enabled direct OpenAI route // to have a stable provider identity. A route-level provider_id overrides the // top-level fallback, but the fallback itself remains independently // dispatchable and therefore also requires a binding. Provider catalog // cross-reference validation is deliberately not applied to legacy direct // adapter routes. func validateOpenAIAttributionBindings(openAI EdgeOpenAIConf) error { if !openAI.Enabled { return nil } fallbackProviderID := strings.TrimSpace(openAI.ProviderID) if fallbackProviderID == "" { return fmt.Errorf("openai.provider_id must not be empty when OpenAI is enabled") } for i, route := range openAI.ModelRoutes { providerID := strings.TrimSpace(route.ProviderID) if providerID == "" { providerID = fallbackProviderID } if providerID == "" { return fmt.Errorf("openai.model_routes[%d].provider_id must not be empty without openai.provider_id fallback", i) } } return nil } // validateOpenAIPrincipalTokens validates the raw-token-free bearer token // operation mapping. Each entry must resolve to a unique, 64-char lowercase // hex SHA-256 hash and a non-empty principal_ref; token_ref must be unique. func validateOpenAIPrincipalTokens(tokens []OpenAIPrincipalTokenConf) error { seenTokenRef := make(map[string]struct{}, len(tokens)) seenHash := make(map[string]struct{}, len(tokens)) for i, t := range tokens { ref := strings.TrimSpace(t.TokenRef) if ref == "" { return fmt.Errorf("openai.principal_tokens[%d]: token_ref must not be empty", i) } if _, dup := seenTokenRef[ref]; dup { return fmt.Errorf("openai.principal_tokens: duplicate token_ref %q", ref) } seenTokenRef[ref] = struct{}{} hash := strings.ToLower(strings.TrimSpace(t.TokenHashSHA256)) if len(hash) != 64 { return fmt.Errorf("openai.principal_tokens[%q]: token_hash_sha256 must be a 64-character hex SHA-256 hash", ref) } if _, err := hex.DecodeString(hash); err != nil { return fmt.Errorf("openai.principal_tokens[%q]: token_hash_sha256 must be hex-encoded: %w", ref, err) } if _, dup := seenHash[hash]; dup { return fmt.Errorf("openai.principal_tokens: duplicate token_hash_sha256 for token_ref %q", ref) } seenHash[hash] = struct{}{} if strings.TrimSpace(t.PrincipalRef) == "" { return fmt.Errorf("openai.principal_tokens[%q]: principal_ref must not be empty", ref) } } return nil } func normalizeOpenAIProviderAuth(v *viper.Viper, auth *EdgeOpenAIProviderAuthConf) error { if !auth.Enabled { return nil } if v.InConfig("openai.provider_auth.from_header") { auth.FromHeader = strings.TrimSpace(auth.FromHeader) if auth.FromHeader == "" { return fmt.Errorf("openai.provider_auth.from_header must not be empty when provider_auth is enabled") } } else { auth.FromHeader = "X-IOP-Provider-Authorization" } if v.InConfig("openai.provider_auth.target_header") { auth.TargetHeader = strings.TrimSpace(auth.TargetHeader) if auth.TargetHeader == "" { return fmt.Errorf("openai.provider_auth.target_header must not be empty when provider_auth is enabled") } } else { auth.TargetHeader = "Authorization" } auth.Scheme = strings.TrimSpace(auth.Scheme) if auth.Scheme == "" { auth.Scheme = "Bearer" } if !v.InConfig("openai.provider_auth.required") { auth.Required = true } return nil } // validateProviderLongContextBudget enforces the long-context admission budget: // every provider referenced by a model group with a positive context window must // have total_context_tokens >= context_window_tokens * long_context_capacity. // Providers with long_context_capacity == 0 declare no long-slot budget and are // therefore unconstrained by this check. func validateProviderLongContextBudget(m ModelCatalogEntry, providerByID map[string]NodeProviderConf) error { if m.ContextWindowTokens <= 0 { return nil } for pid := range m.Providers { p := strings.TrimSpace(pid) prov, ok := providerByID[p] if !ok { continue } if prov.LongContextCapacity <= 0 { continue } required := m.ContextWindowTokens * prov.LongContextCapacity if prov.TotalContextTokens < required { return fmt.Errorf("providers[%q].total_context_tokens (%d) must be >= context_window_tokens (%d) * long_context_capacity (%d) = %d", p, prov.TotalContextTokens, m.ContextWindowTokens, prov.LongContextCapacity, required) } } return nil } // validateModelTokenCounter keeps token counting local and Chat-only. The // deterministic mode takes no tuning parameter; estimate mode uses an explicit // positive tokens-per-1k-characters rate bounded to one token per character. func validateModelTokenCounter(m ModelCatalogEntry, providerByID map[string]NodeProviderConf) error { if m.TokenCounter == nil { return nil } counter := *m.TokenCounter switch counter.Mode { case TokenCounterDeterministic: if counter.Per1kInput != 0 { return fmt.Errorf("models[%q].token_counter.per_1k_input must be 0 in deterministic mode", m.ID) } case TokenCounterEstimate: if counter.Per1kInput < 1 || counter.Per1kInput > 1000 { return fmt.Errorf("models[%q].token_counter.per_1k_input must be between 1 and 1000 in estimate mode", m.ID) } default: return fmt.Errorf("models[%q].token_counter.mode must be %q or %q, got %q", m.ID, TokenCounterDeterministic, TokenCounterEstimate, counter.Mode) } for providerID := range m.Providers { provider, ok := providerByID[strings.TrimSpace(providerID)] if !ok || provider.RuntimeProfile == nil { continue } driver := provider.RuntimeProfile.Driver if driver != ProtocolDriverOpenAIChat { return fmt.Errorf("models[%q].token_counter is Chat-only; provider %q selects profile %q with driver %q", m.ID, providerID, provider.RuntimeProfile.ID, driver) } } return nil } func checkUniqueNames(field string, name func(int) string, n int) error { seen := make(map[string]struct{}, n) for i := 0; i < n; i++ { k := name(i) if k == "" { return fmt.Errorf("%s[%d]: name must not be empty", field, i) } if _, dup := seen[k]; dup { return fmt.Errorf("%s: duplicate name %q", field, k) } seen[k] = struct{}{} } return nil } func validateProviderQueueConfig(field string, capacity, maxQueue, queueTimeoutMS, requestTimeoutMS int) error { if capacity < 0 { return fmt.Errorf("%s.capacity must be non-negative", field) } if maxQueue < 0 { return fmt.Errorf("%s.max_queue must be non-negative", field) } if queueTimeoutMS < 0 { return fmt.Errorf("%s.queue_timeout_ms must be non-negative", field) } if requestTimeoutMS < 0 { return fmt.Errorf("%s.request_timeout_ms must be non-negative", field) } return nil } // CheckProviderLegacyConflict validates that when a provider-first resource // declares an ID matching an enabled legacy adapter instance name, the // provider's execution fields match the legacy adapter's fields. A mismatch // produces a validation error to prevent silent precedence between the two // config paths. func CheckProviderLegacyConflict(nodeIdx int, nodeName string, node *NodeDefinition) error { // Build a map of enabled adapter instance names to their configs per type. legacyByKey := make(map[string]*legacyAdapterRef) for _, inst := range node.Adapters.OllamaInstances { if inst.Enabled { legacyByKey[inst.Name] = &legacyAdapterRef{ Type: "ollama", BaseURL: inst.BaseURL, ContextSize: inst.ContextSize, Capacity: inst.Capacity, } } } for _, inst := range node.Adapters.VllmInstances { if inst.Enabled { legacyByKey[inst.Name] = &legacyAdapterRef{ Type: "vllm", Endpoint: inst.Endpoint, Capacity: inst.Capacity, } } } for _, inst := range node.Adapters.OpenAICompatInstances { if inst.Enabled { legacyByKey[inst.Name] = &legacyAdapterRef{ Type: "openai_compat", Provider: inst.Provider, Endpoint: inst.Endpoint, Headers: inst.Headers, Capacity: inst.Capacity, MaxQueue: inst.MaxQueue, QueueTimeoutMS: inst.QueueTimeoutMS, } } } // CLI legacy: enabled cli adapter with its profiles. if node.Adapters.CLI.Enabled { for name, profile := range node.Adapters.CLI.Profiles { legacyByKey[name] = &legacyAdapterRef{ Type: "cli", Command: profile.Command, Args: profile.Args, } } } for j, p := range node.Providers { legacy, exists := legacyByKey[p.ID] if !exists { continue } normType := NormalizeProviderType(p.Type) switch normType { case "ollama": if legacy.Type != "ollama" { continue } if p.BaseURL != "" && p.BaseURL != legacy.BaseURL { return fmt.Errorf("nodes[%d].providers[%d] %q conflicts with adapters.ollama instance %q: base_url mismatch (provider=%q, legacy=%q)", nodeIdx, j, p.ID, p.ID, p.BaseURL, legacy.BaseURL) } if p.ContextSize != 0 && p.ContextSize != legacy.ContextSize { return fmt.Errorf("nodes[%d].providers[%d] %q conflicts with adapters.ollama instance %q: context_size mismatch (provider=%d, legacy=%d)", nodeIdx, j, p.ID, p.ID, p.ContextSize, legacy.ContextSize) } if p.Capacity != legacy.Capacity { return fmt.Errorf("nodes[%d].providers[%d] %q conflicts with adapters.ollama instance %q: capacity mismatch (provider=%d, legacy=%d)", nodeIdx, j, p.ID, p.ID, p.Capacity, legacy.Capacity) } case "vllm", "openai_compat": if legacy.Type != "vllm" && legacy.Type != "openai_compat" { continue } if p.Endpoint != "" && p.Endpoint != legacy.Endpoint { return fmt.Errorf("nodes[%d].providers[%d] %q conflicts with adapters.%s instance %q: endpoint mismatch (provider=%q, legacy=%q)", nodeIdx, j, p.ID, legacy.Type, p.ID, p.Endpoint, legacy.Endpoint) } if p.Capacity != legacy.Capacity { return fmt.Errorf("nodes[%d].providers[%d] %q conflicts with adapters.%s instance %q: capacity mismatch (provider=%d, legacy=%d)", nodeIdx, j, p.ID, legacy.Type, p.ID, p.Capacity, legacy.Capacity) } case "cli": if legacy.Type != "cli" { continue } if p.Command != "" && p.Command != legacy.Command { return fmt.Errorf("nodes[%d].providers[%d] %q conflicts with adapters.cli profile %q: command mismatch (provider=%q, legacy=%q)", nodeIdx, j, p.ID, p.ID, p.Command, legacy.Command) } } } return nil } type legacyAdapterRef struct { Type string Endpoint string BaseURL string Provider string Headers map[string]string ContextSize int Capacity int MaxQueue int QueueTimeoutMS int Command string Args []string }