Files
mailgoserver/internal/db/crud_ip_blacklist.go
T

185 lines
6.5 KiB
Go

package db
import (
"database/sql"
"errors"
"time"
)
// IPBlacklistEntry is a temporary (or manual) block on SMTP/IMAP traffic from one IP.
// Deliberately separate from WhitelistedIP (esrv_whitelisted_ips), which authorizes
// unauthenticated relay for a domain — a completely different concern.
type IPBlacklistEntry struct {
ID int64
IPAddress string
Reason string
OffenseCount int
Manual bool
BlacklistedAt time.Time
ExpiresAt time.Time
}
// IPAbuseWhitelistEntry exempts one IP from abuse detection entirely.
type IPAbuseWhitelistEntry struct {
ID int64
IPAddress string
Note string
CreatedAt time.Time
}
// smtpImapAuthTypes are the esrv_auth_logs auth_type values that count toward abuse
// detection: SMTP/IMAP traffic, not admin/webmail dashboard logins (those already have
// their own lockout in internal/webui/ratelimit.go).
const smtpImapAuthTypesSQL = `auth_type IN ('sender', 'mailbox', 'sender_validation', 'mailbox_validation', 'ip', 'imap_login')`
// CountFailedAuthAttemptsByIP mirrors CountRecentFailedAttempts (queries.go:140) exactly,
// including its documented since.UTC().Format("2006-01-02 15:04:05") requirement, but
// scoped by ip_address across all SMTP/IMAP auth types instead of by identifier+one type.
func (d *DB) CountFailedAuthAttemptsByIP(ip string, since time.Time) (int, error) {
var n int
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_auth_logs
WHERE ip_address = ? AND success = 0 AND created_at >= ? AND `+smtpImapAuthTypesSQL,
ip, since.UTC().Format("2006-01-02 15:04:05")).Scan(&n)
return n, err
}
// IsIPBlacklisted reports whether ip has a currently-active (unexpired) blacklist entry.
func (d *DB) IsIPBlacklisted(ip string) (bool, error) {
var n int
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_ip_blacklist WHERE ip_address = ? AND expires_at > ?`,
ip, time.Now().UTC().Format("2006-01-02 15:04:05")).Scan(&n)
return n > 0, err
}
// IsIPAbuseWhitelisted reports whether ip is exempt from abuse detection.
func (d *DB) IsIPAbuseWhitelisted(ip string) (bool, error) {
var n int
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_ip_abuse_whitelist WHERE ip_address = ?`, ip).Scan(&n)
return n > 0, err
}
// BlacklistIP upserts ip's blacklist entry, escalating the block duration on repeat
// offenses: duration = min(baseHours * 2^(offenseCount-1), maxHours), where offenseCount
// is incremented on every call regardless of whether the previous entry had expired.
func (d *DB) BlacklistIP(ip, reason string, baseHours, maxHours int) error {
var existingCount int
err := d.QueryRow(`SELECT offense_count FROM esrv_ip_blacklist WHERE ip_address = ?`, ip).Scan(&existingCount)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
newCount := existingCount + 1
hours := baseHours
for i := 1; i < newCount; i++ {
hours *= 2
if hours >= maxHours {
hours = maxHours
break
}
}
if hours > maxHours {
hours = maxHours
}
expiresAt := time.Now().UTC().Add(time.Duration(hours) * time.Hour).Format("2006-01-02 15:04:05")
_, err = d.Exec(`INSERT INTO esrv_ip_blacklist (ip_address, reason, offense_count, manual, blacklisted_at, expires_at)
VALUES (?, ?, ?, 0, CURRENT_TIMESTAMP, ?)
ON CONFLICT(ip_address) DO UPDATE SET
reason = excluded.reason,
offense_count = excluded.offense_count,
manual = 0,
blacklisted_at = CURRENT_TIMESTAMP,
expires_at = excluded.expires_at`,
ip, reason, newCount, expiresAt)
return err
}
// AddManualBlacklistEntry is an admin-initiated block: fixed duration, no escalation math.
func (d *DB) AddManualBlacklistEntry(ip, reason string, hours int) error {
expiresAt := time.Now().UTC().Add(time.Duration(hours) * time.Hour).Format("2006-01-02 15:04:05")
_, err := d.Exec(`INSERT INTO esrv_ip_blacklist (ip_address, reason, offense_count, manual, blacklisted_at, expires_at)
VALUES (?, ?, 1, 1, CURRENT_TIMESTAMP, ?)
ON CONFLICT(ip_address) DO UPDATE SET
reason = excluded.reason,
manual = 1,
blacklisted_at = CURRENT_TIMESTAMP,
expires_at = excluded.expires_at`,
ip, reason, expiresAt)
return err
}
func (d *DB) ListBlacklist() ([]IPBlacklistEntry, error) {
rows, err := d.Query(`SELECT id, ip_address, reason, offense_count, manual, blacklisted_at, expires_at
FROM esrv_ip_blacklist ORDER BY blacklisted_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []IPBlacklistEntry
for rows.Next() {
var e IPBlacklistEntry
var blacklistedAt, expiresAt string
if err := rows.Scan(&e.ID, &e.IPAddress, &e.Reason, &e.OffenseCount, &e.Manual, &blacklistedAt, &expiresAt); err != nil {
return nil, err
}
e.BlacklistedAt, _ = parseTime(blacklistedAt)
e.ExpiresAt, _ = parseTime(expiresAt)
out = append(out, e)
}
return out, rows.Err()
}
func (d *DB) RemoveBlacklistEntry(id int64) error {
_, err := d.Exec(`DELETE FROM esrv_ip_blacklist WHERE id = ?`, id)
return err
}
func (d *DB) ListAbuseWhitelist() ([]IPAbuseWhitelistEntry, error) {
rows, err := d.Query(`SELECT id, ip_address, note, created_at FROM esrv_ip_abuse_whitelist ORDER BY ip_address`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []IPAbuseWhitelistEntry
for rows.Next() {
var e IPAbuseWhitelistEntry
var createdAt string
if err := rows.Scan(&e.ID, &e.IPAddress, &e.Note, &createdAt); err != nil {
return nil, err
}
e.CreatedAt, _ = parseTime(createdAt)
out = append(out, e)
}
return out, rows.Err()
}
func (d *DB) AddAbuseWhitelist(ip, note string) error {
_, err := d.Exec(`INSERT INTO esrv_ip_abuse_whitelist (ip_address, note) VALUES (?, ?)
ON CONFLICT(ip_address) DO UPDATE SET note = excluded.note`, ip, note)
return err
}
func (d *DB) RemoveAbuseWhitelist(id int64) error {
_, err := d.Exec(`DELETE FROM esrv_ip_abuse_whitelist WHERE id = ?`, id)
return err
}
// CountBlacklistEventsSince counts blacklist entries (auto or manual) created since the
// given cutoff, for the dashboard's attack-count tiles.
func (d *DB) CountBlacklistEventsSince(since time.Time) (int, error) {
var n int
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_ip_blacklist WHERE blacklisted_at >= ?`,
since.UTC().Format("2006-01-02 15:04:05")).Scan(&n)
return n, err
}
// CountFailedAuthSince counts failed SMTP/IMAP auth attempts (any IP) since the given
// cutoff, for the dashboard's attack-count tiles.
func (d *DB) CountFailedAuthSince(since time.Time) (int, error) {
var n int
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_auth_logs
WHERE success = 0 AND created_at >= ? AND `+smtpImapAuthTypesSQL,
since.UTC().Format("2006-01-02 15:04:05")).Scan(&n)
return n, err
}