-
Notifications
You must be signed in to change notification settings - Fork 73
🌱 OPRUN-4573: tests: add unit and e2e tests for HTTPS_PROXY support #2654
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
Open
tmshort
wants to merge
1
commit into
operator-framework:main
Choose a base branch
from
tmshort:proxy-tests
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,198 @@ | ||
| package http_test | ||
|
|
||
| import ( | ||
| "context" | ||
| "encoding/pem" | ||
| "io" | ||
| "net" | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "net/url" | ||
| "os" | ||
| "path/filepath" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/stretchr/testify/require" | ||
| "sigs.k8s.io/controller-runtime/pkg/log" | ||
|
|
||
| httputil "github.com/operator-framework/operator-controller/internal/shared/util/http" | ||
| ) | ||
|
|
||
| // startRecordingProxy starts a plain-HTTP CONNECT proxy that tunnels HTTPS | ||
| // connections and records the target host of each CONNECT request. | ||
| func startRecordingProxy(proxied chan<- string) *httptest.Server { | ||
| return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| if r.Method != http.MethodConnect { | ||
| http.Error(w, "only CONNECT supported", http.StatusMethodNotAllowed) | ||
| return | ||
| } | ||
| // Non-blocking: if there are unexpected extra CONNECT requests (retries, | ||
| // parallel connections) we record the first one and drop the rest rather | ||
| // than blocking the proxy handler goroutine. | ||
| select { | ||
| case proxied <- r.Host: | ||
| default: | ||
| } | ||
|
|
||
| dst, err := net.Dial("tcp", r.Host) | ||
| if err != nil { | ||
| http.Error(w, err.Error(), http.StatusBadGateway) | ||
| return | ||
| } | ||
| defer dst.Close() | ||
|
|
||
| hj, ok := w.(http.Hijacker) | ||
| if !ok { | ||
| http.Error(w, "hijacking not supported", http.StatusInternalServerError) | ||
| return | ||
| } | ||
| conn, bufrw, err := hj.Hijack() | ||
| if err != nil { | ||
| http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| return | ||
| } | ||
| defer conn.Close() | ||
|
|
||
| if _, err = conn.Write([]byte("HTTP/1.1 200 Connection established\r\n\r\n")); err != nil { | ||
| return | ||
| } | ||
|
|
||
| done := make(chan struct{}, 2) | ||
| tunnel := func(dst io.Writer, src io.Reader) { | ||
| defer func() { done <- struct{}{} }() | ||
| _, _ = io.Copy(dst, src) | ||
| } | ||
| // Use bufrw (not conn) as the client→dst source: Hijack may have | ||
| // buffered bytes (e.g. the TLS ClientHello) that arrived together with | ||
| // the CONNECT headers; reading from conn directly would lose them. | ||
| go tunnel(dst, bufrw) | ||
| go tunnel(conn, dst) | ||
| <-done | ||
| <-done // wait for both directions before closing connections | ||
| })) | ||
| } | ||
|
|
||
| // certPoolWatcherForTLSServer creates a CertPoolWatcher that trusts the given | ||
| // TLS test server's certificate. | ||
| func certPoolWatcherForTLSServer(t *testing.T, server *httptest.Server) *httputil.CertPoolWatcher { | ||
| t.Helper() | ||
|
|
||
| dir := t.TempDir() | ||
| certPath := filepath.Join(dir, "server.pem") | ||
|
|
||
| certDER := server.TLS.Certificates[0].Certificate[0] | ||
| f, err := os.Create(certPath) | ||
| require.NoError(t, err) | ||
| require.NoError(t, pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: certDER})) | ||
| require.NoError(t, f.Close()) | ||
|
|
||
| cpw, err := httputil.NewCertPoolWatcher(dir, log.FromContext(context.Background())) | ||
| require.NoError(t, err) | ||
| require.NotNil(t, cpw) | ||
| t.Cleanup(cpw.Done) | ||
| require.NoError(t, cpw.Start(context.Background())) | ||
| return cpw | ||
| } | ||
|
|
||
| // TestBuildHTTPClientTransportUsesProxyFromEnvironment verifies that the | ||
| // transport returned by BuildHTTPClient has Proxy set to http.ProxyFromEnvironment | ||
| // so that HTTPS_PROXY and NO_PROXY env vars are honoured at runtime. | ||
| func TestBuildHTTPClientTransportUsesProxyFromEnvironment(t *testing.T) { | ||
| // Use system certs (empty dir) — we only need a valid CertPoolWatcher. | ||
| cpw, err := httputil.NewCertPoolWatcher("", log.FromContext(context.Background())) | ||
| require.NoError(t, err) | ||
| t.Cleanup(cpw.Done) | ||
| require.NoError(t, cpw.Start(context.Background())) | ||
|
|
||
| client, err := httputil.BuildHTTPClient(cpw) | ||
| require.NoError(t, err) | ||
|
|
||
| transport, ok := client.Transport.(*http.Transport) | ||
| require.True(t, ok) | ||
| require.NotNil(t, transport.Proxy, | ||
| "BuildHTTPClient must set transport.Proxy so that HTTPS_PROXY env vars are respected; "+ | ||
| "a nil Proxy field means no proxy regardless of environment") | ||
| } | ||
|
|
||
| // TestBuildHTTPClientProxyTunnelsConnections verifies end-to-end that the | ||
| // HTTP client produced by BuildHTTPClient correctly tunnels HTTPS connections | ||
| // through an HTTP CONNECT proxy. | ||
| // | ||
| // The test overrides transport.Proxy with http.ProxyURL rather than relying on | ||
| // HTTPS_PROXY: httptest servers bind to 127.0.0.1, which http.ProxyFromEnvironment | ||
| // silently excludes from proxying, and env-var changes within the same process | ||
| // are unreliable due to sync.Once caching. Using http.ProxyURL directly exercises | ||
| // the same tunnelling code path that HTTPS_PROXY triggers in production. | ||
| func TestBuildHTTPClientProxyTunnelsConnections(t *testing.T) { | ||
| targetServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | ||
| w.WriteHeader(http.StatusOK) | ||
| })) | ||
| defer targetServer.Close() | ||
|
|
||
| proxied := make(chan string, 1) | ||
| proxyServer := startRecordingProxy(proxied) | ||
| defer proxyServer.Close() | ||
|
|
||
| proxyURL, err := url.Parse(proxyServer.URL) | ||
| require.NoError(t, err) | ||
|
|
||
| cpw := certPoolWatcherForTLSServer(t, targetServer) | ||
| client, err := httputil.BuildHTTPClient(cpw) | ||
| require.NoError(t, err) | ||
|
|
||
| // Point the transport directly at our test proxy, bypassing the loopback | ||
| // exclusion and env-var caching of http.ProxyFromEnvironment. | ||
| transport, ok := client.Transport.(*http.Transport) | ||
| require.True(t, ok) | ||
| transport.Proxy = http.ProxyURL(proxyURL) | ||
|
Comment on lines
+144
to
+148
|
||
|
|
||
| resp, err := client.Get(targetServer.URL) | ||
| require.NoError(t, err) | ||
| resp.Body.Close() | ||
|
|
||
| select { | ||
| case host := <-proxied: | ||
| require.Equal(t, targetServer.Listener.Addr().String(), host, | ||
| "proxy must have received a CONNECT request for the target server address") | ||
| case <-time.After(5 * time.Second): | ||
| t.Fatal("HTTPS connection to target server did not go through the proxy") | ||
| } | ||
|
tmshort marked this conversation as resolved.
|
||
| } | ||
|
|
||
| // TestBuildHTTPClientProxyBlocksWhenRejected verifies that when the proxy | ||
| // rejects the CONNECT tunnel, the client request fails rather than silently | ||
| // falling back to a direct connection. | ||
| func TestBuildHTTPClientProxyBlocksWhenRejected(t *testing.T) { | ||
| targetServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | ||
| w.WriteHeader(http.StatusOK) | ||
| })) | ||
| defer targetServer.Close() | ||
|
|
||
| // A proxy that returns 403 Forbidden for every CONNECT request. | ||
| rejectingProxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| if r.Method == http.MethodConnect { | ||
| http.Error(w, "proxy access denied", http.StatusForbidden) | ||
| return | ||
| } | ||
| http.Error(w, "only CONNECT supported", http.StatusMethodNotAllowed) | ||
| })) | ||
| defer rejectingProxy.Close() | ||
|
|
||
| proxyURL, err := url.Parse(rejectingProxy.URL) | ||
| require.NoError(t, err) | ||
|
|
||
| cpw := certPoolWatcherForTLSServer(t, targetServer) | ||
| client, err := httputil.BuildHTTPClient(cpw) | ||
| require.NoError(t, err) | ||
|
|
||
| transport, ok := client.Transport.(*http.Transport) | ||
| require.True(t, ok) | ||
| transport.Proxy = http.ProxyURL(proxyURL) | ||
|
|
||
| resp, err := client.Get(targetServer.URL) | ||
| if resp != nil { | ||
| resp.Body.Close() | ||
| } | ||
| require.Error(t, err, "request should fail when the proxy rejects the CONNECT tunnel") | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| Feature: HTTPS proxy support for outbound catalog requests | ||
|
|
||
| OLM's operator-controller fetches catalog data from catalogd over HTTPS. | ||
| When HTTPS_PROXY is set in the operator-controller's environment, all | ||
| outbound HTTPS requests must be routed through the configured proxy. | ||
|
|
||
| Background: | ||
| Given OLM is available | ||
| And ClusterCatalog "test" serves bundles | ||
| And ServiceAccount "olm-sa" with needed permissions is available in test namespace | ||
|
|
||
| @HTTPProxy | ||
| Scenario: operator-controller respects HTTPS_PROXY when fetching catalog data | ||
| Given the "operator-controller" component is configured with HTTPS_PROXY "http://127.0.0.1:39999" | ||
| When ClusterExtension is applied | ||
| """ | ||
| apiVersion: olm.operatorframework.io/v1 | ||
| kind: ClusterExtension | ||
| metadata: | ||
| name: ${NAME} | ||
| spec: | ||
| namespace: ${TEST_NAMESPACE} | ||
| serviceAccount: | ||
| name: olm-sa | ||
| source: | ||
| sourceType: Catalog | ||
| catalog: | ||
| packageName: test | ||
| selector: | ||
| matchLabels: | ||
| "olm.operatorframework.io/metadata.name": test-catalog | ||
| """ | ||
| Then ClusterExtension reports Progressing as True with Reason Retrying and Message includes: | ||
| """ | ||
| proxyconnect | ||
| """ | ||
|
|
||
| @HTTPProxy | ||
| Scenario: operator-controller sends catalog requests through a configured HTTPS proxy | ||
| # The recording proxy runs on the host and cannot route to in-cluster service | ||
| # addresses, so it responds 502 after recording the CONNECT. This is | ||
| # intentional: the scenario only verifies that operator-controller respects | ||
| # HTTPS_PROXY and sends catalog fetches through the proxy, not that the full | ||
| # end-to-end request succeeds. | ||
| Given the "operator-controller" component is configured with HTTPS_PROXY pointing to a recording proxy | ||
| When ClusterExtension is applied | ||
| """ | ||
| apiVersion: olm.operatorframework.io/v1 | ||
| kind: ClusterExtension | ||
| metadata: | ||
| name: ${NAME} | ||
| spec: | ||
| namespace: ${TEST_NAMESPACE} | ||
| serviceAccount: | ||
| name: olm-sa | ||
| source: | ||
| sourceType: Catalog | ||
| catalog: | ||
| packageName: test | ||
| selector: | ||
| matchLabels: | ||
| "olm.operatorframework.io/metadata.name": test-catalog | ||
| """ | ||
| Then the recording proxy received a CONNECT request for the catalogd service |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.