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 }