Skip to content

Commit cf7b39d

Browse files
Merge pull request #3363 from shajmakh/tls-sched-e2e
e2e: add TLS profile modification test
2 parents e9b7850 + 617dce3 commit cf7b39d

7 files changed

Lines changed: 705 additions & 2 deletions

File tree

internal/api/features/_topics.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"metrics",
2323
"nrtcrdanns",
2424
"netpols",
25-
"mgselinuxcollect"
25+
"mgselinuxcollect",
26+
"tlscompliance"
2627
]
2728
}

internal/remoteexec/command.go

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,25 @@ import (
2020
"bytes"
2121
"context"
2222
"fmt"
23+
"io"
24+
"net"
25+
"net/http"
2326
"os"
27+
"time"
2428

2529
corev1 "k8s.io/api/core/v1"
30+
"k8s.io/apimachinery/pkg/util/httpstream"
31+
utilportforward "k8s.io/apimachinery/pkg/util/portforward"
2632
"k8s.io/client-go/kubernetes"
2733
"k8s.io/client-go/kubernetes/scheme"
2834
"k8s.io/client-go/tools/remotecommand"
35+
"k8s.io/client-go/transport/spdy"
36+
"k8s.io/klog/v2"
2937

3038
"sigs.k8s.io/controller-runtime/pkg/client/config"
3139
)
3240

