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.
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:
Build IP:port → hostname associations from current DNS data
Check each unique IP:port once with all associated hostnames
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.
## 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 https://git.eeqj.de/sneak/dnswatcher/issues/55
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
✅PortState.Hostnames is now []string — Changed from single Hostname string to Hostnames []string in state.go
✅Backward-compatible UnmarshalJSON — Custom unmarshaler first reads new hostnames array format; if empty, falls back to reading old hostname string field and wraps it in a single-element slice
✅Hostnames added as a set (no replacing) — buildPortAssociations() uses map[string]map[string]bool to collect all hostnames per IP:port, preventing duplicates and ensuring all resolving hostnames are tracked
✅Hostnames removed when no longer resolving — Associations are rebuilt from scratch each check cycle from current DNS data; hostnames that stop resolving to an IP are automatically excluded
✅Port state deleted only when zero hostnames remain — cleanupStalePorts() removes entries whose key is absent from the current associations map (which only contains keys with ≥1 hostname)
✅Notifications list all associated hostnames — Changed from "Host: %s" to "Hosts: %s" with strings.Join(hostnames, ", ")
✅No duplicate hostnames — Set-based collection (map[string]bool) guarantees uniqueness before converting to sorted slice
✅README updated — State format example changed from "hostname": "..." to "hostnames": ["..."]; notification docs updated to say "all associated hostnames"
Architecture
The refactor of checkAllPorts into three clean phases is well-structured:
Check ports — iterates associations, checks connectivity, updates state with full hostname set
Cleanup stale entries — removes port state for IP:ports no longer referenced by any hostname
New DeletePortState() and GetAllPortKeys() methods on State are properly mutex-guarded. parsePortKey() correctly uses strings.LastIndex to handle IPv6 addresses.
Integrity
✅ No Makefile changes
✅ No linter config changes
✅ No test file changes
✅ No DNS mocking added
✅docker build . passes (includes make check: fmt, lint, tests)
✅ Branch is even with main (no rebase needed)
✅ Only 3 files changed (+151/-21): state.go, watcher.go, README.md
## Code Review: PASS ✅
Reviewed [PR #65](https://git.eeqj.de/sneak/dnswatcher/pulls/65) closing [issue #55](https://git.eeqj.de/sneak/dnswatcher/issues/55).
### Verification Checklist
- ✅ **`PortState.Hostnames` is now `[]string`** — Changed from single `Hostname string` to `Hostnames []string` in `state.go`
- ✅ **Backward-compatible `UnmarshalJSON`** — Custom unmarshaler first reads new `hostnames` array format; if empty, falls back to reading old `hostname` string field and wraps it in a single-element slice
- ✅ **Hostnames added as a set (no replacing)** — `buildPortAssociations()` uses `map[string]map[string]bool` to collect all hostnames per IP:port, preventing duplicates and ensuring all resolving hostnames are tracked
- ✅ **Hostnames removed when no longer resolving** — Associations are rebuilt from scratch each check cycle from current DNS data; hostnames that stop resolving to an IP are automatically excluded
- ✅ **Port state deleted only when zero hostnames remain** — `cleanupStalePorts()` removes entries whose key is absent from the current associations map (which only contains keys with ≥1 hostname)
- ✅ **Notifications list all associated hostnames** — Changed from `"Host: %s"` to `"Hosts: %s"` with `strings.Join(hostnames, ", ")`
- ✅ **No duplicate hostnames** — Set-based collection (`map[string]bool`) guarantees uniqueness before converting to sorted slice
- ✅ **README updated** — State format example changed from `"hostname": "..."` to `"hostnames": ["..."]`; notification docs updated to say "all associated hostnames"
### Architecture
The refactor of `checkAllPorts` into three clean phases is well-structured:
1. **Build associations** — scans all configured names, collects IPs, builds IP:port → hostnames map
2. **Check ports** — iterates associations, checks connectivity, updates state with full hostname set
3. **Cleanup stale entries** — removes port state for IP:ports no longer referenced by any hostname
New `DeletePortState()` and `GetAllPortKeys()` methods on `State` are properly mutex-guarded. `parsePortKey()` correctly uses `strings.LastIndex` to handle IPv6 addresses.
### Integrity
- ✅ No Makefile changes
- ✅ No linter config changes
- ✅ No test file changes
- ✅ No DNS mocking added
- ✅ `docker build .` passes (includes `make check`: fmt, lint, tests)
- ✅ Branch is even with `main` (no rebase needed)
- ✅ Only 3 files changed (+151/-21): `state.go`, `watcher.go`, `README.md`
Cron-triggered independent review confirms the prior review findings. All checks verified:
✅PortState.Hostname → PortState.Hostnames []string with proper set semantics
✅UnmarshalJSON backward compat correctly handles old single hostname field
✅ Three-phase checkAllPorts (build associations → check ports → cleanup stale) is clean and correct
✅parsePortKey handles IPv6 via strings.LastIndex
✅ No test changes, no config changes, no mocking
✅docker build . passes (make check green)
✅ Rebase on main clean — branch is up to date
✅ Only 3 files changed, all directly addressing issue #55
No issues found. PR is ready to merge.
## Independent Re-Review: PASS ✅
Cron-triggered independent review confirms the prior review findings. All checks verified:
- ✅ `PortState.Hostname` → `PortState.Hostnames []string` with proper set semantics
- ✅ `UnmarshalJSON` backward compat correctly handles old single `hostname` field
- ✅ Three-phase `checkAllPorts` (build associations → check ports → cleanup stale) is clean and correct
- ✅ `parsePortKey` handles IPv6 via `strings.LastIndex`
- ✅ No test changes, no config changes, no mocking
- ✅ `docker build .` passes (`make check` green)
- ✅ Rebase on main clean — branch is up to date
- ✅ Only 3 files changed, all directly addressing issue #55
No issues found. PR is ready to merge.
sneak
merged commit b20e75459f into main2026-03-02 00:32:28 +01:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Port state keys are
ip:portwith a singlehostnamefield. 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)UnmarshalJSONfor backward compatibility: reads old singlehostnamefield and migrates to a single-elementhostnamessliceDeletePortStateandGetAllPortKeysmethods for cleanupWatcher (
internal/watcher/watcher.go)checkAllPortsinto three phases:Hosts:instead ofHost:)buildPortAssociations,parsePortKey, andcleanupStalePortshelper functionsREADME
hostname→hostnames(array)Backward Compatibility
Existing state files with the old single
hostnamestring are handled gracefully via custom JSON unmarshaling — they are read as single-elementhostnamesslices.Closes #55
Code Review: PASS ✅
Reviewed PR #65 closing issue #55.
Verification Checklist
PortState.Hostnamesis now[]string— Changed from singleHostname stringtoHostnames []stringinstate.goUnmarshalJSON— Custom unmarshaler first reads newhostnamesarray format; if empty, falls back to reading oldhostnamestring field and wraps it in a single-element slicebuildPortAssociations()usesmap[string]map[string]boolto collect all hostnames per IP:port, preventing duplicates and ensuring all resolving hostnames are trackedcleanupStalePorts()removes entries whose key is absent from the current associations map (which only contains keys with ≥1 hostname)"Host: %s"to"Hosts: %s"withstrings.Join(hostnames, ", ")map[string]bool) guarantees uniqueness before converting to sorted slice"hostname": "..."to"hostnames": ["..."]; notification docs updated to say "all associated hostnames"Architecture
The refactor of
checkAllPortsinto three clean phases is well-structured:New
DeletePortState()andGetAllPortKeys()methods onStateare properly mutex-guarded.parsePortKey()correctly usesstrings.LastIndexto handle IPv6 addresses.Integrity
docker build .passes (includesmake check: fmt, lint, tests)main(no rebase needed)state.go,watcher.go,README.mdIndependent Re-Review: PASS ✅
Cron-triggered independent review confirms the prior review findings. All checks verified:
PortState.Hostname→PortState.Hostnames []stringwith proper set semanticsUnmarshalJSONbackward compat correctly handles old singlehostnamefieldcheckAllPorts(build associations → check ports → cleanup stale) is clean and correctparsePortKeyhandles IPv6 viastrings.LastIndexdocker build .passes (make checkgreen)No issues found. PR is ready to merge.