fix: track multiple hostnames per IP:port in port state #65

Merged
sneak merged 1 commits from fix/issue-55-port-hostname-set into main 2026-03-02 00:32:28 +01:00
3 changed files with 152 additions and 22 deletions
Showing only changes of commit 2410776fba - Show all commits

View File

@@ -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"
} }
}, },

View File

@@ -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,

View File

@@ -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,
}) })
} }