Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions cmd/signals/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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++
}
Expand Down
43 changes: 43 additions & 0 deletions internal/config/connecthost_canonicity.go
Original file line number Diff line number Diff line change
@@ -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 ""
}
36 changes: 36 additions & 0 deletions internal/config/connecthost_canonicity_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}