97 lines
3.3 KiB
Go
97 lines
3.3 KiB
Go
// Package abuseguard automatically blacklists IPs that rack up too many failed
|
|
// SMTP/IMAP auth attempts, and rejects connections from already-blacklisted IPs before
|
|
// the SMTP/IMAP banner is ever sent. Deliberately separate from the web admin/webmail
|
|
// login lockout (internal/webui/ratelimit.go) and from the relay-authorization
|
|
// whitelist (esrv_whitelisted_ips) — see the [Security] section of settings.ini.
|
|
package abuseguard
|
|
|
|
import (
|
|
"net"
|
|
"time"
|
|
|
|
"gopkg.in/ini.v1"
|
|
|
|
"mailgoserver/internal/db"
|
|
"mailgoserver/internal/toolbox"
|
|
)
|
|
|
|
// RecordFailureAndMaybeBlacklist should be called after every failed SMTP AUTH or IMAP
|
|
// login. It counts recent failures from ip and blacklists it once the configured
|
|
// threshold is hit. Fails open (does nothing) on a DB error rather than blocking auth
|
|
// over a transient issue.
|
|
func RecordFailureAndMaybeBlacklist(database *db.DB, cfg *ini.File, logger *toolbox.Logger, ip string) {
|
|
if ip == "" || cfg == nil {
|
|
return
|
|
}
|
|
sec := cfg.Section("Security")
|
|
if !sec.Key("abuse_detection_enabled").MustBool(true) {
|
|
return
|
|
}
|
|
if whitelisted, err := database.IsIPAbuseWhitelisted(ip); err != nil || whitelisted {
|
|
return
|
|
}
|
|
|
|
threshold := sec.Key("abuse_failure_threshold").MustInt(8)
|
|
windowMinutes := sec.Key("abuse_detection_window_minutes").MustInt(10)
|
|
since := time.Now().Add(-time.Duration(windowMinutes) * time.Minute)
|
|
|
|
n, err := database.CountFailedAuthAttemptsByIP(ip, since)
|
|
if err != nil || n < threshold {
|
|
return
|
|
}
|
|
|
|
baseHours := sec.Key("abuse_blacklist_base_hours").MustInt(12)
|
|
maxHours := sec.Key("abuse_blacklist_max_hours").MustInt(168)
|
|
reason := "automatic: too many failed SMTP/IMAP auth attempts"
|
|
if err := database.BlacklistIP(ip, reason, baseHours, maxHours); err != nil && logger != nil {
|
|
logger.Error("abuseguard: failed to blacklist %s: %v", ip, err)
|
|
return
|
|
}
|
|
if logger != nil {
|
|
logger.Warning("abuseguard: blacklisted %s after %d failed attempts in %dm", ip, n, windowMinutes)
|
|
}
|
|
}
|
|
|
|
// guardedListener wraps a net.Listener so Accept() silently drops connections from
|
|
// blacklisted IPs (never returning them to the caller) before any protocol banner is
|
|
// written, and keeps looping rather than returning an error.
|
|
type guardedListener struct {
|
|
net.Listener
|
|
database *db.DB
|
|
logger *toolbox.Logger
|
|
}
|
|
|
|
// GuardListener wraps inner so every accepted connection is checked against the IP
|
|
// blacklist (skipping the check entirely for abuse-whitelisted IPs) before the caller
|
|
// ever sees it.
|
|
func GuardListener(inner net.Listener, database *db.DB, logger *toolbox.Logger) net.Listener {
|
|
return &guardedListener{Listener: inner, database: database, logger: logger}
|
|
}
|
|
|
|
func (g *guardedListener) Accept() (net.Conn, error) {
|
|
for {
|
|
conn, err := g.Listener.Accept()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
host, _, splitErr := net.SplitHostPort(conn.RemoteAddr().String())
|
|
if splitErr != nil {
|
|
host = conn.RemoteAddr().String()
|
|
}
|
|
if whitelisted, wErr := g.database.IsIPAbuseWhitelisted(host); wErr == nil && whitelisted {
|
|
return conn, nil
|
|
}
|
|
blocked, bErr := g.database.IsIPBlacklisted(host)
|
|
if bErr != nil {
|
|
return conn, nil // fail open on a DB error
|
|
}
|
|
if !blocked {
|
|
return conn, nil
|
|
}
|
|
if g.logger != nil {
|
|
g.logger.Warning("abuseguard: rejected connection from blacklisted IP %s", host)
|
|
}
|
|
conn.Close()
|
|
}
|
|
}
|