|
1 | 1 | package crypto |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "crypto/x509" |
| 5 | + "encoding/pem" |
| 6 | + "net" |
4 | 7 | "os" |
5 | 8 | "path/filepath" |
6 | 9 | "testing" |
@@ -386,8 +389,134 @@ func TestGenerateSelfSignedCert_IncludesHosts(t *testing.T) { |
386 | 389 | t.Fatalf("LoadOrCreateTLSCert() error = %v", err) |
387 | 390 | } |
388 | 391 |
|
389 | | - // Verify the cert can be parsed (basic validation) |
390 | | - if err := verifyCertReadable(certPath); err != nil { |
391 | | - t.Errorf("Generated cert is not readable: %v", err) |
| 392 | + cert := loadCertForTest(t, certPath) |
| 393 | + |
| 394 | + // Requested SANs must be present. |
| 395 | + if !containsString(cert.DNSNames, "example.com") { |
| 396 | + t.Errorf("cert DNSNames missing example.com: %v", cert.DNSNames) |
| 397 | + } |
| 398 | + if !containsIPString(cert.IPAddresses, "192.168.1.1") { |
| 399 | + t.Errorf("cert IPAddresses missing 192.168.1.1: %v", cert.IPAddresses) |
| 400 | + } |
| 401 | + |
| 402 | + // Loopback SANs are always added. |
| 403 | + if !containsString(cert.DNSNames, "localhost") { |
| 404 | + t.Errorf("cert DNSNames missing localhost: %v", cert.DNSNames) |
| 405 | + } |
| 406 | + if !containsIPString(cert.IPAddresses, "127.0.0.1") { |
| 407 | + t.Errorf("cert IPAddresses missing 127.0.0.1: %v", cert.IPAddresses) |
| 408 | + } |
| 409 | + if !containsIPString(cert.IPAddresses, "::1") { |
| 410 | + t.Errorf("cert IPAddresses missing ::1: %v", cert.IPAddresses) |
| 411 | + } |
| 412 | +} |
| 413 | + |
| 414 | +// TestLoadOrCreateTLSCert_RegenOnMissingSAN verifies that a previously |
| 415 | +// generated cert is regenerated when LoadOrCreateTLSCert is called with a |
| 416 | +// host that the existing cert does not cover. Without this behaviour an |
| 417 | +// operator who adds a new entry to HAPROXY_AGENT_TLS_HOSTS would have to |
| 418 | +// manually wipe the cert file before the change takes effect. |
| 419 | +func TestLoadOrCreateTLSCert_RegenOnMissingSAN(t *testing.T) { |
| 420 | + tmpDir := t.TempDir() |
| 421 | + certPath := filepath.Join(tmpDir, "server.crt") |
| 422 | + keyPath := filepath.Join(tmpDir, "server.key") |
| 423 | + |
| 424 | + // Initial cert covers example.com only. |
| 425 | + _, isNew, err := LoadOrCreateTLSCert(certPath, keyPath, []string{"example.com"}) |
| 426 | + if err != nil { |
| 427 | + t.Fatalf("initial LoadOrCreateTLSCert() error = %v", err) |
| 428 | + } |
| 429 | + if !isNew { |
| 430 | + t.Fatal("initial call should report isNew = true") |
| 431 | + } |
| 432 | + originalSerial := loadCertForTest(t, certPath).SerialNumber.String() |
| 433 | + |
| 434 | + // Same hosts → reuse, no regen. |
| 435 | + _, isNew, err = LoadOrCreateTLSCert(certPath, keyPath, []string{"example.com"}) |
| 436 | + if err != nil { |
| 437 | + t.Fatalf("second LoadOrCreateTLSCert() error = %v", err) |
| 438 | + } |
| 439 | + if isNew { |
| 440 | + t.Error("call with same hosts should report isNew = false") |
| 441 | + } |
| 442 | + if loadCertForTest(t, certPath).SerialNumber.String() != originalSerial { |
| 443 | + t.Error("cert serial changed despite same hosts (unexpected regeneration)") |
| 444 | + } |
| 445 | + |
| 446 | + // New host added → regenerate. |
| 447 | + _, isNew, err = LoadOrCreateTLSCert(certPath, keyPath, []string{"example.com", "172.16.2.3"}) |
| 448 | + if err != nil { |
| 449 | + t.Fatalf("third LoadOrCreateTLSCert() error = %v", err) |
| 450 | + } |
| 451 | + if !isNew { |
| 452 | + t.Fatal("call with added host should report isNew = true") |
| 453 | + } |
| 454 | + regenerated := loadCertForTest(t, certPath) |
| 455 | + if regenerated.SerialNumber.String() == originalSerial { |
| 456 | + t.Error("cert serial unchanged after adding new host (regeneration did not happen)") |
| 457 | + } |
| 458 | + if !containsIPString(regenerated.IPAddresses, "172.16.2.3") { |
| 459 | + t.Errorf("regenerated cert missing 172.16.2.3 SAN: %v", regenerated.IPAddresses) |
| 460 | + } |
| 461 | + if !containsString(regenerated.DNSNames, "example.com") { |
| 462 | + t.Errorf("regenerated cert lost example.com SAN: %v", regenerated.DNSNames) |
| 463 | + } |
| 464 | + regeneratedSerial := regenerated.SerialNumber.String() |
| 465 | + |
| 466 | + // Casing change on the same DNS name → reuse, no spurious regen |
| 467 | + // (RFC 6125 §6.4: DNS host matching is case-insensitive). |
| 468 | + _, isNew, err = LoadOrCreateTLSCert(certPath, keyPath, []string{"EXAMPLE.com", "172.16.2.3"}) |
| 469 | + if err != nil { |
| 470 | + t.Fatalf("fourth LoadOrCreateTLSCert() error = %v", err) |
| 471 | + } |
| 472 | + if isNew { |
| 473 | + t.Error("call differing only in DNS casing should not regenerate") |
| 474 | + } |
| 475 | + if loadCertForTest(t, certPath).SerialNumber.String() != regeneratedSerial { |
| 476 | + t.Error("cert serial changed across casing-only difference (unexpected regeneration)") |
| 477 | + } |
| 478 | + |
| 479 | + // Trailing-dot equivalence (example.com. == example.com per DNS). |
| 480 | + _, isNew, err = LoadOrCreateTLSCert(certPath, keyPath, []string{"example.com.", "172.16.2.3"}) |
| 481 | + if err != nil { |
| 482 | + t.Fatalf("fifth LoadOrCreateTLSCert() error = %v", err) |
| 483 | + } |
| 484 | + if isNew { |
| 485 | + t.Error("trailing-dot variant should not regenerate") |
| 486 | + } |
| 487 | +} |
| 488 | + |
| 489 | +func loadCertForTest(t *testing.T, certPath string) *x509.Certificate { |
| 490 | + t.Helper() |
| 491 | + data, err := os.ReadFile(certPath) |
| 492 | + if err != nil { |
| 493 | + t.Fatalf("read cert: %v", err) |
| 494 | + } |
| 495 | + block, _ := pem.Decode(data) |
| 496 | + if block == nil { |
| 497 | + t.Fatal("failed to decode cert PEM") |
| 498 | + } |
| 499 | + cert, err := x509.ParseCertificate(block.Bytes) |
| 500 | + if err != nil { |
| 501 | + t.Fatalf("parse cert: %v", err) |
| 502 | + } |
| 503 | + return cert |
| 504 | +} |
| 505 | + |
| 506 | +func containsString(haystack []string, needle string) bool { |
| 507 | + for _, v := range haystack { |
| 508 | + if v == needle { |
| 509 | + return true |
| 510 | + } |
| 511 | + } |
| 512 | + return false |
| 513 | +} |
| 514 | + |
| 515 | +func containsIPString(haystack []net.IP, needle string) bool { |
| 516 | + for _, ip := range haystack { |
| 517 | + if ip.String() == needle { |
| 518 | + return true |
| 519 | + } |
392 | 520 | } |
| 521 | + return false |
393 | 522 | } |
0 commit comments