fix: track multiple hostnames per IP:port in port state #65
@@ -110,8 +110,8 @@ includes:
|
||||
- **NS recoveries**: Which nameserver recovered, which hostname/domain.
|
||||
- **NS inconsistencies**: Which nameservers disagree, what each one
|
||||
returned, which hostname affected.
|
||||
- **Port changes**: Which IP:port, old state, new state, associated
|
||||
hostname.
|
||||
- **Port changes**: Which IP:port, old state, new state, all associated
|
||||
hostnames.
|
||||
- **TLS expiry warnings**: Which certificate, days remaining, CN,
|
||||
issuer, associated hostname and IP.
|
||||
- **TLS certificate changes**: Old and new CN/issuer/SANs, associated
|
||||
@@ -290,12 +290,12 @@ not as a merged view, to enable inconsistency detection.
|
||||
"ports": {
|
||||
"93.184.216.34:80": {
|
||||
"open": true,
|
||||
"hostname": "www.example.com",
|
||||
"hostnames": ["www.example.com"],
|
||||
"lastChecked": "2026-02-19T12:00:00Z"
|
||||
},
|
||||
"93.184.216.34:443": {
|
||||
"open": true,
|
||||
"hostname": "www.example.com",
|
||||
"hostnames": ["www.example.com"],
|
||||
"lastChecked": "2026-02-19T12:00:00Z"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -57,10 +57,49 @@ type HostnameState struct {
|
||||
// PortState holds the monitoring state for a port.
|
||||
type PortState struct {
|
||||
Open bool `json:"open"`
|
||||
Hostname string `json:"hostname"`
|
||||
Hostnames []string `json:"hostnames"`
|
||||
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.
|
||||
type CertificateState struct {
|
||||
CommonName string `json:"commonName"`
|
||||
@@ -263,6 +302,27 @@ func (s *State) GetPortState(key string) (*PortState, bool) {
|
||||
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.
|
||||
func (s *State) SetCertificateState(
|
||||
key string,
|
||||
|
||||
@@ -473,24 +473,94 @@ func (w *Watcher) detectInconsistencies(
|
||||
}
|
||||
|
||||
func (w *Watcher) checkAllPorts(ctx context.Context) {
|
||||
for _, hostname := range w.config.Hostnames {
|
||||
w.checkPortsForHostname(ctx, hostname)
|
||||
// Phase 1: Build current IP:port → hostname associations
|
||||
// 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
|
||||
}
|
||||
|
||||
for _, domain := range w.config.Domains {
|
||||
w.checkPortsForHostname(ctx, domain)
|
||||
w.checkSinglePort(ctx, ip, port, hostnames)
|
||||
}
|
||||
|
||||
// Phase 3: Remove port state entries that no longer have
|
||||
// any hostname referencing them.
|
||||
w.cleanupStalePorts(associations)
|
||||
}
|
||||
|
||||
func (w *Watcher) checkPortsForHostname(
|
||||
ctx context.Context,
|
||||
hostname string,
|
||||
) {
|
||||
ips := w.collectIPs(hostname)
|
||||
// buildPortAssociations constructs a map from IP:port keys to
|
||||
// the sorted set of hostnames currently resolving to that IP.
|
||||
func (w *Watcher) buildPortAssociations() map[string][]string {
|
||||
assoc := make(map[string]map[string]bool)
|
||||
|
||||
allNames := make(
|
||||
[]string, 0,
|
||||
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 {
|
||||
w.checkSinglePort(ctx, ip, port, hostname)
|
||||
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,
|
||||
ip string,
|
||||
port int,
|
||||
hostname string,
|
||||
hostnames []string,
|
||||
) {
|
||||
result, err := w.portCheck.CheckPort(ctx, ip, port)
|
||||
if err != nil {
|
||||
@@ -552,8 +622,8 @@ func (w *Watcher) checkSinglePort(
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"Host: %s\nAddress: %s\nPort now %s",
|
||||
hostname, key, stateStr,
|
||||
"Hosts: %s\nAddress: %s\nPort now %s",
|
||||
strings.Join(hostnames, ", "), key, stateStr,
|
||||
)
|
||||
|
||||
w.notify.SendNotification(
|
||||
@@ -566,7 +636,7 @@ func (w *Watcher) checkSinglePort(
|
||||
|
||||
w.state.SetPortState(key, &state.PortState{
|
||||
Open: result.Open,
|
||||
Hostname: hostname,
|
||||
Hostnames: hostnames,
|
||||
LastChecked: now,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user