fix: remove ErrNotImplemented stub — all checks fully implemented (closes #16) #23
@@ -6,6 +6,7 @@ import (
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
@@ -49,6 +50,8 @@ type Watcher struct {
|
||||
notify Notifier
|
||||
cancel context.CancelFunc
|
||||
firstRun bool
|
||||
expiryNotifiedMu sync.Mutex
|
||||
expiryNotified map[string]time.Time
|
||||
}
|
||||
|
||||
// New creates a new Watcher instance wired into the fx lifecycle.
|
||||
@@ -65,6 +68,7 @@ func New(
|
||||
tlsCheck: params.TLSCheck,
|
||||
notify: params.Notify,
|
||||
firstRun: true,
|
||||
expiryNotified: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
lifecycle.Append(fx.Hook{
|
||||
@@ -108,6 +112,7 @@ func NewForTest(
|
||||
tlsCheck: tc,
|
||||
notify: n,
|
||||
firstRun: true,
|
||||
expiryNotified: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +211,28 @@ func (w *Watcher) checkDomain(
|
||||
Nameservers: nameservers,
|
||||
LastChecked: now,
|
||||
})
|
||||
|
||||
// Also look up A/AAAA records for the apex domain so that
|
||||
// port and TLS checks (which read HostnameState) can find
|
||||
// the domain's IP addresses.
|
||||
records, err := w.resolver.LookupAllRecords(ctx, domain)
|
||||
if err != nil {
|
||||
w.log.Error(
|
||||
"failed to lookup records for domain",
|
||||
"domain", domain,
|
||||
"error", err,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
prevHS, hasPrevHS := w.state.GetHostnameState(domain)
|
||||
if hasPrevHS && !w.firstRun {
|
||||
w.detectHostnameChanges(ctx, domain, prevHS, records)
|
||||
}
|
||||
|
||||
newState := buildHostnameState(records, now)
|
||||
w.state.SetHostnameState(domain, newState)
|
||||
}
|
||||
|
||||
func (w *Watcher) detectNSChanges(
|
||||
@@ -691,6 +718,22 @@ func (w *Watcher) checkTLSExpiry(
|
||||
return
|
||||
}
|
||||
|
||||
// Deduplicate expiry warnings: don't re-notify for the same
|
||||
// hostname within the TLS check interval.
|
||||
dedupKey := fmt.Sprintf("expiry:%s:%s", hostname, ip)
|
||||
|
||||
w.expiryNotifiedMu.Lock()
|
||||
|
||||
lastNotified, seen := w.expiryNotified[dedupKey]
|
||||
if seen && time.Since(lastNotified) < w.config.TLSInterval {
|
||||
w.expiryNotifiedMu.Unlock()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.expiryNotified[dedupKey] = time.Now()
|
||||
w.expiryNotifiedMu.Unlock()
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"Host: %s\nIP: %s\nCN: %s\n"+
|
||||
"Expires: %s (%.0f days)",
|
||||
|
||||
@@ -273,6 +273,10 @@ func setupBaselineMocks(deps *testDeps) {
|
||||
"ns1.example.com.",
|
||||
"ns2.example.com.",
|
||||
}
|
||||
deps.resolver.allRecords["example.com"] = map[string]map[string][]string{
|
||||
"ns1.example.com.": {"A": {"93.184.216.34"}},
|
||||
"ns2.example.com.": {"A": {"93.184.216.34"}},
|
||||
}
|
||||
deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
|
||||
"ns1.example.com.": {"A": {"93.184.216.34"}},
|
||||
"ns2.example.com.": {"A": {"93.184.216.34"}},
|
||||
@@ -290,6 +294,14 @@ func setupBaselineMocks(deps *testDeps) {
|
||||
"www.example.com",
|
||||
},
|
||||
}
|
||||
deps.tlsChecker.certs["93.184.216.34:example.com"] = &tlscheck.CertificateInfo{
|
||||
CommonName: "example.com",
|
||||
Issuer: "DigiCert",
|
||||
NotAfter: time.Now().Add(90 * 24 * time.Hour),
|
||||
SubjectAlternativeNames: []string{
|
||||
"example.com",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoNotifications(
|
||||
@@ -322,14 +334,74 @@ func assertStatePopulated(
|
||||
)
|
||||
}
|
||||
|
||||
if len(snap.Hostnames) != 1 {
|
||||
// Hostnames includes both explicit hostnames and domains
|
||||
// (domains now also get hostname state for port/TLS checks).
|
||||
if len(snap.Hostnames) < 1 {
|
||||
t.Errorf(
|
||||
"expected 1 hostname in state, got %d",
|
||||
"expected at least 1 hostname in state, got %d",
|
||||
len(snap.Hostnames),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainPortAndTLSChecks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := defaultTestConfig(t)
|
||||
cfg.Domains = []string{"example.com"}
|
||||
|
||||
w, deps := newTestWatcher(t, cfg)
|
||||
|
||||
deps.resolver.nsRecords["example.com"] = []string{
|
||||
"ns1.example.com.",
|
||||
}
|
||||
deps.resolver.allRecords["example.com"] = map[string]map[string][]string{
|
||||
"ns1.example.com.": {"A": {"93.184.216.34"}},
|
||||
}
|
||||
deps.portChecker.results["93.184.216.34:80"] = true
|
||||
deps.portChecker.results["93.184.216.34:443"] = true
|
||||
deps.tlsChecker.certs["93.184.216.34:example.com"] = &tlscheck.CertificateInfo{
|
||||
CommonName: "example.com",
|
||||
Issuer: "DigiCert",
|
||||
NotAfter: time.Now().Add(90 * 24 * time.Hour),
|
||||
SubjectAlternativeNames: []string{
|
||||
"example.com",
|
||||
},
|
||||
}
|
||||
|
||||
w.RunOnce(t.Context())
|
||||
|
||||
snap := deps.state.GetSnapshot()
|
||||
|
||||
// Domain should have port state populated
|
||||
if len(snap.Ports) == 0 {
|
||||
t.Error("expected port state for domain, got none")
|
||||
}
|
||||
|
||||
// Domain should have certificate state populated
|
||||
if len(snap.Certificates) == 0 {
|
||||
t.Error("expected certificate state for domain, got none")
|
||||
}
|
||||
|
||||
// Verify port checker was actually called
|
||||
deps.portChecker.mu.Lock()
|
||||
calls := deps.portChecker.calls
|
||||
deps.portChecker.mu.Unlock()
|
||||
|
||||
if calls == 0 {
|
||||
t.Error("expected port checker to be called for domain")
|
||||
}
|
||||
|
||||
// Verify TLS checker was actually called
|
||||
deps.tlsChecker.mu.Lock()
|
||||
tlsCalls := deps.tlsChecker.calls
|
||||
deps.tlsChecker.mu.Unlock()
|
||||
|
||||
if tlsCalls == 0 {
|
||||
t.Error("expected TLS checker to be called for domain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNSChangeDetection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -342,6 +414,12 @@ func TestNSChangeDetection(t *testing.T) {
|
||||
"ns1.example.com.",
|
||||
"ns2.example.com.",
|
||||
}
|
||||
deps.resolver.allRecords["example.com"] = map[string]map[string][]string{
|
||||
"ns1.example.com.": {"A": {"1.2.3.4"}},
|
||||
"ns2.example.com.": {"A": {"1.2.3.4"}},
|
||||
}
|
||||
deps.portChecker.results["1.2.3.4:80"] = false
|
||||
deps.portChecker.results["1.2.3.4:443"] = false
|
||||
|
||||
ctx := t.Context()
|
||||
w.RunOnce(ctx)
|
||||
@@ -351,6 +429,10 @@ func TestNSChangeDetection(t *testing.T) {
|
||||
"ns1.example.com.",
|
||||
"ns3.example.com.",
|
||||
}
|
||||
deps.resolver.allRecords["example.com"] = map[string]map[string][]string{
|
||||
"ns1.example.com.": {"A": {"1.2.3.4"}},
|
||||
"ns3.example.com.": {"A": {"1.2.3.4"}},
|
||||
}
|
||||
deps.resolver.mu.Unlock()
|
||||
|
||||
w.RunOnce(ctx)
|
||||
@@ -506,6 +588,61 @@ func TestTLSExpiryWarning(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSExpiryWarningDedup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := defaultTestConfig(t)
|
||||
cfg.Hostnames = []string{"www.example.com"}
|
||||
cfg.TLSInterval = 24 * time.Hour
|
||||
|
||||
w, deps := newTestWatcher(t, cfg)
|
||||
|
||||
deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
|
||||
"ns1.example.com.": {"A": {"1.2.3.4"}},
|
||||
}
|
||||
deps.resolver.ipAddresses["www.example.com"] = []string{
|
||||
"1.2.3.4",
|
||||
}
|
||||
deps.portChecker.results["1.2.3.4:80"] = true
|
||||
deps.portChecker.results["1.2.3.4:443"] = true
|
||||
deps.tlsChecker.certs["1.2.3.4:www.example.com"] = &tlscheck.CertificateInfo{
|
||||
CommonName: "www.example.com",
|
||||
Issuer: "DigiCert",
|
||||
NotAfter: time.Now().Add(3 * 24 * time.Hour),
|
||||
SubjectAlternativeNames: []string{
|
||||
"www.example.com",
|
||||
},
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
|
||||
// First run = baseline, no notifications
|
||||
w.RunOnce(ctx)
|
||||
|
||||
// Second run should fire one expiry warning
|
||||
w.RunOnce(ctx)
|
||||
|
||||
// Third run should NOT fire another warning (dedup)
|
||||
w.RunOnce(ctx)
|
||||
|
||||
notifications := deps.notifier.getNotifications()
|
||||
|
||||
expiryCount := 0
|
||||
|
||||
for _, n := range notifications {
|
||||
if n.Title == "TLS Expiry Warning: www.example.com" {
|
||||
expiryCount++
|
||||
}
|
||||
}
|
||||
|
||||
if expiryCount != 1 {
|
||||
t.Errorf(
|
||||
"expected exactly 1 expiry warning (dedup), got %d",
|
||||
expiryCount,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGracefulShutdown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -519,6 +656,11 @@ func TestGracefulShutdown(t *testing.T) {
|
||||
deps.resolver.nsRecords["example.com"] = []string{
|
||||
"ns1.example.com.",
|
||||
}
|
||||
deps.resolver.allRecords["example.com"] = map[string]map[string][]string{
|
||||
"ns1.example.com.": {"A": {"1.2.3.4"}},
|
||||
}
|
||||
deps.portChecker.results["1.2.3.4:80"] = false
|
||||
deps.portChecker.results["1.2.3.4:443"] = false
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user