From 02634fd098c4fb00b7d13a57b9efb23c7c51115e Mon Sep 17 00:00:00 2001 From: Frank Heikens Date: Sat, 29 Aug 2026 12:06:55 -0700 Subject: [PATCH] feat(config): warn on non-canonical target connect host (#396) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of #396 (reproduced end-to-end with two produced ZIPs): the Analyzer keys database identity on the composite (cluster_key, database_name) where cluster_key = lower(trim(host)):port, with the host echoed verbatim from the export's target_identity. Signals faithfully emits the CONFIGURED host, so collecting the same database under two different host strings (its DNS endpoint from one Signals instance and a loopback/tunnel address or a resolved IP from another) yields two distinct cluster_keys: the Analyzer holds the second as an unknown identity and its snapshots are silently held unprocessed. Signals cannot know the canonical host a database was registered under elsewhere, so it cannot unify the two — but it can stop the failure being SILENT. Add a startup WARNING when an enabled target's connect host is a loopback (localhost / 127.0.0.0/8 / ::1) or a bare IP literal: a non-canonical identity that will split against the DNS endpoint. It logs the target name + reason class only, never the host value or credentials (R078 no-leak discipline). NonCanonicalHostReason is unit-tested across loopback names, IPv4/IPv6 loopback, private + public IP literals, and DNS hostnames. closes #396 --- CHANGELOG.md | 10 +++++ cmd/signals/main.go | 18 ++++++++ internal/config/connecthost_canonicity.go | 43 +++++++++++++++++++ .../config/connecthost_canonicity_test.go | 36 ++++++++++++++++ 4 files changed, 107 insertions(+) create mode 100644 internal/config/connecthost_canonicity.go create mode 100644 internal/config/connecthost_canonicity_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 1288d74..75a0cee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ This project adheres to [Semantic Versioning](https://semver.org/). ### Added +- Startup warning when an enabled target's connect host is **non-canonical** — + a loopback (`localhost` / `127.0.0.0/8` / `::1`) or a bare IP literal (#396). + The Analyzer keys database identity on `(lower(trim(host)):port, dbname)` with + the host echoed verbatim, so collecting the same database under a different + host string (e.g. its DNS endpoint from another Signals instance) produces a + second, unrecognized identity whose snapshots are silently held unprocessed. + Signals cannot know the canonical host, so the guard is advisory — but it + surfaces the split loudly at boot (target name + reason class only; never the + host value or credentials). + - de-arq guard (CI): `scripts/check-no-legacy-arq.sh` is a baseline ratchet (replicated from Elevarq/Analyzer#2568) that blocks NEW legacy `arq` naming — case-insensitive `arq` at a word boundary (cleanly excludes `elevarq` and the diff --git a/cmd/signals/main.go b/cmd/signals/main.go index 3a7aa98..fb87026 100644 --- a/cmd/signals/main.go +++ b/cmd/signals/main.go @@ -98,6 +98,24 @@ func run() error { for _, t := range cfg.Targets { if t.Enabled { enabled++ + // #396 — warn loudly when an enabled target's connect host is a + // non-canonical identity (loopback or a bare IP literal). The + // Analyzer keys database identity on (lower(trim(host)):port, + // dbname) with the host echoed verbatim, so collecting the same + // database under a different host string (e.g. its DNS endpoint + // elsewhere) yields a SECOND, unrecognized identity whose snapshots + // are silently held unprocessed. Signals can't know the canonical + // host, so this is advisory — but it would have caught the live + // split. Target name + reason class only, never the raw host value + // or credentials (consistent with the R078 no-leak discipline). + if reason := config.NonCanonicalHostReason(t.Host); reason != "" { + slog.Warn("target connect host is non-canonical; database identity may split at the Analyzer", + "target", t.Name, + "reason", reason, + "impact", "the Analyzer keys database identity on (host:port, dbname) with the host verbatim; the same database collected under a different host string becomes a second, unrecognized identity and its snapshots are held unprocessed (Elevarq/Signals#396)", + "fix", "configure the canonical connect host — the same hostname the database is registered under elsewhere (e.g. the DNS endpoint), not a loopback/tunnel address or a resolved IP", + ) + } } else { disabled++ } diff --git a/internal/config/connecthost_canonicity.go b/internal/config/connecthost_canonicity.go new file mode 100644 index 0000000..9a2f3e1 --- /dev/null +++ b/internal/config/connecthost_canonicity.go @@ -0,0 +1,43 @@ +package config + +import ( + "net" + "strings" +) + +// NonCanonicalHostReason reports why a target's configured connect host is a +// NON-canonical database identity, or "" when the host is a canonical DNS +// hostname (#396). +// +// The Analyzer keys a database's identity on the composite +// (cluster_key, database_name) where cluster_key = lower(trim(host)):port — +// the connect host is echoed verbatim into the export's target_identity and is +// NOT reconciled to a DNS name. So collecting the SAME database under two +// different host strings (e.g. its DNS endpoint from one Signals instance and a +// loopback/tunnel address or a resolved IP from another) produces two distinct +// cluster_keys: the Analyzer recognizes the first and holds the second as an +// unknown identity, silently leaving those snapshots unprocessed. +// +// Signals cannot know which host string the database was registered under +// elsewhere, so it cannot unify them; the guard is advisory. A loopback host +// (localhost / 127.0.0.0/8 / ::1) or a bare IP literal is almost always a +// non-canonical identity that will split against the DNS endpoint the database +// is registered under, so we surface it loudly at startup. A DNS hostname is +// treated as canonical. +func NonCanonicalHostReason(host string) string { + h := strings.ToLower(strings.TrimSpace(host)) + h = strings.Trim(h, "[]") // tolerate a bracketed IPv6 literal + if h == "" { + return "" // empty host is a separate required-field validation, not this guard + } + if h == "localhost" { + return "loopback hostname \"localhost\"" + } + if ip := net.ParseIP(h); ip != nil { + if ip.IsLoopback() { + return "loopback IP address" + } + return "bare IP literal (not a DNS hostname)" + } + return "" +} diff --git a/internal/config/connecthost_canonicity_test.go b/internal/config/connecthost_canonicity_test.go new file mode 100644 index 0000000..b049944 --- /dev/null +++ b/internal/config/connecthost_canonicity_test.go @@ -0,0 +1,36 @@ +package config + +import "testing" + +// TestNonCanonicalHostReason_396 covers the connect-host canonicity guard: +// loopback hosts and bare IP literals are non-canonical (they split against the +// DNS endpoint a database is registered under); DNS hostnames are canonical. +func TestNonCanonicalHostReason_396(t *testing.T) { + canonical := []string{ + "elevarq-demo-timeseries.abc123.us-east-1.rds.amazonaws.com", + "db", + "postgres", + "my-host.internal", + "pg.svc.cluster.local", + "", // empty is a separate required-field check, not this guard + " ", + } + for _, h := range canonical { + if r := NonCanonicalHostReason(h); r != "" { + t.Errorf("NonCanonicalHostReason(%q) = %q, want \"\" (canonical)", h, r) + } + } + + nonCanonical := []string{ + "localhost", "LOCALHOST", " localhost ", + "127.0.0.1", "127.0.0.5", "127.1.2.3", + "::1", "[::1]", + "10.0.0.5", "192.168.1.10", "172.16.0.1", "8.8.8.8", + "2001:db8::1", "[2001:db8::1]", + } + for _, h := range nonCanonical { + if r := NonCanonicalHostReason(h); r == "" { + t.Errorf("NonCanonicalHostReason(%q) = \"\", want a non-canonical reason", h) + } + } +}