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
+68 -9
View File
@@ -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 }