ensureTLSCerts now checks certificate expiry in addition to existence. Certs that expire within 30 days are scheduled for renewal alongside missing certs.
390 lines
12 KiB
Go
390 lines
12 KiB
Go
package reconcile
|
|
|
|
import (
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/pem"
|
|
"math/big"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/wild-cloud/wild-central/internal/config"
|
|
"github.com/wild-cloud/wild-central/internal/dnsmasq"
|
|
"github.com/wild-cloud/wild-central/internal/domains"
|
|
"github.com/wild-cloud/wild-central/internal/haproxy"
|
|
"github.com/wild-cloud/wild-central/internal/sse"
|
|
)
|
|
|
|
// --- Stubs ---
|
|
|
|
type stubDomainManager struct {
|
|
domains []domains.Domain
|
|
err error
|
|
}
|
|
|
|
func (s *stubDomainManager) List() ([]domains.Domain, error) {
|
|
return s.domains, s.err
|
|
}
|
|
|
|
type stubHAProxy struct {
|
|
generateCalls int
|
|
lastL4Routes []haproxy.L4Route
|
|
writtenConfig string
|
|
writeErr error
|
|
reloadCalled bool
|
|
}
|
|
|
|
func (s *stubHAProxy) GenerateWithOpts(l4 []haproxy.L4Route, _ []haproxy.CustomRoute, _ haproxy.GenerateOpts) string {
|
|
s.generateCalls++
|
|
s.lastL4Routes = l4
|
|
return "generated-config"
|
|
}
|
|
func (s *stubHAProxy) SafeApply(c string) error { s.writtenConfig = c; return s.writeErr }
|
|
func (s *stubHAProxy) WriteConfig(c string) error { s.writtenConfig = c; return s.writeErr }
|
|
func (s *stubHAProxy) ReloadService() error { s.reloadCalled = true; return nil }
|
|
|
|
type stubDNS struct {
|
|
entries []dnsmasq.DNSEntry
|
|
filterPath string
|
|
applyCalled bool
|
|
}
|
|
|
|
func (s *stubDNS) Generate(_ *config.State, entries []dnsmasq.DNSEntry) string {
|
|
s.entries = entries
|
|
return "generated-dns-config"
|
|
}
|
|
func (s *stubDNS) SafeApply(_ string) error {
|
|
s.applyCalled = true
|
|
return nil
|
|
}
|
|
func (s *stubDNS) SetFilterConfPath(p string) { s.filterPath = p }
|
|
func (s *stubDNS) GetStatus() (*dnsmasq.Status, error) { return nil, nil }
|
|
|
|
type stubAuth struct {
|
|
userCount int
|
|
restartCalled bool
|
|
}
|
|
|
|
func (s *stubAuth) UserCount() int { return s.userCount }
|
|
func (s *stubAuth) RestartService() error { s.restartCalled = true; return nil }
|
|
|
|
type stubDDNS struct{ triggerCalled bool }
|
|
|
|
func (s *stubDDNS) Trigger() { s.triggerCalled = true }
|
|
|
|
type stubDNSFilter struct{ hostsPath string }
|
|
|
|
func (s *stubDNSFilter) HostsFilePath() string { return s.hostsPath }
|
|
|
|
type stubSSE struct{ events []*sse.Event }
|
|
|
|
func (s *stubSSE) Broadcast(e *sse.Event) { s.events = append(s.events, e) }
|
|
|
|
func newTestReconciler(t *testing.T, doms []domains.Domain) (*Reconciler, *stubHAProxy, *stubDNS, *stubDDNS) {
|
|
t.Helper()
|
|
tmpDir := t.TempDir()
|
|
statePath := filepath.Join(tmpDir, "state.yaml")
|
|
// Write minimal state
|
|
_ = config.SaveState(&config.State{}, statePath)
|
|
|
|
hp := &stubHAProxy{}
|
|
dns := &stubDNS{}
|
|
ddns := &stubDDNS{}
|
|
|
|
r := &Reconciler{
|
|
Domains: &stubDomainManager{domains: doms},
|
|
HAProxy: hp,
|
|
DNS: dns,
|
|
Auth: &stubAuth{},
|
|
DDNS: ddns,
|
|
DNSFilter: &stubDNSFilter{},
|
|
SSE: &stubSSE{},
|
|
StatePath: statePath,
|
|
}
|
|
return r, hp, dns, ddns
|
|
}
|
|
|
|
// --- Pure function tests ---
|
|
|
|
func TestBuildRoutes_MixedBackendTypes(t *testing.T) {
|
|
doms := []domains.Domain{
|
|
{DomainName: "cloud.example.com", Backend: domains.Backend{Address: "192.168.1.10:443", Type: domains.BackendTCPPassthrough}, Subdomains: true},
|
|
{DomainName: "api.example.com", Backend: domains.Backend{Address: "192.168.1.20:8080", Type: domains.BackendHTTP}},
|
|
{DomainName: "ssh.example.com", Backend: domains.Backend{Address: "192.168.1.30:22", Type: domains.BackendDNSOnly}},
|
|
}
|
|
|
|
l4, http := buildRoutes(doms)
|
|
|
|
if len(l4) != 1 {
|
|
t.Fatalf("expected 1 L4 route, got %d", len(l4))
|
|
}
|
|
if l4[0].Domain != "cloud.example.com" || l4[0].BackendIP != "192.168.1.10" || !l4[0].Subdomains {
|
|
t.Errorf("L4 route mismatch: %+v", l4[0])
|
|
}
|
|
|
|
if len(http) != 1 {
|
|
t.Fatalf("expected 1 HTTP route, got %d", len(http))
|
|
}
|
|
if http[0].Domain != "api.example.com" {
|
|
t.Errorf("HTTP route domain = %q, want api.example.com", http[0].Domain)
|
|
}
|
|
if len(http[0].Routes) != 1 || http[0].Routes[0].Backend != "192.168.1.20:8080" {
|
|
t.Errorf("HTTP route backend mismatch: %+v", http[0].Routes)
|
|
}
|
|
}
|
|
|
|
func TestBuildRoutes_EmptyDomains(t *testing.T) {
|
|
l4, http := buildRoutes(nil)
|
|
if l4 != nil || http != nil {
|
|
t.Error("expected nil routes for nil domains")
|
|
}
|
|
}
|
|
|
|
func TestBuildDNSEntries_IPAssignment(t *testing.T) {
|
|
centralIP := "10.0.0.1"
|
|
doms := []domains.Domain{
|
|
{DomainName: "central.example.com", Backend: domains.Backend{Address: "127.0.0.1:5055", Type: domains.BackendHTTP}},
|
|
{DomainName: "cloud.example.com", Backend: domains.Backend{Address: "192.168.1.10:443", Type: domains.BackendTCPPassthrough}},
|
|
{DomainName: "ssh.example.com", Backend: domains.Backend{Address: "192.168.1.30:22", Type: domains.BackendDNSOnly}},
|
|
}
|
|
|
|
entries := buildDNSEntries(doms, centralIP)
|
|
|
|
if len(entries) != 3 {
|
|
t.Fatalf("expected 3 entries, got %d", len(entries))
|
|
}
|
|
|
|
// HTTP → centralIP
|
|
if entries[0].IP != centralIP {
|
|
t.Errorf("HTTP domain IP = %q, want %q", entries[0].IP, centralIP)
|
|
}
|
|
// TCP passthrough → backend IP
|
|
if entries[1].IP != "192.168.1.10" {
|
|
t.Errorf("TCP domain IP = %q, want 192.168.1.10", entries[1].IP)
|
|
}
|
|
// DNS-only → backend IP
|
|
if entries[2].IP != "192.168.1.30" {
|
|
t.Errorf("DNS-only domain IP = %q, want 192.168.1.30", entries[2].IP)
|
|
}
|
|
}
|
|
|
|
func TestBuildDNSEntries_SkipsEmptyDomainName(t *testing.T) {
|
|
entries := buildDNSEntries([]domains.Domain{{DomainName: ""}}, "10.0.0.1")
|
|
if len(entries) != 0 {
|
|
t.Error("expected empty domain to be skipped")
|
|
}
|
|
}
|
|
|
|
func TestBuildDNSEntries_WildcardFlag(t *testing.T) {
|
|
doms := []domains.Domain{
|
|
{DomainName: "cloud.example.com", Backend: domains.Backend{Address: "192.168.1.10:443", Type: domains.BackendTCPPassthrough}, Subdomains: true},
|
|
{DomainName: "exact.example.com", Backend: domains.Backend{Address: "192.168.1.20:443", Type: domains.BackendTCPPassthrough}, Subdomains: false},
|
|
}
|
|
entries := buildDNSEntries(doms, "10.0.0.1")
|
|
if !entries[0].Wildcard {
|
|
t.Error("expected first entry to be wildcard")
|
|
}
|
|
if entries[1].Wildcard {
|
|
t.Error("expected second entry to not be wildcard")
|
|
}
|
|
}
|
|
|
|
// --- Integration tests with stubs ---
|
|
|
|
func TestReconcile_EmptyDomains(t *testing.T) {
|
|
r, hp, dns, ddns := newTestReconciler(t, nil)
|
|
r.Reconcile()
|
|
|
|
if !dns.applyCalled {
|
|
t.Error("expected dnsmasq update")
|
|
}
|
|
if len(dns.entries) != 0 {
|
|
t.Errorf("expected 0 DNS entries, got %d", len(dns.entries))
|
|
}
|
|
if !hp.reloadCalled {
|
|
// No routes but config is still generated and written
|
|
}
|
|
if !ddns.triggerCalled {
|
|
t.Error("expected DDNS trigger")
|
|
}
|
|
// With no routes, HAProxy should still generate (empty config is valid)
|
|
if hp.generateCalls != 1 {
|
|
t.Errorf("expected 1 HAProxy generate call, got %d", hp.generateCalls)
|
|
}
|
|
}
|
|
|
|
func TestReconcile_DDNSTriggered(t *testing.T) {
|
|
doms := []domains.Domain{
|
|
{DomainName: "test.example.com", Backend: domains.Backend{Address: "127.0.0.1:80", Type: domains.BackendHTTP}},
|
|
}
|
|
r, _, _, ddns := newTestReconciler(t, doms)
|
|
r.Reconcile()
|
|
|
|
if !ddns.triggerCalled {
|
|
t.Error("expected DDNS.Trigger() to be called")
|
|
}
|
|
}
|
|
|
|
func TestReconcile_DNSEntriesBuiltFromDomains(t *testing.T) {
|
|
doms := []domains.Domain{
|
|
{DomainName: "app.example.com", Backend: domains.Backend{Address: "127.0.0.1:8080", Type: domains.BackendHTTP}},
|
|
{DomainName: "k8s.example.com", Backend: domains.Backend{Address: "192.168.1.50:443", Type: domains.BackendTCPPassthrough}},
|
|
}
|
|
r, _, dns, _ := newTestReconciler(t, doms)
|
|
r.Reconcile()
|
|
|
|
if len(dns.entries) != 2 {
|
|
t.Fatalf("expected 2 DNS entries, got %d", len(dns.entries))
|
|
}
|
|
// HTTP domain should use centralIP (127.0.0.1 fallback since state has no IP)
|
|
if dns.entries[0].Domain != "app.example.com" {
|
|
t.Errorf("first DNS entry domain = %q", dns.entries[0].Domain)
|
|
}
|
|
// TCP passthrough should use backend IP directly
|
|
if dns.entries[1].IP != "192.168.1.50" {
|
|
t.Errorf("TCP DNS entry IP = %q, want 192.168.1.50", dns.entries[1].IP)
|
|
}
|
|
}
|
|
|
|
func TestReconcile_CNAMEsSkippedWithoutToken(t *testing.T) {
|
|
// A public wildcard domain should not panic or error when no CF token is configured.
|
|
doms := []domains.Domain{
|
|
{
|
|
DomainName: "cloud.example.com",
|
|
Backend: domains.Backend{Address: "192.168.1.10:80", Type: domains.BackendHTTP},
|
|
Subdomains: true,
|
|
Public: true,
|
|
TLS: domains.TLSTerminate,
|
|
},
|
|
}
|
|
r, _, _, ddns := newTestReconciler(t, doms)
|
|
// GetCloudflareToken is nil — ensureCloudflareCNAMEs should silently return
|
|
r.Reconcile()
|
|
|
|
if !ddns.triggerCalled {
|
|
t.Error("expected DDNS.Trigger() to be called")
|
|
}
|
|
}
|
|
|
|
func TestEnsureCloudflareCNAMEs_FiltersCorrectly(t *testing.T) {
|
|
// Track which domains EnsureCNAME would be called for by using a token
|
|
// that would fail at the API level. We verify the method doesn't attempt
|
|
// CNAMEs for non-qualifying domains by checking it returns cleanly
|
|
// (no token = no API calls).
|
|
doms := []domains.Domain{
|
|
{DomainName: "cloud.example.com", Subdomains: true, Public: true}, // qualifies
|
|
{DomainName: "app.example.com", Subdomains: false, Public: true}, // no subdomains
|
|
{DomainName: "internal.example.com", Subdomains: true, Public: false}, // not public
|
|
{DomainName: "", Subdomains: true, Public: true}, // empty name
|
|
}
|
|
r, _, _, _ := newTestReconciler(t, doms)
|
|
// No token — method returns immediately without attempting any CNAME operations
|
|
r.ensureCloudflareCNAMEs(doms)
|
|
// No panic, no error — success
|
|
}
|
|
|
|
// --- Helper tests ---
|
|
|
|
func TestIsValidCertFile_Valid(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
path := filepath.Join(tmp, "test.pem")
|
|
if err := os.WriteFile(path, []byte("--- cert content ---"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !isValidCertFile(path) {
|
|
t.Error("expected valid cert file to return true")
|
|
}
|
|
}
|
|
|
|
func TestIsValidCertFile_Empty(t *testing.T) {
|
|
tmp := t.TempDir()
|
|
path := filepath.Join(tmp, "empty.pem")
|
|
if err := os.WriteFile(path, nil, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if isValidCertFile(path) {
|
|
t.Error("expected 0-byte cert file to return false")
|
|
}
|
|
}
|
|
|
|
func TestIsValidCertFile_Missing(t *testing.T) {
|
|
if isValidCertFile("/nonexistent/path.pem") {
|
|
t.Error("expected missing file to return false")
|
|
}
|
|
}
|
|
|
|
func writeSelfSignedCert(t *testing.T, path string, notAfter time.Time) {
|
|
t.Helper()
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tmpl := &x509.Certificate{
|
|
SerialNumber: big.NewInt(1),
|
|
Subject: pkix.Name{CommonName: "test"},
|
|
NotBefore: time.Now().Add(-time.Hour),
|
|
NotAfter: notAfter,
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: der})
|
|
}
|
|
|
|
func TestCertExpiresWithin_Expired(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "expired.pem")
|
|
writeSelfSignedCert(t, path, time.Now().Add(-24*time.Hour))
|
|
if !certExpiresWithin(path, 30*24*time.Hour) {
|
|
t.Error("expected expired cert to return true")
|
|
}
|
|
}
|
|
|
|
func TestCertExpiresWithin_ExpiringSoon(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "expiring.pem")
|
|
writeSelfSignedCert(t, path, time.Now().Add(10*24*time.Hour))
|
|
if !certExpiresWithin(path, 30*24*time.Hour) {
|
|
t.Error("expected cert expiring in 10 days to return true for 30-day threshold")
|
|
}
|
|
}
|
|
|
|
func TestCertExpiresWithin_Valid(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "valid.pem")
|
|
writeSelfSignedCert(t, path, time.Now().Add(60*24*time.Hour))
|
|
if certExpiresWithin(path, 30*24*time.Hour) {
|
|
t.Error("expected cert with 60 days left to return false for 30-day threshold")
|
|
}
|
|
}
|
|
|
|
func TestCertExpiresWithin_Missing(t *testing.T) {
|
|
if certExpiresWithin("/nonexistent/cert.pem", 30*24*time.Hour) {
|
|
t.Error("expected missing file to return false")
|
|
}
|
|
}
|
|
|
|
func TestExtractHost(t *testing.T) {
|
|
tests := []struct {
|
|
input, want string
|
|
}{
|
|
{"192.168.1.1:8080", "192.168.1.1"},
|
|
{"example.com:443", "example.com"},
|
|
{"localhost", "localhost"},
|
|
{"127.0.0.1", "127.0.0.1"},
|
|
}
|
|
for _, tt := range tests {
|
|
if got := extractHost(tt.input); got != tt.want {
|
|
t.Errorf("extractHost(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|