Pin golangci-lint to commit c0d3ddc9cf3faa61a4e378e879ece580256d76e5
(v2.12.2) in Dockerfile and script/bootstrap.
Replace .golangci.yml with a v2-schema migration of the canonical
config, produced with golangci-lint migrate (owner-authorized; the
same file becomes the new org-wide canonical via a prompts-repo PR).
Lint settings now live under linters.settings, so the lll, funlen,
cyclop, and dupl thresholds are actually applied. The deprecated
gomodguard linter is disabled in favor of gomodguard_v2, resolving
the v2.12 deprecation warning. Deliberate delta from the migrate
output: the gci formatter is not enabled because its default two-group
import ordering conflicts with the repo's stdlib/third-party/local
import style that script/fmt (gofmt + goimports) produces.
Fix all findings surfaced by the now-active thresholds:
- goconst: shared constants for repeated status, priority, and DNS
fixture strings in watcher.go and the notify, state, and watcher
tests
- dupl: consolidate duplicated ntfy/slack HTTP-error tests and
SendNotification endpoint-error tests behind shared helpers
- lll: wrap long test table entries and comments; shorten one inline
nolint justification
## 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>
State.Save() was using RLock but mutating s.snapshot.LastUpdated,
which is a write operation. This created a data race since other
goroutines could also hold a read lock and observe a partially
written timestamp. Changed to full Lock to ensure exclusive access
during the mutation.
Full project structure following upaas conventions: uber/fx DI, go-chi
routing, slog logging, Viper config. State persisted as JSON file with
per-nameserver record tracking for inconsistency detection. Stub
implementations for resolver, portcheck, tlscheck, and watcher.