fix: track multiple hostnames per IP:port in port state
Visas pārbaudes ir veiksmīgas
check / check (push) Successful in 46s

Port state keys are ip:port with a single hostname field. When multiple
hostnames resolve to the same IP (shared hosting, CDN), only one hostname
was associated. This caused orphaned port state when that hostname removed
the IP from DNS while the IP remained valid for other hostnames.

Changes:
- PortState.Hostname (string) → PortState.Hostnames ([]string)
- Custom UnmarshalJSON for backward compatibility with old state files
  that have single 'hostname' field (migrated to single-element slice)
- Refactored checkAllPorts to build IP:port→hostname associations first,
  then check each unique IP:port once with all associated hostnames
- Port state entries are cleaned up when no hostnames reference them
- Port change notifications now list all associated hostnames
- Added DeletePortState and GetAllPortKeys methods to state
- Updated README state file format documentation

Closes #55
Šī revīzija ir iekļauta:
clawbot
2026-03-01 15:18:27 -08:00
vecāks ee14bd01ae
revīzija 2410776fba
3 mainīti faili ar 152 papildinājumiem un 22 dzēšanām

Parādīt failu

@@ -110,8 +110,8 @@ includes:
- **NS recoveries**: Which nameserver recovered, which hostname/domain. - **NS recoveries**: Which nameserver recovered, which hostname/domain.
- **NS inconsistencies**: Which nameservers disagree, what each one - **NS inconsistencies**: Which nameservers disagree, what each one
returned, which hostname affected. returned, which hostname affected.
- **Port changes**: Which IP:port, old state, new state, associated - **Port changes**: Which IP:port, old state, new state, all associated
hostname. hostnames.
- **TLS expiry warnings**: Which certificate, days remaining, CN, - **TLS expiry warnings**: Which certificate, days remaining, CN,
issuer, associated hostname and IP. issuer, associated hostname and IP.
- **TLS certificate changes**: Old and new CN/issuer/SANs, associated - **TLS certificate changes**: Old and new CN/issuer/SANs, associated
@@ -290,12 +290,12 @@ not as a merged view, to enable inconsistency detection.
"ports": { "ports": {
"93.184.216.34:80": { "93.184.216.34:80": {
"open": true, "open": true,
"hostname": "www.example.com", "hostnames": ["www.example.com"],
"lastChecked": "2026-02-19T12:00:00Z" "lastChecked": "2026-02-19T12:00:00Z"
}, },
"93.184.216.34:443": { "93.184.216.34:443": {
"open": true, "open": true,
"hostname": "www.example.com", "hostnames": ["www.example.com"],
"lastChecked": "2026-02-19T12:00:00Z" "lastChecked": "2026-02-19T12:00:00Z"
} }
}, },

Parādīt failu

