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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions support/certs/ipv6_fix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package certs

import (
"net"
"testing"
)

func TestCompareIPAddresses_IPv6Normalization(t *testing.T) {
tests := []struct {
name string
actual []string
expected []string
shouldError bool
}{
{
name: "IPv6 compressed vs expanded - should match",
actual: []string{
"127.0.0.1",
"0:0:0:0:0:0:0:1", // expanded
"172.31.0.1",
"FD05:0:0:0:0:0:0:1", // expanded, uppercase
"172.20.0.1",
"2620:52:0:2EF8:0:0:0:9F", // expanded, mixed case
},
expected: []string{
"127.0.0.1",
"::1", // compressed
"172.31.0.1",
"fd05::1", // compressed, lowercase
"172.20.0.1",
"2620:52:0:2ef8::9f", // compressed, lowercase
},
shouldError: false,
},
{
name: "Different IP count - should fail",
actual: []string{
"127.0.0.1",
"::1",
},
expected: []string{
"127.0.0.1",
},
shouldError: true,
},
{
name: "Actually different IPs - should fail",
actual: []string{
"127.0.0.1",
"::1",
},
expected: []string{
"127.0.0.1",
"::2", // different IP
},
shouldError: true,
},
{
name: "Order doesn't matter - should match",
actual: []string{
"172.31.0.1",
"127.0.0.1",
"::1",
},
expected: []string{
"127.0.0.1",
"::1",
"172.31.0.1",
},
shouldError: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Convert strings to net.IP
actualIPs := make([]net.IP, len(tt.actual))
for i, ipStr := range tt.actual {
actualIPs[i] = net.ParseIP(ipStr)
if actualIPs[i] == nil {
t.Fatalf("Failed to parse actual IP: %s", ipStr)
}
}

expectedIPs := make([]net.IP, len(tt.expected))
for i, ipStr := range tt.expected {
expectedIPs[i] = net.ParseIP(ipStr)
if expectedIPs[i] == nil {
t.Fatalf("Failed to parse expected IP: %s", ipStr)
}
}

// Test the comparison function
err := compareIPAddresses(actualIPs, expectedIPs)

if tt.shouldError && err == nil {
t.Errorf("Expected error but got none")
}
if !tt.shouldError && err != nil {
t.Errorf("Expected no error but got: %v", err)
}
})
}
}

func TestCompareIPAddresses_DualStackScenario(t *testing.T) {
// This test specifically recreates the dualstack-420 cluster scenario
t.Run("dualstack-420 cluster scenario", func(t *testing.T) {
// IPs as they appear in the certificate (expanded format from x509)
actualIPs := []net.IP{
net.ParseIP("127.0.0.1"),
net.ParseIP("0:0:0:0:0:0:0:1"),
net.ParseIP("172.31.0.1"),
net.ParseIP("FD05:0:0:0:0:0:0:1"),
net.ParseIP("172.20.0.1"),
net.ParseIP("2620:52:0:2EF8:0:0:0:9F"),
}

// IPs as they're calculated by GetKASServerCertificatesSANs (compressed format)
expectedIPs := []net.IP{
net.ParseIP("127.0.0.1"),
net.ParseIP("0:0:0:0:0:0:0:1"), // Note: this is already expanded in the code
net.ParseIP("172.31.0.1"),
net.ParseIP("fd05::1"),
net.ParseIP("172.20.0.1"),
net.ParseIP("2620:52:0:2ef8::9f"),
}

err := compareIPAddresses(actualIPs, expectedIPs)
if err != nil {
t.Errorf("Comparison should succeed for dualstack-420 scenario, but got error: %v", err)
}
})
}
46 changes: 43 additions & 3 deletions support/certs/tls.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ import (
"math/big"
"net"
"os"
"sort"
"strconv"
"strings"
"time"

corev1 "k8s.io/api/core/v1"
Expand Down Expand Up @@ -290,6 +292,44 @@ func parsePemKeypair(key, certificate []byte) (*rsa.PrivateKey, *x509.Certificat
return privKey, cert, nil
}

// compareIPAddresses compares two slices of IP addresses semantically.
// This handles IPv6 address normalization (e.g., "::1" vs "0:0:0:0:0:0:0:1")
// by using net.IP.Equal() for comparison instead of byte comparison.
func compareIPAddresses(actual, expected []net.IP) error {
if len(actual) != len(expected) {
return fmt.Errorf("actual ip addresses count (%d) differs from expected (%d)", len(actual), len(expected))
}

// Create sorted copies to handle order differences
actualSorted := make([]net.IP, len(actual))
expectedSorted := make([]net.IP, len(expected))
copy(actualSorted, actual)
copy(expectedSorted, expected)

// Sort both slices using byte comparison for consistent ordering
sortIPSlice := func(ips []net.IP) {
sort.Slice(ips, func(i, j int) bool {
return bytes.Compare(ips[i], ips[j]) < 0
})
}
sortIPSlice(actualSorted)
sortIPSlice(expectedSorted)

// Compare each IP semantically
var mismatches []string
for i := range actualSorted {
if !actualSorted[i].Equal(expectedSorted[i]) {
mismatches = append(mismatches, fmt.Sprintf("position %d: actual=%s, expected=%s", i, actualSorted[i], expectedSorted[i]))
}
}

if len(mismatches) > 0 {
return fmt.Errorf("actual ip addresses differ from expected: %s", strings.Join(mismatches, "; "))
}

return nil
}

func ValidateKeyPair(pemKey, pemCertificate []byte, cfg *CertCfg, minimumRemainingValidity time.Duration) error {
_, cert, err := parsePemKeypair(pemKey, pemCertificate)
if err != nil {
Expand All @@ -316,9 +356,9 @@ func ValidateKeyPair(pemKey, pemCertificate []byte, cfg *CertCfg, minimumRemaini
errs = append(errs, fmt.Errorf("actual extended key usages differ from expected: %s", extUsageDiff))
}

ipAddressDiff := cmp.Diff(cert.IPAddresses, cfg.IPAddresses, cmpopts.SortSlices(func(a, b []byte) bool { return bytes.Compare(a, b) == -1 }))
if ipAddressDiff != "" {
errs = append(errs, fmt.Errorf("actual ip addresses differ from expected: %s", ipAddressDiff))
// Compare IP addresses semantically to handle IPv6 normalization
if err := compareIPAddresses(cert.IPAddresses, cfg.IPAddresses); err != nil {
errs = append(errs, err)
}

if cert.KeyUsage != cfg.KeyUsages {
Expand Down