Add 80% limit warning, stop repeat notifications, fix review findings

Notifications:
- Warn once when a counter reaches warn_percent of its limit, through
  separate warn_*_url endpoints.
- Write ntf/ markers so a lost Redis reply cannot re-send a breach.
- Retry only when the request never left or the web app answered 429/5xx.

Fixes:
- Retry transactions Redis rejected; treat pool timeouts as definite.
- Install the signal handler before startup work.
- Stop logging the login suffix; redact notify URL credentials.
- Reject corrupt or empty CDBs, truncated hash tables included.
- Require a Sentinel password, refuse example placeholders, and default
  admin_addr to localhost.
- Normalise /counters domains, count on the arrival day, log rejections,
  hide internals in errors and exit 1 on a failed drain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dimitrije Dimitrijevic
2026-11-09 09:01:56 +01:00
co-authored by Claude Opus 5.5
parent c43ff17627
commit 54f9626121
29 changed files with 1673 additions and 245 deletions
+53 -6
View File
@@ -43,7 +43,7 @@ func TestLoad_Defaults(t *testing.T) {
want any
}{
{"addr", cfg.Addr, ":5555"},
{"admin_addr", cfg.AdminAddr, ":9095"},
{"admin_addr", cfg.AdminAddr, "127.0.0.1:9095"},
{"auth_token", cfg.AuthToken, "t"},
{"queue_size", cfg.QueueSize, 10000},
{"workers", cfg.Workers, 8},
@@ -62,9 +62,10 @@ func TestLoad_Defaults(t *testing.T) {
{"redis.pool_size", cfg.Redis.PoolSize, 32},
{"redis.timeout_ms", cfg.Redis.TimeoutMS, 500},
{"redis.sentinel_mode", cfg.Redis.SentinelMode(), false},
{"notify.timeout_seconds", cfg.Notify.TimeoutSeconds, 5},
{"notify.timeout_seconds", cfg.Notify.TimeoutSeconds, 15},
{"notify.warn_percent", cfg.Notify.WarnPercent, int64(80)},
{"redis timeout", cfg.Redis.Timeout(), 500 * time.Millisecond},
{"notify timeout", cfg.Notify.Timeout(), 5 * time.Second},
{"notify timeout", cfg.Notify.Timeout(), 15 * time.Second},
{"handler timeout", cfg.HandlerTimeoutDuration(), 4 * time.Second},
}
for _, c := range checks {
@@ -86,9 +87,17 @@ func TestLoad_Defaults(t *testing.T) {
}
}
// The shipped example must always load, validate and produce no warnings.
// The shipped example must load and validate once its placeholders are filled
// in, produce no warnings, and be refused while they are still there.
func TestLoad_Example(t *testing.T) {
cfg, err := Load("../../config.toml.example")
if _, err := Load("../../config.toml.example"); err == nil || !strings.Contains(err.Error(), "placeholder") {
t.Fatalf("the unedited example must be refused, got %v", err)
}
raw, err := os.ReadFile("../../config.toml.example")
if err != nil {
t.Fatal(err)
}
cfg, err := Load(writeConfig(t, strings.ReplaceAll(string(raw), "\"…\"", "\"filled-in\"")))
if err != nil {
t.Fatalf("Load(config.toml.example): %v", err)
}
@@ -164,7 +173,7 @@ func TestLoad_AllowedCIDRs(t *testing.T) {
}
func TestLoad_SentinelMode(t *testing.T) {
cfg := mustLoad(t, top+"[redis]\nmaster_name = \"mymaster\"\nsentinels = [\"10.0.0.1:26379\", \"10.0.0.2:26379\"]\n")
cfg := mustLoad(t, top+"[redis]\npassword = \"s\"\nmaster_name = \"mymaster\"\nsentinels = [\"10.0.0.1:26379\", \"10.0.0.2:26379\"]\n")
if !cfg.Redis.SentinelMode() || len(cfg.Redis.ReadAddrs) != 0 || cfg.Redis.WriteAddr != "" {
t.Errorf("sentinel mode config = %+v", cfg.Redis)
}
@@ -202,6 +211,14 @@ func TestValidate_Errors(t *testing.T) {
{"addr", top + "addr = \"no-port\"\n" + redisStatic, "addr"},
{"admin_addr", top + "admin_addr = \"no-port\"\n" + redisStatic, "admin_addr"},
{"notify timeout", minimal + "[notify]\ntimeout_seconds = 0\n", "timeout_seconds"},
{"sentinel needs password", top + "[redis]\nmaster_name = \"m\"\nsentinels = [\"10.0.0.1:26379\"]\n", "redis.password"},
{"placeholder token", "domains_cdb = \"/tmp/x\"\nauth_token = \"…\"\n" + redisStatic, "auth_token"},
{"placeholder redis password", top + redisStatic + "password = \"…\"\n", "redis.password"},
{"placeholder notify password", minimal + "[notify]\npassword = \"…\"\n", "notify.password"},
{"warn percent high", minimal + "[notify]\nwarn_percent = 100\n", "warn_percent"},
{"warn percent negative", minimal + "[notify]\nwarn_percent = -1\n", "warn_percent"},
{"warn url scheme", minimal + "[notify]\nwarn_org_url = \"ftp://x/w\"\n", "warn_org_url"},
{"warn url same as breach", minimal + "[notify]\norg_url = \"https://x/api/limits?level=$LEVEL\"\nwarn_org_url = \"https://x/api/limits?level=$LEVEL&percent=$PERCENT\"\n", "warn_org_url"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -286,3 +303,33 @@ func TestDiscover(t *testing.T) {
t.Errorf("Discover(\"\") = %q, %v; want %q", got, err, found)
}
}
// An explicit opt-out allows a passwordless Sentinel setup (development).
func TestLoad_SentinelPasswordOptional(t *testing.T) {
cfg := mustLoad(t, top+"[redis]\npassword_optional = true\nmaster_name = \"m\"\nsentinels = [\"10.0.0.1:26379\"]\n")
if !cfg.Redis.SentinelMode() {
t.Errorf("config = %+v", cfg.Redis)
}
}
func TestWarnings_PlainHTTPWithBasicAuth(t *testing.T) {
const q = "?level=$LEVEL&id=$ID&flow=$FLOW&period=$PERIOD&by=$ADDRESS&count=$COUNT"
cfg := mustLoad(t, minimal+"[notify]\nuser = \"u\"\norg_url = \"http://x/l"+q+"\"\n")
w := strings.Join(cfg.Warnings(), "\n")
if !strings.Contains(w, "org_url") || !strings.Contains(w, "plain http") {
t.Errorf("Warnings() = %q, want a plain-http warning for org_url", w)
}
cfg = mustLoad(t, minimal+"[notify]\nuser = \"u\"\norg_url = \"https://x/l"+q+"\"\n")
if w := cfg.Warnings(); len(w) != 0 {
t.Errorf("https should not warn: %v", w)
}
}
func TestEffectiveWarnPercent(t *testing.T) {
if got := (NotifyConfig{WarnPercent: 80}).EffectiveWarnPercent(); got != 0 {
t.Errorf("no warning URLs: %d, want 0", got)
}
if got := (NotifyConfig{WarnPercent: 80, WarnOrgURL: "https://x/w"}).EffectiveWarnPercent(); got != 80 {
t.Errorf("with a warning URL: %d, want 80", got)
}
}