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
+14 -2
View File
@@ -78,7 +78,12 @@ func run(args []string, stdout, stderr io.Writer, getenv func(string) string, no
}
q.Set("address", addr)
} else {
q.Set("domain", strings.ToLower(strings.TrimSpace(*domain)))
dom, err := api.NormalizeDomain(*domain)
if err != nil {
fmt.Fprintf(stderr, "invalid domain %q: %v\n", *domain, err)
return 1
}
q.Set("domain", dom)
}
if *dateFlag != "" {
day, err := api.ParseDate(*dateFlag, now())
@@ -87,7 +92,8 @@ func run(args []string, stdout, stderr io.Writer, getenv func(string) string, no
return 1
}
q.Set("date", day)
if t, _ := time.Parse("2006-01-02", day); now().UTC().Sub(t) > 31*24*time.Hour {
today, _ := api.ParseDate("", now())
if d, t := mustDay(today), mustDay(day); d.Sub(t) > 31*24*time.Hour {
fmt.Fprintf(stderr, "warning: %s is more than 31 days ago; counter keys expire after 31 days and will read as 0\n", day)
}
}
@@ -154,6 +160,12 @@ func run(args []string, stdout, stderr io.Writer, getenv func(string) string, no
return 0
}
// mustDay parses a date api.ParseDate already validated.
func mustDay(s string) time.Time {
t, _ := time.Parse("2006-01-02", s)
return t
}
// flows picks the table columns: --in or --out alone restrict the table to
// that flow; neither or both show both flows.
func flows(in, out bool) (showIn, showOut bool) {
+35
View File
@@ -57,6 +57,8 @@ func (f *fakeAPI) last() *http.Request {
return f.requests[len(f.requests)-1]
}
func (f *fakeAPI) rawQuery() string { return f.last().URL.RawQuery }
func writeConfig(t *testing.T, body string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "config.toml")
@@ -333,3 +335,36 @@ func TestRun_Version(t *testing.T) {
t.Errorf("exit %d output %q", code, out)
}
}
// Keys live 31 days from their last increment, so a date exactly 31 days back
// can still hold data.
func TestDateWarning_Boundary(t *testing.T) {
f := &fakeAPI{body: sampleResponse()}
s := httptest.NewServer(f)
defer s.Close()
cfg := writeConfig(t, "auth_token = \"t\"\n")
exact := now.UTC().AddDate(0, 0, -31).Format("2006-01-02")
if _, _, errOut := runCLI(t, noEnv, "--config", cfg, "--url", s.URL, "--address", "me@example.com", "--date", exact); strings.Contains(errOut, "will read as 0") {
t.Errorf("31 days back must not warn: %q", errOut)
}
older := now.UTC().AddDate(0, 0, -32).Format("2006-01-02")
if _, _, errOut := runCLI(t, noEnv, "--config", cfg, "--url", s.URL, "--address", "me@example.com", "--date", older); !strings.Contains(errOut, "will read as 0") {
t.Errorf("32 days back must warn: %q", errOut)
}
}
func TestDomainFlag_Normalised(t *testing.T) {
f := &fakeAPI{body: sampleResponse()}
s := httptest.NewServer(f)
defer s.Close()
cfg := writeConfig(t, "auth_token = \"t\"\n")
if code, _, errOut := runCLI(t, noEnv, "--config", cfg, "--url", s.URL, "--domain", "Bücher.de"); code != 0 {
t.Fatalf("exit %d: %s", code, errOut)
}
if !strings.Contains(f.rawQuery(), "domain=xn--bcher-kva.de") {
t.Errorf("query = %q", f.rawQuery())
}
if code, _, _ := runCLI(t, noEnv, "--config", cfg, "--url", s.URL, "--domain", "a..b"); code == 0 {
t.Error("an invalid domain must exit non-zero")
}
}
+25 -8
View File
@@ -44,6 +44,9 @@ var (
date = "unknown"
)
// beforeStartup lets tests act right after the signal handler is installed.
var beforeStartup func()
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr, nil))
}
@@ -63,6 +66,18 @@ func run(args []string, stdout, stderr io.Writer, sigs <-chan os.Signal) int {
return 0
}
// Installed before anything slow: a SIGHUP's default action is to kill the
// process. Signals that arrive during startup wait in the channel.
if sigs == nil {
ch := make(chan os.Signal, 8)
signal.Notify(ch, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT)
defer signal.Stop(ch)
sigs = ch
}
if beforeStartup != nil {
beforeStartup()
}
path, err := config.Discover(*configPath)
if err != nil {
fmt.Fprintln(stderr, err)
@@ -93,12 +108,6 @@ func run(args []string, stdout, stderr io.Writer, sigs <-chan os.Signal) int {
}
a.Serve(apiLn, adminLn)
if sigs == nil {
ch := make(chan os.Signal, 2)
signal.Notify(ch, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT)
defer signal.Stop(ch)
sigs = ch
}
for {
select {
case sig := <-sigs:
@@ -115,6 +124,7 @@ func run(args []string, stdout, stderr io.Writer, sigs <-chan os.Signal) int {
cancel()
if err != nil {
fmt.Fprintf(stderr, "shutdown: %v\n", err)
return 1
}
return 0
case err := <-a.fatal:
@@ -166,6 +176,11 @@ func newApp(cfg *config.Config) (*app, error) {
logWriter.Close()
return nil, err
}
if db.Count() == 0 {
db.Close()
logWriter.Close()
return nil, fmt.Errorf("domains db %s holds no records", cfg.DomainsCDB)
}
a.db.Store(db)
logger.Info("domains db loaded", "path", cfg.DomainsCDB, "domains", db.Count(), "max_cached_domains", cfg.MaxCachedDomains)
@@ -209,6 +224,7 @@ func newApp(cfg *config.Config) (*app, error) {
a.notifier = notify.New(notify.Config{
OrgURL: cfg.Notify.OrgURL, AddressURL: cfg.Notify.AddressURL, DomainURL: cfg.Notify.DomainURL,
WarnOrgURL: cfg.Notify.WarnOrgURL, WarnAddressURL: cfg.Notify.WarnAddressURL, WarnDomainURL: cfg.Notify.WarnDomainURL,
User: cfg.Notify.User, Password: cfg.Notify.Password, Timeout: cfg.Notify.Timeout(),
}, server.NotifyHooks(tx, a.m))
@@ -220,8 +236,9 @@ func newApp(cfg *config.Config) (*app, error) {
}
return db.Lookup(domain)
}),
Exempt: cfg.ExemptSet(),
CountJunk: cfg.CountJunk,
Exempt: cfg.ExemptSet(),
CountJunk: cfg.CountJunk,
WarnPercent: cfg.Notify.EffectiveWarnPercent(),
}
a.srv = server.New(server.Deps{
AuthToken: cfg.AuthToken,
+47
View File
@@ -415,3 +415,50 @@ func TestRun_SignalLoop(t *testing.T) {
t.Errorf("log:\n%s", e.logs())
}
}
// A SIGHUP while the daemon starts must not kill it, and the reload it asked
// for runs once the daemon is ready.
func TestRun_SIGHUPDuringStartupIsQueued(t *testing.T) {
e := newEnv(t, "")
beforeStartup = func() { syscall.Kill(os.Getpid(), syscall.SIGHUP) }
t.Cleanup(func() { beforeStartup = nil })
done := make(chan int, 1)
var out, errb bytes.Buffer
go func() { done <- run([]string{"-config", e.cfgPath}, &out, &errb, nil) }()
deadline := time.Now().Add(5 * time.Second)
for !strings.Contains(e.logs(), "domains db reloaded") {
if time.Now().After(deadline) {
t.Fatalf("the early SIGHUP was not honoured; log:\n%s", e.logs())
}
time.Sleep(20 * time.Millisecond)
}
syscall.Kill(os.Getpid(), syscall.SIGTERM)
select {
case code := <-done:
if code != 0 {
t.Errorf("exit code %d, stderr %q", code, errb.String())
}
case <-time.After(15 * time.Second):
t.Fatal("run did not return after SIGTERM")
}
}
func TestNewApp_EmptyCDBIsFatal(t *testing.T) {
e := newEnv(t, "")
w, err := cdb.Create(e.cdbPath)
if err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
cfg, err := config.Load(e.cfgPath)
if err != nil {
t.Fatal(err)
}
if a, err := newApp(cfg); err == nil {
a.Shutdown(context.Background())
t.Fatal("an empty domains db must be fatal at startup")
}
}