@@ -57,10 +57,49 @@ type HostnameState struct {
// PortState holds the monitoring state for a port. // PortState holds the monitoring state for a port.
type PortState struct { type PortState struct {
Open bool `json:"open"` Open bool `json:"open"`
Hostname string `json:"hostname"` Hostnames []string `json:"hostnames"`
LastChecked time.Time `json:"lastChecked"` LastChecked time.Time `json:"lastChecked"`
} }
// UnmarshalJSON implements custom unmarshaling to handle both
// the old single-hostname format and the new multi-hostname
// format for backward compatibility with existing state files.
func (ps *PortState) UnmarshalJSON(data []byte) error {
// Use an alias to prevent infinite recursion.
type portStateAlias struct {
Open bool `json:"open"`
Hostnames []string `json:"hostnames"`
LastChecked time.Time `json:"lastChecked"`
}
var alias portStateAlias
err := json.Unmarshal(data, &alias)
if err != nil {
return fmt.Errorf("unmarshaling port state: %w", err)
}
ps.Open = alias.Open
ps.Hostnames = alias.Hostnames
ps.LastChecked = alias.LastChecked
// If Hostnames is empty, try reading the old single-hostname
// format for backward compatibility.
if len(ps.Hostnames) == 0 {
var old struct {
Hostname string `json:"hostname"`
}
// Best-effort: ignore errors since the main unmarshal
// already succeeded.
if json.Unmarshal(data, &old) == nil && old.Hostname != "" {
ps.Hostnames = []string{old.Hostname}
}
}
return nil
}
// CertificateState holds TLS certificate monitoring state. // CertificateState holds TLS certificate monitoring state.
type CertificateState struct { type CertificateState struct {
CommonName string `json:"commonName"` CommonName string `json:"commonName"`
@@ -263,6 +302,27 @@ func (s *State) GetPortState(key string) (*PortState, bool) {
return ps, ok return ps, ok
} }
// DeletePortState removes a port state entry.
func (s *State) DeletePortState(key string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.snapshot.Ports, key)
}
// GetAllPortKeys returns all port state keys.
func (s *State) GetAllPortKeys() []string {
s.mu.RLock()
defer s.mu.RUnlock()
keys := make([]string, 0, len(s.snapshot.Ports))
for k := range s.snapshot.Ports {
keys = append(keys, k)
}
return keys
}
// SetCertificateState updates the state for a certificate. // SetCertificateState updates the state for a certificate.
func (s *State) SetCertificateState( func (s *State) SetCertificateState(
key string, key string,

Parādīt failu

@@ -473,24 +473,94 @@ func (w *Watcher) detectInconsistencies(
} }
func (w *Watcher) checkAllPorts(ctx context.Context) { func (w *Watcher) checkAllPorts(ctx context.Context) {
for _, hostname := range w.config.Hostnames { // Phase 1: Build current IP:port → hostname associations
w.checkPortsForHostname(ctx, hostname) // from fresh DNS data.
associations := w.buildPortAssociations()
// Phase 2: Check each unique IP:port and update state
// with the full set of associated hostnames.
for key, hostnames := range associations {
ip, port := parsePortKey(key)
if port == 0 {
continue
}
w.checkSinglePort(ctx, ip, port, hostnames)
} }
for _, domain := range w.config.Domains { // Phase 3: Remove port state entries that no longer have
w.checkPortsForHostname(ctx, domain) // any hostname referencing them.
} w.cleanupStalePorts(associations)
} }
func (w *Watcher) checkPortsForHostname( // buildPortAssociations constructs a map from IP:port keys to
ctx context.Context, // the sorted set of hostnames currently resolving to that IP.
hostname string, func (w *Watcher) buildPortAssociations() map[string][]string {
) { assoc := make(map[string]map[string]bool)
ips := w.collectIPs(hostname)
for _, ip := range ips { allNames := make(
for _, port := range monitoredPorts { []string, 0,
w.checkSinglePort(ctx, ip, port, hostname) len(w.config.Hostnames)+len(w.config.Domains),
)
allNames = append(allNames, w.config.Hostnames...)
allNames = append(allNames, w.config.Domains...)
for _, name := range allNames {
ips := w.collectIPs(name)
for _, ip := range ips {
for _, port := range monitoredPorts {
key := fmt.Sprintf("%s:%d", ip, port)
if assoc[key] == nil {
assoc[key] = make(map[string]bool)
}
assoc[key][name] = true
}
}
}
result := make(map[string][]string, len(assoc))
for key, set := range assoc {
hostnames := make([]string, 0, len(set))
for h := range set {
hostnames = append(hostnames, h)
}
sort.Strings(hostnames)
result[key] = hostnames
}
return result
}
// parsePortKey splits an "ip:port" key into its components.
func parsePortKey(key string) (string, int) {
lastColon := strings.LastIndex(key, ":")
if lastColon < 0 {
return key, 0
}
ip := key[:lastColon]
var p int
_, err := fmt.Sscanf(key[lastColon+1:], "%d", &p)
if err != nil {
return ip, 0
}
return ip, p
}
// cleanupStalePorts removes port state entries that are no
// longer referenced by any hostname in the current DNS data.
func (w *Watcher) cleanupStalePorts(
currentAssociations map[string][]string,
) {
for _, key := range w.state.GetAllPortKeys() {
if _, exists := currentAssociations[key]; !exists {
w.state.DeletePortState(key)
} }
} }
} }
@@ -527,7 +597,7 @@ func (w *Watcher) checkSinglePort(
ctx context.Context, ctx context.Context,
ip string, ip string,
port int, port int,
hostname string, hostnames []string,
) { ) {
result, err := w.portCheck.CheckPort(ctx, ip, port) result, err := w.portCheck.CheckPort(ctx, ip, port)
if err != nil { if err != nil {
@@ -552,8 +622,8 @@ func (w *Watcher) checkSinglePort(
} }
msg := fmt.Sprintf( msg := fmt.Sprintf(
"Host: %s\nAddress: %s\nPort now %s", "Hosts: %s\nAddress: %s\nPort now %s",
hostname, key, stateStr, strings.Join(hostnames, ", "), key, stateStr,
) )
w.notify.SendNotification( w.notify.SendNotification(
@@ -566,7 +636,7 @@ func (w *Watcher) checkSinglePort(
w.state.SetPortState(key, &state.PortState{ w.state.SetPortState(key, &state.PortState{
Open: result.Open, Open: result.Open,
Hostname: hostname, Hostnames: hostnames,
LastChecked: now, LastChecked: now,
}) })
} }