185 lines
6.5 KiB
Go
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
|
|
}
|