diff --git a/deploy/deployment.yaml b/deploy/deployment.yaml index 9373553..6d8ba1c 100644 --- a/deploy/deployment.yaml +++ b/deploy/deployment.yaml @@ -22,6 +22,12 @@ spec: env: - name: INSECURE value: "true" + - name: TLS_MIN_VERSION + value: "1.3" + - name: TLS_MAX_VERSION + value: "1.3" + - name: TLS_CIPHER_SUITES + value: "" volumeMounts: - mountPath: "/etc/gitops/ssl" name: backend-ssl diff --git a/go.mod b/go.mod index e8017e5..e336033 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.7.0 + github.com/stretchr/testify v1.11.1 go.uber.org/zap v1.27.0 k8s.io/api v0.33.1 k8s.io/apimachinery v0.33.1 @@ -135,7 +136,6 @@ require ( github.com/spf13/cast v1.7.1 // indirect github.com/spf13/jwalterweatherman v1.0.0 // indirect github.com/spf13/pflag v1.0.6 // indirect - github.com/stretchr/testify v1.11.1 // indirect github.com/subosito/gotenv v1.2.0 // indirect github.com/vmihailenco/go-tinylfu v0.2.2 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index d488e7a..8129fb0 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -79,6 +79,11 @@ func makeHTTPCmd() *cobra.Command { log.Println("TLS connections disabled") return server.ListenAndServe() } + tlsConfig, err := buildTLSConfig() + if err != nil { + return err + } + server.TLSConfig = tlsConfig log.Printf("Using TLS from %q and %q", viper.GetString(tlsCertFlag), viper.GetString(tlsKeyFlag)) return server.ListenAndServeTLS(viper.GetString(tlsCertFlag), viper.GetString(tlsKeyFlag)) }, @@ -125,6 +130,26 @@ func makeHTTPCmd() *cobra.Command { "enable HTTP/2 for the server", ) logIfError(viper.BindPFlag(enableHTTP2, cmd.Flags().Lookup(enableHTTP2))) + cmd.Flags().String( + tlsMinVersionFlag, + "", + "minimum supported TLS version (1.2, 1.3)", + ) + logIfError(viper.BindPFlag(tlsMinVersionFlag, cmd.Flags().Lookup(tlsMinVersionFlag))) + + cmd.Flags().String( + tlsMaxVersionFlag, + "", + "maximum supported TLS version (1.2, 1.3)", + ) + logIfError(viper.BindPFlag(tlsMaxVersionFlag, cmd.Flags().Lookup(tlsMaxVersionFlag))) + + cmd.Flags().String( + tlsCipherSuitesFlag, + "", + "comma-separated list of TLS cipher suites", + ) + logIfError(viper.BindPFlag(tlsCipherSuitesFlag, cmd.Flags().Lookup(tlsCipherSuitesFlag))) return cmd } diff --git a/pkg/cmd/tls.go b/pkg/cmd/tls.go new file mode 100644 index 0000000..7fd18e5 --- /dev/null +++ b/pkg/cmd/tls.go @@ -0,0 +1,154 @@ +package cmd + +import ( + "crypto/tls" + "fmt" + "log" + "strings" + + "github.com/spf13/viper" +) + +const ( + tlsMinVersionFlag = "tls-min-version" + tlsMaxVersionFlag = "tls-max-version" + tlsCipherSuitesFlag = "tls-cipher-suites" +) + +type TLSConfig struct { + MinVersion string + MaxVersion string + Ciphers string +} + +// tlsVersionMap maps version strings to tls version constants. +// TLS 1.0 is not supported as it is considered insecure. +var tlsVersionMap = map[string]uint16{ + "1.1": tls.VersionTLS11, + "tls1.1": tls.VersionTLS11, + "1.2": tls.VersionTLS12, + "tls1.2": tls.VersionTLS12, + "1.3": tls.VersionTLS13, + "tls1.3": tls.VersionTLS13, +} + +// TLSVersionName returns a human-readable name for a TLS version constant. +func TLSVersionName(version uint16) string { + for name, v := range tlsVersionMap { + if v == version { + return name + } + } + return fmt.Sprintf("unknown (%d)", version) +} + +// ParseTLSVersion parses a TLS version string (e.g. "1.2", "1.3", "TLS1.2") into a tls version constant. +// Returns 0 if the string is empty (meaning "use default"). +func ParseTLSVersion(version string) (uint16, error) { + if version == "" { + return 0, nil + } + v, ok := tlsVersionMap[strings.ToLower(strings.TrimSpace(version))] + if !ok { + return 0, fmt.Errorf("unsupported TLS version: %q (supported: 1.1, 1.2, 1.3)", version) + } + return v, nil +} + +// ParseTLSCiphers parses a colon-separated list of cipher suite names into cipher suite IDs. +// Only secure cipher suites (from tls.CipherSuites()) are allowed. +// Returns nil if the input is empty. +func ParseTLSCiphers(ciphers string) ([]uint16, error) { + if ciphers == "" { + return nil, nil + } + // Build lookup map from Go's secure cipher suites only + cipherMap := make(map[string]uint16) + for _, cs := range tls.CipherSuites() { + cipherMap[cs.Name] = cs.ID + } + var result []uint16 + for _, name := range strings.Split(ciphers, ":") { + name = strings.TrimSpace(name) + if name == "" { + continue + } + id, ok := cipherMap[name] + if !ok { + return nil, fmt.Errorf("unsupported TLS cipher suite: %q", name) + } + result = append(result, id) + } + return result, nil +} + +// ValidateTLSConfig validates the TLS configuration parameters. +// It checks that: +// - The minimum TLS version is not greater than the maximum TLS version +// - All configured cipher suites are compatible with the minimum TLS version +func ValidateTLSConfig(minVersion, maxVersion uint16, cipherSuites []uint16) error { + if minVersion != 0 && maxVersion != 0 && minVersion > maxVersion { + return fmt.Errorf("minimum TLS version (%s) cannot be higher than maximum TLS version (%s)", + TLSVersionName(minVersion), TLSVersionName(maxVersion)) + } + if len(cipherSuites) > 0 && minVersion != 0 { + availableCiphers := tls.CipherSuites() + for _, cipherID := range cipherSuites { + for _, cs := range availableCiphers { + if cs.ID == cipherID { + supported := false + for _, v := range cs.SupportedVersions { + if v == minVersion { + supported = true + break + } + } + if !supported { + return fmt.Errorf("cipher suite %s is not supported by minimum TLS version %s", + cs.Name, TLSVersionName(minVersion)) + } + break + } + } + } + } + + return nil +} + +func buildTLSConfig() (*tls.Config, error) { + cfg := TLSConfig{ + MinVersion: viper.GetString(tlsMinVersionFlag), + MaxVersion: viper.GetString(tlsMaxVersionFlag), + Ciphers: viper.GetString(tlsCipherSuitesFlag), + } + + tlsCfg := &tls.Config{} //nolint:gosec + minVer, err := ParseTLSVersion(cfg.MinVersion) + if err != nil { + return nil, fmt.Errorf("invalid --%s: %w", tlsMinVersionFlag, err) + } + tlsCfg.MinVersion = minVer + maxVer, err := ParseTLSVersion(cfg.MaxVersion) + if err != nil { + return nil, fmt.Errorf("invalid --%s: %w", tlsMaxVersionFlag, err) + } + tlsCfg.MaxVersion = maxVer + ciphers, err := ParseTLSCiphers(cfg.Ciphers) + if err != nil { + return nil, fmt.Errorf("invalid --%s: %w", tlsCipherSuitesFlag, err) + } + if len(ciphers) > 0 && minVer == tls.VersionTLS13 { + log.Printf( + "%s has no effect when minimum TLS version is 1.3 because TLS 1.3 cipher suites are not configurable; ignoring", + tlsCipherSuitesFlag, + ) + ciphers = nil + } + if err := ValidateTLSConfig(minVer, maxVer, ciphers); err != nil { + return nil, err + } + tlsCfg.CipherSuites = ciphers + + return tlsCfg, nil +} diff --git a/pkg/cmd/tls_test.go b/pkg/cmd/tls_test.go new file mode 100644 index 0000000..f4a94b0 --- /dev/null +++ b/pkg/cmd/tls_test.go @@ -0,0 +1,288 @@ +package cmd + +import ( + "crypto/tls" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTLSVersionName(t *testing.T) { + tests := []struct { + name string + version uint16 + expected string + }{ + { + name: "TLS1.1", + version: tls.VersionTLS11, + expected: "1.1", + }, + { + name: "TLS1.2", + version: tls.VersionTLS12, + expected: "1.2", + }, + { + name: "TLS1.3", + version: tls.VersionTLS13, + expected: "1.3", + }, + { + name: "unknown", + version: 999, + expected: "unknown (999)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, TLSVersionName(tt.version)) + }) + } +} + +func TestParseTLSVersion(t *testing.T) { + tests := []struct { + name string + input string + expected uint16 + expectErr bool + }{ + { + name: "empty", + input: "", + expected: 0, + }, + { + name: "1.2", + input: "1.2", + expected: tls.VersionTLS12, + }, + { + name: "TLS1.2", + input: "TLS1.2", + expected: tls.VersionTLS12, + }, + { + name: "trim spaces", + input: " 1.3 ", + expected: tls.VersionTLS13, + }, + { + name: "unsupported", + input: "1.0", + expectErr: true, + }, + { + name: "garbage", + input: "abc", + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + version, err := ParseTLSVersion(tt.input) + + if tt.expectErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.expected, version) + }) + } +} + +func TestParseTLSCiphers(t *testing.T) { + validCipher := tls.CipherSuites()[0] + + tests := []struct { + name string + input string + expected []uint16 + expectErr bool + }{ + { + name: "empty", + input: "", + expected: nil, + }, + { + name: "single cipher", + input: validCipher.Name, + expected: []uint16{validCipher.ID}, + }, + { + name: "multiple ciphers", + input: validCipher.Name + ":" + + tls.CipherSuites()[1].Name, + expected: []uint16{ + validCipher.ID, + tls.CipherSuites()[1].ID, + }, + }, + { + name: "invalid cipher", + input: "INVALID_CIPHER", + expectErr: true, + }, + { + name: "ignore empty entries", + input: ":" + validCipher.Name + "::", + expected: []uint16{validCipher.ID}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ciphers, err := ParseTLSCiphers(tt.input) + + if tt.expectErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.expected, ciphers) + }) + } +} + +func TestValidateTLSConfig(t *testing.T) { + tls12cipher := tls.CipherSuites()[3] + + tests := []struct { + name string + minVersion uint16 + maxVersion uint16 + ciphers []uint16 + expectErr bool + }{ + { + name: "valid versions", + minVersion: tls.VersionTLS12, + maxVersion: tls.VersionTLS13, + }, + { + name: "min greater than max", + minVersion: tls.VersionTLS13, + maxVersion: tls.VersionTLS12, + expectErr: true, + }, + { + name: "no versions", + minVersion: 0, + maxVersion: 0, + }, + { + name: "valid cipher", + minVersion: tls.VersionTLS12, + maxVersion: tls.VersionTLS13, + ciphers: []uint16{tls12cipher.ID}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateTLSConfig( + tt.minVersion, + tt.maxVersion, + tt.ciphers, + ) + + if tt.expectErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + }) + } +} + +func TestBuildTLSConfig(t *testing.T) { + validCipher := tls.CipherSuites()[3] + + tests := []struct { + name string + minVersion string + maxVersion string + ciphers string + expectedMin uint16 + expectedMax uint16 + expectedSuite []uint16 + expectErr bool + }{ + { + name: "defaults", + expectedMin: 0, + expectedMax: 0, + }, + { + name: "valid config", + minVersion: "1.2", + maxVersion: "1.3", + ciphers: validCipher.Name, + expectedMin: tls.VersionTLS12, + expectedMax: tls.VersionTLS13, + expectedSuite: []uint16{ + validCipher.ID, + }, + }, + { + name: "invalid min version", + minVersion: "1.0", + expectErr: true, + }, + { + name: "invalid max version", + maxVersion: "abc", + expectErr: true, + }, + { + name: "invalid cipher", + ciphers: "BAD_CIPHER", + expectErr: true, + }, + { + name: "min greater than max", + minVersion: "1.3", + maxVersion: "1.2", + expectErr: true, + }, + { + name: "tls13 ignores ciphers", + minVersion: "1.3", + ciphers: validCipher.Name, + expectedMin: tls.VersionTLS13, + expectedMax: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + viper.Reset() + + viper.Set(tlsMinVersionFlag, tt.minVersion) + viper.Set(tlsMaxVersionFlag, tt.maxVersion) + viper.Set(tlsCipherSuitesFlag, tt.ciphers) + + cfg, err := buildTLSConfig() + + if tt.expectErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.expectedMin, cfg.MinVersion) + assert.Equal(t, tt.expectedMax, cfg.MaxVersion) + assert.Equal(t, tt.expectedSuite, cfg.CipherSuites) + }) + } +}