33-
// ExecCommandOnPod runs command in the pod and returns buffer output
41+
// CommandOnPod runs command in the pod and returns buffer output
3442
func CommandOnPod(ctx context.Context, c kubernetes.Interface, pod *corev1.Pod, command ...string) ([]byte, []byte, error) {
3543
return CommandOnPodByNames(ctx, c, pod.Namespace, pod.Name, pod.Spec.Containers[0].Name, command...)
3644
}
@@ -76,3 +84,124 @@ func CommandOnPodByNames(ctx context.Context, c kubernetes.Interface, podNamespa
7684

7785
return outputBuf.Bytes(), errorBuf.Bytes(), nil
7886
}
87+
88+
// PortForwardToPod opens an SPDY tunnel to the given pod port and calls fn
89+
// with the resulting net.Conn that speaks directly to that port inside the pod.
90+
// The data connection is closed after fn returns (before draining the error stream).
91+
func PortForwardToPod(c kubernetes.Interface, pod *corev1.Pod, podPort string, fn func(conn net.Conn) error) error {
92+
return PortForwardToPodByNames(c, pod.Namespace, pod.Name, podPort, fn)
93+
}
94+
95+
func PortForwardToPodByNames(c kubernetes.Interface, podNamespace, podName, podPort string, fn func(conn net.Conn) error) error {
96+
dialer, err := portForwardDialer(c, podNamespace, podName)
97+
if err != nil {
98+
return fmt.Errorf("port-forward dialer creation failed: %w", err)
99+
}
100+
101+
spdyConn, _, err := dialer.Dial(utilportforward.PortForwardV1Name)
102+
if err != nil {
103+
return fmt.Errorf("port-forward dial to %s/%s:%s failed: %w", podNamespace, podName, podPort, err)
104+
}
105+
// by now the connection is established and the port-forward is running
106+
defer func() {
107+
if err := spdyConn.Close(); err != nil {
108+
klog.ErrorS(err, "failed to close SPDY connection")
109+
}
110+
}()
111+
112+
// setup the error stream that would be used by kubelet to send back errors
113+
headers := http.Header{}
114+
headers.Set(corev1.StreamType, corev1.StreamTypeError)
115+
headers.Set(corev1.PortHeader, podPort)
116+
headers.Set(corev1.PortForwardRequestIDHeader, "0")
117+
errorStream, err := spdyConn.CreateStream(headers)
118+
if err != nil {
119+
return fmt.Errorf("port-forward error stream creation failed: %w", err)
120+
}
121+
defer func() {
122+
if err := errorStream.Close(); err != nil {
123+
klog.ErrorS(err, "failed to close error stream")
124+
}
125+
}()
126+
127+
errorCh := make(chan error, 1)
128+
go func() {
129+
buf, err := io.ReadAll(errorStream)
130+
if err != nil {
131+
errorCh <- err
132+
return
133+
}
134+
if len(buf) > 0 {
135+
errorCh <- fmt.Errorf("port-forward to %s/%s:%s: %s", podNamespace, podName, podPort, string(buf))
136+
return
137+
}
138+
errorCh <- nil
139+
}()
140+
141+
// setup the data stream
142+
headers.Set(corev1.StreamType, corev1.StreamTypeData)
143+
dataStream, err := spdyConn.CreateStream(headers)
144+
if err != nil {
145+
return fmt.Errorf("port-forward data stream creation failed: %w", err)
146+
}
147+
148+
conn := &streamConn{stream: dataStream}
149+
fnErr := fn(conn)
150+
// Close the forwarded data stream as soon as fn finishes so the server can
151+
// wind down the port-forward and close the error stream. Otherwise io.ReadAll
152+
// on errorStream can block forever while we wait on errorCh below, and
153+
// deferred closes never run (deadlock).
154+
if err := conn.Close(); err != nil {
155+
klog.ErrorS(err, "failed to close port-forward data stream")
156+
}
157+
if fnErr != nil {
158+
return fnErr
159+
}
160+
if err := <-errorCh; err != nil {
161+
return err
162+
}
163+
return nil
164+
}
165+
166+
func portForwardDialer(c kubernetes.Interface, podNamespace, podName string) (httpstream.Dialer, error) {
167+
req := c.CoreV1().RESTClient().
168+
Post().
169+
Namespace(podNamespace).
170+
Resource("pods").
171+
Name(podName).
172+
SubResource("portforward")
173+
174+
cfg, err := config.GetConfig()
175+
if err != nil {
176+
return nil, err
177+
}
178+
179+
transport, upgrader, err := spdy.RoundTripperFor(cfg)
180+
if err != nil {
181+
return nil, err
182+
}
183+
184+
dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, http.MethodPost, req.URL())
185+
return dialer, nil
186+
}
187+
188+
// streamConn wraps an httpstream.Stream interface as a net.Conn so it can be used with
189+
// crypto/tls.Client and similar APIs that expect a net.Conn.
190+
type streamConn struct {
191+
stream httpstream.Stream
192+
}
193+
194+
func (s *streamConn) Read(b []byte) (int, error) { return s.stream.Read(b) }
195+
func (s *streamConn) Write(b []byte) (int, error) { return s.stream.Write(b) }
196+
func (s *streamConn) Close() error { return s.stream.Close() }
197+
198+
func (s *streamConn) LocalAddr() net.Addr { return stubAddr{} }
199+
func (s *streamConn) RemoteAddr() net.Addr { return stubAddr{} }
200+
func (s *streamConn) SetDeadline(time.Time) error { return nil }
201+
func (s *streamConn) SetReadDeadline(time.Time) error { return nil }
202+
func (s *streamConn) SetWriteDeadline(time.Time) error { return nil }
203+
204+
type stubAddr struct{}
205+
206+
func (stubAddr) Network() string { return "spdy" }
207+
func (stubAddr) String() string { return "port-forward" }

test/e2e/tls/tls_suite_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
* Copyright 2026 Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package tls
18+
19+
import (
20+
"testing"
21+
22+
e2eclient "github.com/openshift-kni/numaresources-operator/test/internal/clients"
23+
24+
. "github.com/onsi/ginkgo/v2"
25+
. "github.com/onsi/gomega"
26+
)
27+
28+
func TestTLS(t *testing.T) {
29+
RegisterFailHandler(Fail)
30+
RunSpecs(t, "TLS")
31+
}
32+
33+
var _ = BeforeSuite(func() {
34+
By("Creating all test resources")
35+
Expect(e2eclient.ClientsEnabled).To(BeTrue(), "failed to create runtime-controller client")
36+
})

