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
+33 -12
View File
@@ -40,7 +40,7 @@ func (a Address) Usable() bool { return a.Value != "" }
type ValidatedOut struct {
TraceID string
AuthUser Address
LoginSuffix string // "SUPPORTTOKEN" of a user@dom@SUPPORTTOKEN login
TokenLogin bool // a user@dom@SUFFIX login; the suffix may be a credential and is dropped
MailFrom string // normalised, or the sanitised input when not an address
MailFromUsable bool
Recipient Address
@@ -60,7 +60,7 @@ type ValidatedIn struct {
// mail_from only feeds the exempt check and never rejects.
func ValidateOut(r OutRequest) (ValidatedOut, error) {
v := ValidatedOut{TraceID: SanitizeTraceID(r.TraceID)}
v.AuthUser, v.LoginSuffix = validateAuthUser(r.AuthUser)
v.AuthUser, v.TokenLogin = validateAuthUser(r.AuthUser)
v.Recipient = validateAddress(r.Recipient)
v.MailFrom, v.MailFromUsable = lenient(r.MailFrom)
if !v.AuthUser.Usable() && !v.Recipient.Usable() {
@@ -93,15 +93,26 @@ func validateAddress(s string) Address {
return Address{Value: n}
}
func validateAuthUser(s string) (Address, string) {
func validateAuthUser(s string) (Address, bool) {
if strings.TrimSpace(s) == "" {
return Address{Reason: "missing"}, ""
return Address{Reason: "missing"}, false
}
n, suffix, err := NormalizeAuthUser(s)
if err != nil {
return Address{Raw: SanitizeForLog(s), Reason: "invalid e-mail address"}, ""
return Address{Raw: redactSuffix(s), Reason: "invalid e-mail address"}, false
}
return Address{Value: n}, suffix
return Address{Value: n}, suffix != ""
}
// redactSuffix prepares an unusable login for the log: anything after a second
// "@" may be a credential, so it is cut off first.
func redactSuffix(s string) string {
if first := strings.Index(s, "@"); first >= 0 {
if second := strings.Index(s[first+1:], "@"); second >= 0 {
s = s[:first+1+second]
}
}
return SanitizeForLog(s)
}
// lenient normalises when possible and otherwise keeps a sanitised copy for
@@ -159,7 +170,21 @@ func NormalizeEmail(s string) (string, error) {
return "", errors.New("local part contains whitespace or control characters")
}
}
domain = strings.TrimSuffix(domain, ".")
ascii, err := NormalizeDomain(domain)
if err != nil {
return "", err
}
out := local + "@" + ascii
if len(out) > MaxEmailLen {
return "", errors.New("longer than 254 characters")
}
return out, nil
}
// NormalizeDomain trims and lowercases a domain, drops one trailing dot and
// converts a Unicode domain to punycode, the form the CDB keys it under.
func NormalizeDomain(s string) (string, error) {
domain := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(s)), ".")
if domain == "" || strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") || strings.Contains(domain, "..") {
return "", errors.New("domain has an empty label")
}
@@ -173,11 +198,7 @@ func NormalizeEmail(s string) (string, error) {
return "", errors.New("domain contains characters other than a-z, 0-9, '-' and '.'")
}
}
out := local + "@" + ascii
if len(out) > MaxEmailLen {
return "", errors.New("longer than 254 characters")
}
return out, nil
return ascii, nil
}
// SanitizeTraceID trims the id, strips control characters (it ends up in log