fix: track multiple hostnames per IP:port in port state (#65)
All checks were successful
check / check (push) Successful in 34s
All checks were successful
check / check (push) Successful in 34s
## Summary 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 ### State (`internal/state/state.go`) - `PortState.Hostname` (string) → `PortState.Hostnames` ([]string) - Custom `UnmarshalJSON` for backward compatibility: reads old single `hostname` field and migrates to a single-element `hostnames` slice - Added `DeletePortState` and `GetAllPortKeys` methods for cleanup ### Watcher (`internal/watcher/watcher.go`) - Refactored `checkAllPorts` into three phases: 1. Build IP:port → hostname associations from current DNS data 2. Check each unique IP:port once with all associated hostnames 3. Clean up stale port state entries with no hostname references - Port change notifications now list all associated hostnames (`Hosts:` instead of `Host:`) - Added `buildPortAssociations`, `parsePortKey`, and `cleanupStalePorts` helper functions ### README - Updated state file format example: `hostname` → `hostnames` (array) - Updated notification description to reflect multiple hostnames ## Backward Compatibility Existing state files with the old single `hostname` string are handled gracefully via custom JSON unmarshaling — they are read as single-element `hostnames` slices. Closes #55 Co-authored-by: clawbot <clawbot@noreply.eeqj.de> Reviewed-on: #65 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
This commit was merged in pull request #65.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
func (w *Watcher) checkPortsForHostname(
|
||||
ctx context.Context,
|
||||
hostname string,
|
||||
) {
|
||||
ips := w.collectIPs(hostname)
|
||||
// Phase 3: Remove port state entries that no longer have
|
||||
// any hostname referencing them.
|
||||
w.cleanupStalePorts(associations)
|
||||
}
|
||||
|
||||
// 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