diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 742834c..6a48ce6 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -6,6 +6,7 @@ import ( "log/slog" "sort" "strings" + "sync" "time" "go.uber.org/fx" @@ -40,15 +41,17 @@ type Params struct { // Watcher orchestrates all monitoring checks on a schedule. type Watcher struct { - log *slog.Logger - config *config.Config - state *state.State - resolver DNSResolver - portCheck PortChecker - tlsCheck TLSChecker - notify Notifier - cancel context.CancelFunc - firstRun bool + log *slog.Logger + config *config.Config + state *state.State + resolver DNSResolver + portCheck PortChecker + tlsCheck TLSChecker + 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. @@ -57,14 +60,15 @@ func New( params Params, ) (*Watcher, error) { w := &Watcher{ - log: params.Logger.Get(), - config: params.Config, - state: params.State, - resolver: params.Resolver, - portCheck: params.PortCheck, - tlsCheck: params.TLSCheck, - notify: params.Notify, - firstRun: true, + log: params.Logger.Get(), + config: params.Config, + state: params.State, + resolver: params.Resolver, + portCheck: params.PortCheck, + tlsCheck: params.TLSCheck, + notify: params.Notify, + firstRun: true, + expiryNotified: make(map[string]time.Time), } lifecycle.Append(fx.Hook{ @@ -100,14 +104,15 @@ func NewForTest( n Notifier, ) *Watcher { return &Watcher{ - log: slog.Default(), - config: cfg, - state: st, - resolver: res, - portCheck: pc, - tlsCheck: tc, - notify: n, - firstRun: true, + log: slog.Default(), + config: cfg, + state: st, + resolver: res, + portCheck: pc, + 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)", diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go index 57b56c7..43514eb 100644 --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -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())