Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
87 changes: 87 additions & 0 deletions cmd/collector_tls.go

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that deserve at least a unit test

Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package cmd

import (
"crypto/tls"
"os"

"google.golang.org/grpc"
"google.golang.org/grpc/credentials"

grpcCollector "github.com/netobserv/flowlogs-pipeline/pkg/pipeline/write/grpc"
"github.com/netobserv/flowlogs-pipeline/pkg/tlsprofile"
)

const (
collectorCertPath = "/etc/collector-tls/tls.crt"
collectorKeyPath = "/etc/collector-tls/tls.key"
)

func collectorTLSOptions() []grpcCollector.CollectorOption {
return buildCollectorTLSOptions(collectorCertPath, collectorKeyPath)
}

// buildCollectorTLSOptions loads the given cert/key pair and returns the gRPC server options enabling
// TLS on the collector. It returns nil (TLS disabled) when either file is missing or the pair fails to
// load. The paths are parameters so this can be unit-tested against a temporary key pair.
func buildCollectorTLSOptions(certPath, keyPath string) []grpcCollector.CollectorOption {
tlsConfig := buildCollectorTLSConfig(certPath, keyPath)
if tlsConfig == nil {
return nil
}

log.Info("TLS enabled for collector")
return []grpcCollector.CollectorOption{
grpcCollector.WithGRPCServerOptions(grpc.Creds(credentials.NewTLS(tlsConfig))),
}
}

// buildCollectorTLSConfig loads the cert/key pair and builds the server tls.Config, honoring the
// cluster TLS security profile through tlsprofile.Apply (TLS_MIN_VERSION / TLS_CIPHER_SUITES /
// TLS_CURVE_PREFERENCES, populated by the resolve-tls initContainer). No TLS version is hardcoded:
// the effective profile is always what the cluster dictates. Returns nil (TLS disabled) when either
// file is missing or the pair fails to load.
func buildCollectorTLSConfig(certPath, keyPath string) *tls.Config {
if _, err := os.Stat(certPath); err != nil {
return nil
}
if _, err := os.Stat(keyPath); err != nil {
return nil
}

cert, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
log.Errorf("Failed to load TLS certificates: %v", err)
return nil
}

tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
}

applied, err := tlsprofile.Apply(tlsConfig)
if err != nil {
log.Errorf("Failed to apply TLS security profile: %v", err)
} else if !applied {
log.Warn("No TLS security profile settings found; relying on Go defaults")
}

return tlsConfig
}

func mockClientTLSConfig() *tls.Config {
return buildMockClientTLSConfig(collectorCertPath)
}

// buildMockClientTLSConfig returns a client TLS config (skipping verification, TLS 1.3) when the given
// cert file exists, or nil otherwise. The path is a parameter so this can be unit-tested.
// This is used only by the mock client (--mock); the collector server always supports TLS 1.3, so it
// stays pinned to 1.3 regardless of the cluster profile applied to the real server.
func buildMockClientTLSConfig(certPath string) *tls.Config {
if _, err := os.Stat(certPath); err != nil {
return nil
}
return &tls.Config{
InsecureSkipVerify: true,
MinVersion: tls.VersionTLS13,
}
}
150 changes: 150 additions & 0 deletions cmd/collector_tls_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package cmd

import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"testing"
"time"

"github.com/stretchr/testify/assert"
)

// writeSelfSignedCert generates a throwaway self-signed cert/key pair, writes it into dir and returns
// the cert and key paths.
func writeSelfSignedCert(t *testing.T, dir string) (string, string) {
t.Helper()

priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("failed to generate key: %v", err)
}

template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "collector-test"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
}
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("failed to create certificate: %v", err)
}

keyDER, err := x509.MarshalECPrivateKey(priv)
if err != nil {
t.Fatalf("failed to marshal key: %v", err)
}

certPath := filepath.Join(dir, "tls.crt")
keyPath := filepath.Join(dir, "tls.key")
writeFile(t, certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
writeFile(t, keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))
return certPath, keyPath
}

func writeFile(t *testing.T, path string, data []byte) {
t.Helper()
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("failed to write %s: %v", path, err)
}
}

