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:
co-authored by
Claude Opus 5.5
parent
c43ff17627
commit
54f9626121
@@ -29,6 +29,10 @@ const (
|
||||
EnvNotifyPassword = "MIGADU_COUNTERS_NOTIFY_PASSWORD"
|
||||
)
|
||||
|
||||
// examplePlaceholder marks the secrets in config.toml.example that must be
|
||||
// replaced before use.
|
||||
const examplePlaceholder = "…"
|
||||
|
||||
// NotifyPlaceholders are the template variables every notify URL is expected
|
||||
// to carry: the web app's /api/limits action pattern-matches all six query
|
||||
// parameters and answers 500 when one is missing.
|
||||
@@ -84,10 +88,12 @@ func (l LoggingConfig) CountingEnabled() bool { return l.CountingOutput != "off"
|
||||
// Sentinel mode is used when Sentinels is non-empty; otherwise the static
|
||||
// WriteAddr/ReadAddrs are used (development, tests).
|
||||
type RedisConfig struct {
|
||||
DB int `toml:"db"`
|
||||
Password string `toml:"password"`
|
||||
PoolSize int `toml:"pool_size"`
|
||||
TimeoutMS int `toml:"timeout_ms"`
|
||||
DB int `toml:"db"`
|
||||
Password string `toml:"password"`
|
||||
// PasswordOptional allows Sentinel mode without a password (development).
|
||||
PasswordOptional bool `toml:"password_optional"`
|
||||
PoolSize int `toml:"pool_size"`
|
||||
TimeoutMS int `toml:"timeout_ms"`
|
||||
|
||||
MasterName string `toml:"master_name"`
|
||||
Sentinels []string `toml:"sentinels"`
|
||||
@@ -106,9 +112,15 @@ func (r RedisConfig) Timeout() time.Duration { return time.Duration(r.TimeoutMS)
|
||||
// NotifyConfig holds the per-level web app notification URLs. An empty URL
|
||||
// disables notifications for that level.
|
||||
type NotifyConfig struct {
|
||||
OrgURL string `toml:"org_url"`
|
||||
AddressURL string `toml:"address_url"`
|
||||
DomainURL string `toml:"domain_url"`
|
||||
OrgURL string `toml:"org_url"`
|
||||
AddressURL string `toml:"address_url"`
|
||||
DomainURL string `toml:"domain_url"`
|
||||
// Early warning: WarnPercent of a limit triggers a call to the warn_* URL
|
||||
// of that level (0 = off). An empty URL disables that level.
|
||||
WarnPercent int64 `toml:"warn_percent"`
|
||||
WarnOrgURL string `toml:"warn_org_url"`
|
||||
WarnAddressURL string `toml:"warn_address_url"`
|
||||
WarnDomainURL string `toml:"warn_domain_url"`
|
||||
User string `toml:"user"`
|
||||
Password string `toml:"password"`
|
||||
TimeoutSeconds int `toml:"timeout_seconds"`
|
||||
@@ -117,11 +129,20 @@ type NotifyConfig struct {
|
||||
// Timeout is the per-attempt HTTP timeout for a notification.
|
||||
func (n NotifyConfig) Timeout() time.Duration { return time.Duration(n.TimeoutSeconds) * time.Second }
|
||||
|
||||
// EffectiveWarnPercent is WarnPercent, or 0 when no warning URL is configured,
|
||||
// so an unused feature adds no markers, metrics or log lines.
|
||||
func (n NotifyConfig) EffectiveWarnPercent() int64 {
|
||||
if n.WarnOrgURL == "" && n.WarnAddressURL == "" && n.WarnDomainURL == "" {
|
||||
return 0
|
||||
}
|
||||
return n.WarnPercent
|
||||
}
|
||||
|
||||
// Default returns the configuration with every default applied.
|
||||
func Default() Config {
|
||||
return Config{
|
||||
Addr: ":5555",
|
||||
AdminAddr: ":9095",
|
||||
AdminAddr: "127.0.0.1:9095",
|
||||
Exempt: []string{"support@migadu.com", "contact@migadu.com"},
|
||||
QueueSize: 10000,
|
||||
Workers: 8,
|
||||
@@ -132,7 +153,7 @@ func Default() Config {
|
||||
HandlerTimeout: 4,
|
||||
Logging: LoggingConfig{Output: "stderr", Format: "console", Level: "info", CountingFormat: "text"},
|
||||
Redis: RedisConfig{PoolSize: 32, TimeoutMS: 500},
|
||||
Notify: NotifyConfig{TimeoutSeconds: 5},
|
||||
Notify: NotifyConfig{TimeoutSeconds: 15, WarnPercent: 80},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +246,13 @@ func (c *Config) Validate() error {
|
||||
if strings.TrimSpace(c.AuthToken) == "" {
|
||||
fail("auth_token is required; set it in the config or %s", EnvAuthToken)
|
||||
}
|
||||
for _, p := range []struct{ name, value string }{
|
||||
{"auth_token", c.AuthToken}, {"redis.password", c.Redis.Password}, {"notify.password", c.Notify.Password},
|
||||
} {
|
||||
if strings.TrimSpace(p.value) == examplePlaceholder {
|
||||
fail("%s still holds the example placeholder %q", p.name, examplePlaceholder)
|
||||
}
|
||||
}
|
||||
if c.QueueSize <= 0 {
|
||||
fail("queue_size must be > 0")
|
||||
}
|
||||
@@ -282,6 +310,9 @@ func (c *Config) Validate() error {
|
||||
fail("redis.timeout_ms must be > 0")
|
||||
}
|
||||
if c.Redis.SentinelMode() {
|
||||
if c.Redis.Password == "" && !c.Redis.PasswordOptional {
|
||||
fail("redis.password is required in Sentinel mode; set it or %s, or set redis.password_optional = true", EnvRedisPassword)
|
||||
}
|
||||
if c.Redis.MasterName == "" {
|
||||
fail("redis.master_name is required when redis.sentinels is set")
|
||||
}
|
||||
@@ -315,6 +346,18 @@ func (c *Config) Validate() error {
|
||||
if c.Notify.TimeoutSeconds <= 0 {
|
||||
fail("notify.timeout_seconds must be > 0")
|
||||
}
|
||||
if c.Notify.WarnPercent < 0 || c.Notify.WarnPercent > 99 {
|
||||
fail("notify.warn_percent must be between 0 and 99 (got %d)", c.Notify.WarnPercent)
|
||||
}
|
||||
for _, p := range []struct{ name, warn, breach string }{
|
||||
{"warn_org_url", c.Notify.WarnOrgURL, c.Notify.OrgURL},
|
||||
{"warn_address_url", c.Notify.WarnAddressURL, c.Notify.AddressURL},
|
||||
{"warn_domain_url", c.Notify.WarnDomainURL, c.Notify.DomainURL},
|
||||
} {
|
||||
if p.warn != "" && p.breach != "" && samePath(p.warn, p.breach) {
|
||||
fail("notify.%s must not use the same path as the breach URL: the web app treats every call there as a breach", p.name)
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
@@ -331,6 +374,9 @@ func (c *Config) Warnings() []string {
|
||||
w = append(w, fmt.Sprintf("notify.%s: missing %s placeholder (the web app requires all six query parameters)", u.name, ph))
|
||||
}
|
||||
}
|
||||
if c.Notify.User != "" && strings.HasPrefix(strings.ToLower(u.url), "http://") {
|
||||
w = append(w, fmt.Sprintf("notify.%s: basic auth credentials would travel over plain http", u.name))
|
||||
}
|
||||
}
|
||||
return w
|
||||
}
|
||||
@@ -340,9 +386,22 @@ func (c *Config) notifyURLs() []struct{ name, url string } {
|
||||
{"org_url", c.Notify.OrgURL},
|
||||
{"address_url", c.Notify.AddressURL},
|
||||
{"domain_url", c.Notify.DomainURL},
|
||||
{"warn_org_url", c.Notify.WarnOrgURL},
|
||||
{"warn_address_url", c.Notify.WarnAddressURL},
|
||||
{"warn_domain_url", c.Notify.WarnDomainURL},
|
||||
}
|
||||
}
|
||||
|
||||
// samePath reports whether two URLs differ at most in their query string.
|
||||
func samePath(a, b string) bool {
|
||||
ua, errA := url.Parse(a)
|
||||
ub, errB := url.Parse(b)
|
||||
if errA != nil || errB != nil {
|
||||
return false
|
||||
}
|
||||
return ua.Scheme == ub.Scheme && ua.Host == ub.Host && strings.TrimRight(ua.Path, "/") == strings.TrimRight(ub.Path, "/")
|
||||
}
|
||||
|
||||
// ExemptSet returns the normalised exempt addresses.
|
||||
func (c *Config) ExemptSet() map[string]bool { return c.exempt }
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user