test/e2e/tls/tls_test.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/*
2+
* Copyright 2026 Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package tls
18+
19+
import (
20+
"context"
21+
"crypto/tls"
22+
"errors"
23+
"fmt"
24+
25+
"k8s.io/klog/v2"
26+
27+
ctrltls "github.com/openshift/controller-runtime-common/pkg/tls"
28+
libgocrypto "github.com/openshift/library-go/pkg/crypto"
29+
30+
nropv1 "github.com/openshift-kni/numaresources-operator/api/v1"
31+
"github.com/openshift-kni/numaresources-operator/internal/podlist"
32+
e2eclient "github.com/openshift-kni/numaresources-operator/test/internal/clients"
33+
"github.com/openshift-kni/numaresources-operator/test/internal/objects"
34+
intls "github.com/openshift-kni/numaresources-operator/test/internal/tls"
35+
36+
. "github.com/onsi/ginkgo/v2"
37+
. "github.com/onsi/gomega"
38+
)
39+
40+
const schedulerSecurePort = "10259"
41+
42+
var _ = Describe("TLS", func() {
43+
It("should reject TLS connections that are not compatible with the profile - negative test", func(ctx context.Context) {
44+
By("getting the current OCP TLS profile")
45+
tlsProfileSpec, err := ctrltls.FetchAPIServerTLSProfile(ctx, e2eclient.Client)
46+
Expect(err).ToNot(HaveOccurred(), "unable to get TLS profile from APIServer")
47+
48+
tlsConfigFn, _ := ctrltls.NewTLSConfigFromProfile(tlsProfileSpec)
49+
tlsCfg := &tls.Config{}
50+
tlsConfigFn(tlsCfg)
51+
minVersion := tlsCfg.MinVersion
52+
klog.InfoS("current TLS minimum version", "version", libgocrypto.TLSVersionToNameOrDie(minVersion))
53+
54+
belowMinVersion, err := intls.TLSVersionBelow(minVersion)
55+
Expect(err).ToNot(HaveOccurred(), "failed to get TLS version below %s", libgocrypto.TLSVersionToNameOrDie(minVersion))
56+
57+
By("getting the scheduler deployment and pods")
58+
nroSchedObj := &nropv1.NUMAResourcesScheduler{}
59+
nroSchedKey := objects.NROSchedObjectKey()
60+
Expect(e2eclient.Client.Get(ctx, nroSchedKey, nroSchedObj)).To(Succeed(), "failed to get %q in the cluster", nroSchedKey.String())
61+
62+
deployment, err := podlist.With(e2eclient.Client).DeploymentByOwnerReference(ctx, nroSchedObj.GetUID())
63+
Expect(err).ToNot(HaveOccurred(), "failed to get the deployment")
64+
Expect(deployment).ToNot(BeNil(), "scheduler deployment not found")
65+
66+
pods, err := podlist.With(e2eclient.Client).ByDeployment(ctx, *deployment)
67+
Expect(err).ToNot(HaveOccurred(), "failed to get the pods")
68+
Expect(pods).ToNot(BeEmpty(), "no pods found for the deployment")
69+
70+
schedulerPod := &pods[0]
71+
72+
By(fmt.Sprintf("verifying that TLS connections at version %s are rejected by the server", tls.VersionName(belowMinVersion)))
73+
err = intls.ProbeMaxTLSVersion(e2eclient.K8sClient, schedulerPod, schedulerSecurePort, belowMinVersion)
74+
Expect(err).To(HaveOccurred(), "scheduler server should reject TLS connections capped at %s", tls.VersionName(belowMinVersion))
75+
Expect(errors.Is(err, intls.ErrTLSHandshakeRejected)).To(BeTrue(),
76+
"expected TLS handshake rejection, got: %v", err)
77+
78+
By(fmt.Sprintf("verifying that TLS connections with unsupported ciphers are rejected by the server"))
79+
if minVersion == tls.VersionTLS13 {
80+
klog.InfoS("TLS 1.3 is not configurable, so we cannot test unsupported ciphers")
81+
return
82+
}
83+
84+
disallowedCipher := intls.FindDisallowedCipher(tlsProfileSpec.Ciphers)
85+
if disallowedCipher == "" {
86+
Skip("all known TLS 1.2 ciphers are in the allowed set, nothing to test")
87+
}
88+
klog.InfoS("testing with disallowed cipher", "cipher", disallowedCipher)
89+
err = intls.ProbeTLSCipher(e2eclient.K8sClient, schedulerPod, schedulerSecurePort, disallowedCipher)
90+
Expect(err).To(HaveOccurred(), "scheduler server should reject connections with disallowed cipher %s", disallowedCipher)
91+
Expect(errors.Is(err, intls.ErrTLSHandshakeRejected)).To(BeTrue(), "expected TLS handshake rejection for cipher %s, got: %v", disallowedCipher, err)
92+
})
93+
94+
It("should adhere to openshift TLS profile - positive test", func(ctx context.Context) {
95+
By("getting the current OCP TLS profile")
96+
tlsProfileSpec, err := ctrltls.FetchAPIServerTLSProfile(ctx, e2eclient.Client)
97+
Expect(err).ToNot(HaveOccurred(), "unable to get TLS profile from APIServer")
98+
99+
tlsConfigFn, _ := ctrltls.NewTLSConfigFromProfile(tlsProfileSpec)
100+
tlsCfg := &tls.Config{}
101+
tlsConfigFn(tlsCfg)
102+
minVersion := tlsCfg.MinVersion
103+
klog.InfoS("current TLS minimum version", "version", libgocrypto.TLSVersionToNameOrDie(minVersion))
104+
105+
By("getting the scheduler deployment and pods")
106+
nroSchedObj := &nropv1.NUMAResourcesScheduler{}
107+
nroSchedKey := objects.NROSchedObjectKey()
108+
Expect(e2eclient.Client.Get(ctx, nroSchedKey, nroSchedObj)).To(Succeed(), "failed to get %q in the cluster", nroSchedKey.String())
109+
110+
deployment, err := podlist.With(e2eclient.Client).DeploymentByOwnerReference(ctx, nroSchedObj.GetUID())
111+
Expect(err).ToNot(HaveOccurred(), "failed to get the deployment")
112+
Expect(deployment).ToNot(BeNil(), "scheduler deployment not found")
113+
114+
pods, err := podlist.With(e2eclient.Client).ByDeployment(ctx, *deployment)
115+
Expect(err).ToNot(HaveOccurred(), "failed to get the pods")
116+
Expect(pods).ToNot(BeEmpty(), "no pods found for the deployment")
117+
118+
schedulerPod := &pods[0]
119+
120+
By("probing the scheduler HTTPS endpoint to verify TLS connection is accepted")
121+
gotVersion, gotCipherID, err := intls.ProbeTLSSettings(e2eclient.K8sClient, schedulerPod, schedulerSecurePort)
122+
Expect(err).ToNot(HaveOccurred(), "failed to probe TLS settings on pod %q", schedulerPod.Name)
123+
124+
gotVersionName := tls.VersionName(gotVersion)
125+
gotCipherName := tls.CipherSuiteName(gotCipherID)
126+
klog.InfoS("negotiated TLS settings", "version", gotVersionName, "cipher", gotCipherName)
127+
128+
Expect(gotVersion).To(BeNumerically(">=", minVersion), "negotiated TLS version %s is below the expected minimum %s", gotVersionName, libgocrypto.TLSVersionToNameOrDie(minVersion))
129+
130+
// TLS 1.3 cipher suites are not configurable and won't appear in
131+
// the profile's list; only validate for TLS 1.2 and below.
132+
allowedCipherNames := libgocrypto.OpenSSLToIANACipherSuites(tlsProfileSpec.Ciphers)
133+
if gotVersion < tls.VersionTLS13 {
134+
Expect(gotCipherName).ToNot(BeEmpty(), "could not resolve negotiated cipher suite ID 0x%04x", gotCipherID)
135+
Expect(allowedCipherNames).To(ContainElement(gotCipherName), "negotiated cipher %s is not in the allowed set %v", gotCipherName, tlsProfileSpec.Ciphers)
136+
}
137+
})
138+
})

test/internal/clients/clients.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"sigs.k8s.io/controller-runtime/pkg/client"
2626
"sigs.k8s.io/controller-runtime/pkg/client/config"
2727

28+
configv1 "github.com/openshift/api/config/v1"
2829
mcov1 "github.com/openshift/api/machineconfiguration/v1"
2930
hypershiftv1beta1 "github.com/openshift/hypershift/api/hypershift/v1beta1"
3031

@@ -48,6 +49,10 @@ var (
4849

4950
func init() {
5051
// Setup Scheme for all resources
52+
if err := configv1.Install(scheme.Scheme); err != nil {
53+
klog.Exit(err.Error())
54+
}
55+
5156
if err := mcov1.AddToScheme(scheme.Scheme); err != nil {
5257
klog.Exit(err.Error())
5358
}

0 commit comments

Comments
 (0)