func TestBuildCollectorTLSOptions(t *testing.T) {
t.Run("returns nil when the cert file is missing", func(t *testing.T) {
dir := t.TempDir()
assert.Nil(t, buildCollectorTLSOptions(filepath.Join(dir, "missing.crt"), filepath.Join(dir, "missing.key")))
})

t.Run("returns nil when the key file is missing", func(t *testing.T) {
dir := t.TempDir()
certPath, _ := writeSelfSignedCert(t, dir)
assert.Nil(t, buildCollectorTLSOptions(certPath, filepath.Join(dir, "missing.key")))
})

t.Run("returns nil when the key pair is invalid", func(t *testing.T) {
dir := t.TempDir()
certPath := filepath.Join(dir, "tls.crt")
keyPath := filepath.Join(dir, "tls.key")
writeFile(t, certPath, []byte("not a cert"))
writeFile(t, keyPath, []byte("not a key"))
assert.Nil(t, buildCollectorTLSOptions(certPath, keyPath))
})

t.Run("returns one server option for a valid key pair", func(t *testing.T) {
dir := t.TempDir()
certPath, keyPath := writeSelfSignedCert(t, dir)
opts := buildCollectorTLSOptions(certPath, keyPath)
assert.Len(t, opts, 1)
})
}

func TestBuildCollectorTLSConfig(t *testing.T) {
t.Run("returns nil when the cert file is missing", func(t *testing.T) {
dir := t.TempDir()
assert.Nil(t, buildCollectorTLSConfig(filepath.Join(dir, "missing.crt"), filepath.Join(dir, "missing.key")))
})

t.Run("does not hardcode a min version when no profile env is set", func(t *testing.T) {
dir := t.TempDir()
certPath, keyPath := writeSelfSignedCert(t, dir)
cfg := buildCollectorTLSConfig(certPath, keyPath)
if cfg == nil {
t.Fatal("expected a non-nil TLS config")
}
// No TLS_* env: nothing applied, so MinVersion stays 0 (Go decides), never a hardcoded 1.3.
assert.Equal(t, uint16(0), cfg.MinVersion)
assert.Len(t, cfg.Certificates, 1)
})

t.Run("applies min version and cipher suites from the profile env (TLS 1.2)", func(t *testing.T) {
dir := t.TempDir()
certPath, keyPath := writeSelfSignedCert(t, dir)
t.Setenv("TLS_MIN_VERSION", "771") // TLS 1.2
t.Setenv("TLS_CIPHER_SUITES", "49199,49195") // ECDHE-RSA/ECDSA AES128-GCM
cfg := buildCollectorTLSConfig(certPath, keyPath)
if cfg == nil {
t.Fatal("expected a non-nil TLS config")
}
assert.Equal(t, uint16(tls.VersionTLS12), cfg.MinVersion)
assert.Equal(t, []uint16{49199, 49195}, cfg.CipherSuites)
})

t.Run("ignores cipher suites when the profile pins TLS 1.3", func(t *testing.T) {
dir := t.TempDir()
certPath, keyPath := writeSelfSignedCert(t, dir)
t.Setenv("TLS_MIN_VERSION", "772") // TLS 1.3
t.Setenv("TLS_CIPHER_SUITES", "49199,49195")
cfg := buildCollectorTLSConfig(certPath, keyPath)
if cfg == nil {
t.Fatal("expected a non-nil TLS config")
}
assert.Equal(t, uint16(tls.VersionTLS13), cfg.MinVersion)
assert.Empty(t, cfg.CipherSuites)
})
}

