-
Notifications
You must be signed in to change notification settings - Fork 78
feat(ai): add proxy and custom CA support for enterprise environments #85
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. CA load failures are swallowed here. if |
||
| 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, | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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()) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
#47 asked for proxy and custom CA, not a TLS-verification bypass.
insecure_skip_verifyis a footgun in a tool that runs as root, and it's not in the issue's scope. the nolint and the "local/dev" comment don't change that someone sets it in prod and forgets. drop it, or raise it on #47 first.