From 57fe1c431b4024dec957b443a91c958c8df5c6b4 Mon Sep 17 00:00:00 2001 From: Himanshu Date: Sun, 17 May 2026 13:46:43 +0530 Subject: [PATCH] feat(ai): add proxy and custom CA support for enterprise environments Signed-off-by: Himanshu --- README.md | 34 ++++++ internal/ai/anthropic.go | 9 +- internal/ai/http_client.go | 90 ++++++++++++++++ internal/ai/http_client_test.go | 178 ++++++++++++++++++++++++++++++++ internal/ai/ollama.go | 9 +- internal/ai/openai.go | 9 +- internal/ai/provider.go | 9 ++ internal/ai/provider_test.go | 2 +- internal/config/config.go | 28 +++++ 9 files changed, 361 insertions(+), 7 deletions(-) create mode 100644 internal/ai/http_client.go create mode 100644 internal/ai/http_client_test.go diff --git a/README.md b/README.md index 2de48d4..e12628f 100644 --- a/README.md +++ b/README.md @@ -530,6 +530,40 @@ kubectl -n kerno-system exec ds/kerno -- kerno doctor --ai --- +### Enterprise Deployments + +Kerno supports enterprise proxy environments and custom CA certificates for AI providers. + +Example configuration: + +```yaml +ai: + proxy: http://corp-proxy.internal:8080 + ca_cert_file: /etc/kerno/corp-ca.crt + insecure_skip_verify: false + timeout: 30s +``` + +#### Proxy Support + +If `ai.proxy` is not configured, Kerno automatically honors: + +- `HTTPS_PROXY` +- `HTTP_PROXY` +- `NO_PROXY` + +#### Custom CA Certificates + +Use `ai.ca_cert_file` to append additional CA certificates without replacing the system trust store. + +This is commonly required in enterprise environments using TLS-inspecting corporate proxies. + +#### TLS Verification Errors + +Kerno returns actionable TLS verification errors including hostname and certificate verification details to simplify debugging enterprise proxy and CA configurations. + +--- + ## Configuration Kerno works with **zero configuration**. For custom setups, mount a `config.yaml` or use `KERNO_*` env vars: diff --git a/internal/ai/anthropic.go b/internal/ai/anthropic.go index e442504..176ee13 100644 --- a/internal/ai/anthropic.go +++ b/internal/ai/anthropic.go @@ -54,7 +54,12 @@ func NewAnthropicProvider(cfg ProviderConfig) *AnthropicProvider { model: model, maxTokens: maxTokens, temperature: temp, - client: &http.Client{}, + client: NewHTTPClient( + cfg.Timeout, + cfg.Proxy, + cfg.CACertFile, + cfg.InsecureSkipVerify, + ), } } @@ -96,7 +101,7 @@ func (p *AnthropicProvider) Complete(ctx context.Context, req CompletionRequest) resp, err := p.client.Do(httpReq) if err != nil { - return nil, fmt.Errorf("anthropic API call failed: %w", err) + return nil, fmt.Errorf("anthropic request failed: %w", formatHTTPError(err)) } defer resp.Body.Close() diff --git a/internal/ai/http_client.go b/internal/ai/http_client.go new file mode 100644 index 0000000..f0421e3 --- /dev/null +++ b/internal/ai/http_client.go @@ -0,0 +1,90 @@ +// Copyright 2026 Optiqor contributors +// SPDX-License-Identifier: Apache-2.0 + +package ai + +import ( + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "time" +) + +func NewHTTPClient( + timeout time.Duration, + proxy string, + caCertFile string, + insecureSkipVerify bool, +) *http.Client { + //nolint:gosec // InsecureSkipVerify is intentionally configurable for local/dev and air-gapped environments. + tlsConfig := &tls.Config{ + InsecureSkipVerify: insecureSkipVerify, + } + + if caCertFile != "" { + certPool, err := x509.SystemCertPool() + if err != nil || certPool == nil { + certPool = x509.NewCertPool() + } + //nolint:gosec // CA certificate path is intentionally user-configurable via trusted config. + caCert, err := os.ReadFile(caCertFile) + if err == nil { + ok := certPool.AppendCertsFromPEM(caCert) + if ok { + tlsConfig.RootCAs = certPool + } + } + } + + transport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + TLSClientConfig: tlsConfig, + } + + if proxy != "" { + proxyURL, err := url.Parse(proxy) + if err == nil { + transport.Proxy = http.ProxyURL(proxyURL) + } + } + + return &http.Client{ + Timeout: timeout, + Transport: transport, + } +} + +func formatHTTPError(err error) error { + if err == nil { + return nil + } + + var unknownAuthErr x509.UnknownAuthorityError + if errors.As(err, &unknownAuthErr) { + return fmt.Errorf( + "TLS verification failed for certificate subject %q: %w. "+ + "If your environment uses a corporate MITM proxy, configure ai.ca_cert_file", + unknownAuthErr.Cert.Subject, + err, + ) + } + + var hostnameErr x509.HostnameError + if errors.As(err, &hostnameErr) { + return fmt.Errorf( + "TLS hostname verification failed for host %q: %w", + hostnameErr.Host, + err, + ) + } + + return fmt.Errorf( + "HTTP request failed: %w. "+ + "If using a corporate proxy or custom CA, configure ai.ca_cert_file or ai.proxy", + err, + ) +} diff --git a/internal/ai/http_client_test.go b/internal/ai/http_client_test.go new file mode 100644 index 0000000..03fb7d7 --- /dev/null +++ b/internal/ai/http_client_test.go @@ -0,0 +1,178 @@ +// Copyright 2026 Optiqor contributors +// SPDX-License-Identifier: Apache-2.0 + +package ai + +import ( + "context" + "encoding/pem" + "net/http" + "net/http/httptest" + "net/url" + "os" + "testing" + "time" +) + +func TestHTTPClient_TLSVerificationFails(t *testing.T) { + server := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }), + ) + defer server.Close() + + client := NewHTTPClient( + 5*time.Second, + "", + "", + false, + ) + + req, err := http.NewRequestWithContext( + context.Background(), + http.MethodGet, + server.URL, + nil, + ) + if err != nil { + t.Fatalf("creating request: %v", err) + } + + resp, err := client.Do(req) + if resp != nil { + defer resp.Body.Close() + } + if err == nil { + t.Fatal("expected TLS verification error, got nil") + } +} + +func TestHTTPClient_InsecureSkipVerify(t *testing.T) { + server := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }), + ) + defer server.Close() + + client := NewHTTPClient( + 5*time.Second, + "", + "", + true, + ) + req, err := http.NewRequestWithContext( + context.Background(), + http.MethodGet, + server.URL, + nil, + ) + if err != nil { + t.Fatalf("creating request: %v", err) + } + resp, err := client.Do(req) + if err != nil { + t.Fatalf("expected successful request, got error : %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected status 200, got %d", resp.StatusCode) + } +} + +func TestHTTPClient_CustomCA(t *testing.T) { + server := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }), + ) + defer server.Close() + + // Export server cert as PEM + cert := server.Certificate() + + pemData := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: cert.Raw, + }) + + tmpFile, err := os.CreateTemp("", "kerno-ca-*.crt") + if err != nil { + t.Fatalf("creating temp cert file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.Write(pemData); err != nil { + t.Fatalf("writing cert file: %v", err) + } + + if err := tmpFile.Close(); err != nil { + t.Fatalf("closing cert file: %v", err) + } + + client := NewHTTPClient( + 5*time.Second, + "", + tmpFile.Name(), + false, + ) + + req, err := http.NewRequestWithContext( + context.Background(), + http.MethodGet, + server.URL, + nil, + ) + if err != nil { + t.Fatalf("creating request: %v", err) + } + + resp, err := client.Do(req) + if err != nil { + t.Fatalf("expected successful request with custom CA, got : %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected status 200, got %d", resp.StatusCode) + } +} + +func TestHTTPClient_CustomProxy(t *testing.T) { + client := NewHTTPClient( + 5*time.Second, + "http://localhost:8080", + "", + false, + ) + + transport, ok := client.Transport.(*http.Transport) + if !ok { + t.Fatal("expected *http.Transport") + } + + req := &http.Request{ + URL: &url.URL{ + Scheme: "https", + Host: "example.com", + }, + } + + proxyURL, err := transport.Proxy(req) + if err != nil { + t.Fatalf("proxy function returned error: %v", err) + } + + if proxyURL == nil { + t.Fatal("expected proxy URL, got nil") + } + + if proxyURL.String() != "http://localhost:8080" { + t.Fatalf("unexpected proxy URL: %s", proxyURL.String()) + } +} diff --git a/internal/ai/ollama.go b/internal/ai/ollama.go index 3798e36..7459105 100644 --- a/internal/ai/ollama.go +++ b/internal/ai/ollama.go @@ -51,7 +51,12 @@ func NewOllamaProvider(cfg ProviderConfig) *OllamaProvider { model: model, maxTokens: maxTokens, temperature: temp, - client: &http.Client{}, + client: NewHTTPClient( + cfg.Timeout, + cfg.Proxy, + cfg.CACertFile, + cfg.InsecureSkipVerify, + ), } } @@ -91,7 +96,7 @@ func (p *OllamaProvider) Complete(ctx context.Context, req CompletionRequest) (* resp, err := p.client.Do(httpReq) if err != nil { - return nil, fmt.Errorf("ollama API call failed (is Ollama running at %s?): %w", p.endpoint, err) + return nil, fmt.Errorf("ollama request failed: %w", formatHTTPError(err)) } defer resp.Body.Close() diff --git a/internal/ai/openai.go b/internal/ai/openai.go index 08d5a46..eb62fe0 100644 --- a/internal/ai/openai.go +++ b/internal/ai/openai.go @@ -53,7 +53,12 @@ func NewOpenAIProvider(cfg ProviderConfig) *OpenAIProvider { model: model, maxTokens: maxTokens, temperature: temp, - client: &http.Client{}, + client: NewHTTPClient( + cfg.Timeout, + cfg.Proxy, + cfg.CACertFile, + cfg.InsecureSkipVerify, + ), } } @@ -96,7 +101,7 @@ func (p *OpenAIProvider) Complete(ctx context.Context, req CompletionRequest) (* resp, err := p.client.Do(httpReq) if err != nil { - return nil, fmt.Errorf("openai API call failed: %w", err) + return nil, fmt.Errorf("openai request failed: %w", formatHTTPError(err)) } defer resp.Body.Close() diff --git a/internal/ai/provider.go b/internal/ai/provider.go index 90c8b39..8aeff20 100644 --- a/internal/ai/provider.go +++ b/internal/ai/provider.go @@ -12,6 +12,7 @@ package ai import ( "context" "fmt" + "time" ) // Provider abstracts an LLM backend. Implementations exist for Anthropic, @@ -70,6 +71,14 @@ type ProviderConfig struct { // Temperature default. Temperature float64 + + Timeout time.Duration + + Proxy string + + CACertFile string + + InsecureSkipVerify bool } // NewProvider constructs the appropriate Provider from config. diff --git a/internal/ai/provider_test.go b/internal/ai/provider_test.go index 5789bed..a4087a3 100644 --- a/internal/ai/provider_test.go +++ b/internal/ai/provider_test.go @@ -297,7 +297,7 @@ func TestOllamaProviderConnectionRefused(t *testing.T) { if err == nil { t.Fatal("expected connection error") } - if !strings.Contains(err.Error(), "Ollama") { + if !strings.Contains(strings.ToLower(err.Error()), "ollama") { t.Errorf("error should mention Ollama (helpful hint); got: %v", err) } } diff --git a/internal/config/config.go b/internal/config/config.go index 191451f..e726dd4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,6 +8,8 @@ package config import ( "fmt" + "net/url" + "os" "time" ) @@ -70,6 +72,14 @@ type AIConfig struct { // PrivacyMode controls what data is sent to the LLM: "full", "redacted", "summary". PrivacyMode string `mapstructure:"privacy_mode" json:"privacyMode"` + + Timeout time.Duration `mapstructure:"timeout" json:"timeout"` + + Proxy string `mapstructure:"proxy" json:"proxy"` + + CACertFile string `mapstructure:"ca_cert_file" json:"caCertFile"` + + InsecureSkipVerify bool `mapstructure:"insecure_skip_verify" json:"insecureSkipVerify"` } // CollectorsConfig controls which signal collectors are active. @@ -162,6 +172,7 @@ func Default() *Config { CacheTTL: "5m", RateLimitPerMinute: 10, PrivacyMode: "summary", + Timeout: 30 * time.Second, }, Prometheus: PrometheusConfig{ Enabled: true, @@ -213,6 +224,23 @@ func (c *Config) Validate() error { default: return fmt.Errorf("invalid ai.privacy_mode %q: must be full, redacted, or summary", c.AI.PrivacyMode) } + if c.AI.Timeout < time.Second { + return fmt.Errorf("ai.timeout must be at least 1s") + } + if c.AI.Timeout > 5*time.Minute { + return fmt.Errorf("ai.timeout must be at most 5m") + } + if c.AI.Proxy != "" { + u, err := url.Parse(c.AI.Proxy) + if err != nil || u.Scheme == "" || u.Host == "" { + return fmt.Errorf("invalid ai.proxy: %w", err) + } + } + if c.AI.CACertFile != "" { + if _, err := os.Stat(c.AI.CACertFile); err != nil { + return fmt.Errorf("ai.ca_cert_file does not exist: %w", err) + } + } } if c.Prometheus.Enabled && c.Prometheus.Addr == "" {