func TestBuildMockClientTLSConfig(t *testing.T) {
t.Run("returns nil when the cert file is missing", func(t *testing.T) {
dir := t.TempDir()
assert.Nil(t, buildMockClientTLSConfig(filepath.Join(dir, "missing.crt")))
})

t.Run("returns an insecure TLS 1.3 config when the cert file exists", func(t *testing.T) {
dir := t.TempDir()
certPath, _ := writeSelfSignedCert(t, dir)
cfg := buildMockClientTLSConfig(certPath)
if cfg == nil {
t.Fatal("expected a non-nil TLS config")
}
assert.True(t, cfg.InsecureSkipVerify)
assert.Equal(t, uint16(tls.VersionTLS13), cfg.MinVersion)
})
}
2 changes: 1 addition & 1 deletion cmd/flow_capture.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ func startFlowCollector() {
log.Debug("Initialized database")

flowPackets := make(chan *genericmap.Flow, 100)
collector, err := grpc.StartCollector(port, flowPackets)
collector, err := grpc.StartCollector(port, flowPackets, collectorTLSOptions()...)
if err != nil {
log.Errorf("StartCollector failed: %v", err.Error())
return
Expand Down
2 changes: 1 addition & 1 deletion cmd/mocks.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ func mockForever() {
time.Sleep(1 * time.Second)
}

cc, err := grpc.ConnectClient("127.0.0.1", port, nil)
cc, err := grpc.ConnectClient("127.0.0.1", port, mockClientTLSConfig())
if err != nil {
log.Fatal(err)
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/packet_capture.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ func startPacketCollector() {
log.Trace("Wrote pcap section header & interface")

flowPackets := make(chan *genericmap.Flow, 100)
collector, err := grpc.StartCollector(port, flowPackets)
collector, err := grpc.StartCollector(port, flowPackets, collectorTLSOptions()...)
if err != nil {
log.Error("StartCollector failed", err)
return
Expand Down
27 changes: 27 additions & 0 deletions cmd/resolve_tls.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package cmd

import (
"context"

"github.com/netobserv/network-observability-cli/internal/pkg/tlsresolver"
"github.com/spf13/cobra"
)

// resolveTLSCmd reads the cluster's OpenShift APIServer tlsSecurityProfile and writes the resolved
// numeric TLS settings to the collector-tls-config ConfigMap. It runs as the collector pod's
// initContainer so the ConfigMap exists before the collector server and the agents start.
var resolveTLSCmd = &cobra.Command{
Use: "resolve-tls",
Short: "Resolve the cluster TLS security profile into a ConfigMap",
Long: `Reads the OpenShift APIServer tlsSecurityProfile and writes the corresponding
TLS_MIN_VERSION / TLS_CIPHER_SUITES / TLS_CURVE_PREFERENCES settings to the
collector-tls-config ConfigMap, so the collector and agents honor the cluster profile.`,
Run: runResolveTLS,
}

func runResolveTLS(_ *cobra.Command, _ []string) {
if err := tlsresolver.Resolve(context.Background(), namespace); err != nil {
log.Fatalf("failed to resolve TLS profile: %v", err)
}
log.Infof("TLS profile resolved into ConfigMap %s in namespace %s", tlsresolver.ConfigMapName, namespace)
}
3 changes: 3 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ func init() {

// metrics
rootCmd.AddCommand(metricCmd)

// resolve-tls (collector initContainer helper)
rootCmd.AddCommand(resolveTLSCmd)
}

func onInit() {
Expand Down
30 changes: 25 additions & 5 deletions commands/netobserv
Original file line number Diff line number Diff line change
Expand Up @@ -204,12 +204,23 @@ if [[ "$command" == "flows" || "$command" == "packets" || "$command" == "metrics
fi

# override extra configs
overrides="'{\"spec\":{\"serviceAccount\": \"netobserv-cli\"}}'"
if [[ "$tlsEnabled" == "true" ]]; then
# resolve-tls initContainer writes the collector-tls-config ConfigMap (resolved from the cluster
# tlsSecurityProfile) before the collector starts; the collector container consumes it via envFrom
# so its gRPC server honors the cluster profile instead of hardcoding a TLS version.
overrides="'{\"spec\":{\"serviceAccount\":\"netobserv-cli\",\"volumes\":[{\"name\":\"collector-tls\",\"secret\":{\"secretName\":\"collector-tls\"}}],\"initContainers\":[{\"name\":\"resolve-tls\",\"image\":\"$img\",\"imagePullPolicy\":\"Always\",\"command\":[\"/network-observability-cli\",\"resolve-tls\",\"--namespace\",\"$namespace\"]}],\"containers\":[{\"name\":\"collector\",\"image\":\"$img\",\"envFrom\":[{\"configMapRef\":{\"name\":\"collector-tls-config\",\"optional\":false}}],\"volumeMounts\":[{\"name\":\"collector-tls\",\"mountPath\":\"/etc/collector-tls\",\"readOnly\":true}]}]}}'"
else
overrides="'{\"spec\":{\"serviceAccount\": \"netobserv-cli\"}}'"
fi

overrideType=""
if [[ "$tlsEnabled" == "true" ]]; then
overrideType="--override-type=strategic"
fi
cmd="${K8S_CLI_BIN} run -n $namespace collector \\
--image=$img --image-pull-policy='Always' --restart='Never' \\
--overrides=$overrides \\
--command -- $runCommand"
--image=$img --image-pull-policy='Always' --restart='Never' \\
$overrideType --overrides=$overrides \\
--command -- $runCommand"

if [[ "$outputYAML" == "true" ]]; then
if [[ "$command" == "flows" || "$command" == "packets" ]]; then
Expand Down Expand Up @@ -241,12 +252,21 @@ if [[ "$command" == "flows" || "$command" == "packets" || "$command" == "metrics
eval "$cmd"

${K8S_CLI_BIN} wait \
--timeout 60s \
--timeout 120s \
-n "$namespace" \
--for=condition=Ready pod/collector || exit 1

captureStarted=true

# On the TLS path, agent deployment was deferred by setup() until the collector's resolve-tls
# initContainer wrote the collector-tls-config ConfigMap. Now that the collector is ready, the
# ConfigMap exists, so it's safe to create the agents that reference it via envFrom.
if [[ "$tlsEnabled" == "true" ]]; then
echo "creating capture agents"
applyYAML "$agentManifest"
waitDaemonset
fi

if [[ "$runBackground" != "true" && "$outputYAML" != "true" ]]; then
echo "Executing collector command... "
if [ -n "$execOptions" ]; then
Expand Down
Loading