MFA fix, added IP blacklist, update webmail client
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user