diff --git a/README.md b/README.md index 65dbd48..feb0136 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,13 @@ If you want to change them - check available options in the help message (`!pm h * **!pm mailboxes** - Show the list of all mailboxes * **!pm delete** <mailbox> - Delete specific mailbox +--- + +* **!pm banlist** - Enable/disable banlist and show current values +* **!pm banlist:add** - Ban an IP +* **!pm banlist:remove** - Unban an IP +* **!pm banlist:reset** - Reset banlist + diff --git a/bot/access.go b/bot/access.go index 489140a..8ec070a 100644 --- a/bot/access.go +++ b/bot/access.go @@ -2,6 +2,7 @@ package bot import ( "context" + "net" "regexp" "strings" @@ -71,6 +72,29 @@ func (b *Bot) allowSend(actorID id.UserID, targetRoomID id.RoomID) bool { return !cfg.NoSend() } +// IsBanned checks if address is banned +func (b *Bot) IsBanned(addr net.Addr) bool { + if !b.getBotSettings().BanlistEnabled() { + return false + } + return b.getBanlist().Has(addr) +} + +// Ban an address +func (b *Bot) Ban(addr net.Addr) { + if !b.getBotSettings().BanlistEnabled() { + return + } + + b.log.Debug("banning %s", addr.String()) + banlist := b.getBanlist() + banlist.Add(addr) + err := b.setBanlist(banlist) + if err != nil { + b.log.Error("cannot update banlist with %s: %v", addr.String(), err) + } +} + // AllowAuth check if SMTP login (email) and password are valid func (b *Bot) AllowAuth(email, password string) bool { var suffix bool diff --git a/bot/command.go b/bot/command.go index 23452ac..b4674cb 100644 --- a/bot/command.go +++ b/bot/command.go @@ -13,16 +13,20 @@ import ( ) const ( - commandHelp = "help" - commandStop = "stop" - commandSend = "send" - commandDKIM = "dkim" - commandCatchAll = botOptionCatchAll - commandUsers = botOptionUsers - commandQueueBatch = botOptionQueueBatch - commandQueueRetries = botOptionQueueRetries - commandDelete = "delete" - commandMailboxes = "mailboxes" + commandHelp = "help" + commandStop = "stop" + commandSend = "send" + commandDKIM = "dkim" + commandCatchAll = botOptionCatchAll + commandUsers = botOptionUsers + commandQueueBatch = botOptionQueueBatch + commandQueueRetries = botOptionQueueRetries + commandDelete = "delete" + commandBanlist = "banlist" + commandBanlistAdd = "banlist:add" + commandBanlistRemove = "banlist:remove" + commandBanlistReset = "banlist:reset" + commandMailboxes = "mailboxes" ) type ( @@ -211,6 +215,27 @@ func (b *Bot) initCommands() commandList { description: "Delete specific mailbox", allowed: b.allowAdmin, }, + {allowed: b.allowAdmin}, // delimiter + { + key: commandBanlist, + description: "Enable/disable banlist and show current values", + allowed: b.allowAdmin, + }, + { + key: commandBanlistAdd, + description: "Ban an IP", + allowed: b.allowAdmin, + }, + { + key: commandBanlistRemove, + description: "Unban an IP", + allowed: b.allowAdmin, + }, + { + key: commandBanlistReset, + description: "Reset banlist", + allowed: b.allowAdmin, + }, } } @@ -245,6 +270,14 @@ func (b *Bot) handleCommand(ctx context.Context, evt *event.Event, commandSlice b.runCatchAll(ctx, commandSlice) case commandDelete: b.runDelete(ctx, commandSlice) + case commandBanlist: + b.runBanlist(ctx, commandSlice) + case commandBanlistAdd: + b.runBanlistAdd(ctx, commandSlice) + case commandBanlistRemove: + b.runBanlistRemove(ctx, commandSlice) + case commandBanlistReset: + b.runBanlistReset(ctx) case commandMailboxes: b.sendMailboxes(ctx) default: diff --git a/bot/command_admin.go b/bot/command_admin.go index 46fd293..3382d04 100644 --- a/bot/command_admin.go +++ b/bot/command_admin.go @@ -3,6 +3,7 @@ package bot import ( "context" "fmt" + "net" "sort" "strings" @@ -205,3 +206,104 @@ func (b *Bot) runCatchAll(ctx context.Context, commandSlice []string) { b.SendNotice(ctx, evt.RoomID, fmt.Sprintf("Catch-all is set to: `%s` (%s).", mailbox, utils.EmailsList(mailbox, ""))) } + +func (b *Bot) runBanlist(ctx context.Context, commandSlice []string) { + evt := eventFromContext(ctx) + cfg := b.getBotSettings() + if len(commandSlice) < 2 { + banlist := b.getBanlist() + var msg strings.Builder + if len(banlist) > 0 { + msg.WriteString("Currently: `") + msg.WriteString(cfg.Get(botOptionBanlistEnabled)) + msg.WriteString("` (`") + msg.WriteString(strings.Join(banlist.Slice(), " ")) + msg.WriteString("`)\n\n") + } + if !cfg.BanlistEnabled() { + msg.WriteString("To enable banlist, send `") + msg.WriteString(b.prefix) + msg.WriteString(" banlist true`\n\n") + } + msg.WriteString("To ban somebody: `") + msg.WriteString(b.prefix) + msg.WriteString(" banlist:add IP1 IP2 IP3...`") + msg.WriteString("where each ip is IPv4 or IPv6\n") + + b.SendNotice(ctx, evt.RoomID, msg.String()) + return + } + value := utils.SanitizeBoolString(commandSlice[1]) + cfg.Set(botOptionBanlistEnabled, value) + err := b.setBotSettings(cfg) + if err != nil { + b.Error(ctx, evt.RoomID, "cannot set bot config: %v", err) + } + b.SendNotice(ctx, evt.RoomID, "banlist has been updated") +} + +func (b *Bot) runBanlistAdd(ctx context.Context, commandSlice []string) { + evt := eventFromContext(ctx) + if len(commandSlice) < 2 { + b.runBanlist(ctx, commandSlice) + return + } + banlist := b.getBanlist() + + ips := commandSlice[1:] + for _, ip := range ips { + addr, err := net.ResolveIPAddr("ip", ip) + if err != nil { + b.Error(ctx, evt.RoomID, "cannot add %s to banlist: %v", ip, err) + return + } + banlist.Add(addr) + } + + err := b.setBanlist(banlist) + if err != nil { + b.Error(ctx, evt.RoomID, "cannot set banlist: %v", err) + return + } + + b.SendNotice(ctx, evt.RoomID, "banlist has been updated, kupo") +} + +func (b *Bot) runBanlistRemove(ctx context.Context, commandSlice []string) { + evt := eventFromContext(ctx) + if len(commandSlice) < 2 { + b.runBanlist(ctx, commandSlice) + return + } + banlist := b.getBanlist() + + ips := commandSlice[1:] + for _, ip := range ips { + addr, err := net.ResolveIPAddr("ip", ip) + if err != nil { + b.Error(ctx, evt.RoomID, "cannot remove %s from banlist: %v", ip, err) + return + } + banlist.Remove(addr) + } + + err := b.setBanlist(banlist) + if err != nil { + b.Error(ctx, evt.RoomID, "cannot set banlist: %v", err) + return + } + + b.SendNotice(ctx, evt.RoomID, "banlist has been updated, kupo") +} + +func (b *Bot) runBanlistReset(ctx context.Context) { + evt := eventFromContext(ctx) + + err := b.setBanlist(banList{}) + if err != nil { + b.Error(ctx, evt.RoomID, "cannot set banlist: %v", err) + return + } + + b.SendNotice(ctx, evt.RoomID, "banlist has been reset, kupo") +} diff --git a/bot/settings_banlist.go b/bot/settings_banlist.go new file mode 100644 index 0000000..2f0a720 --- /dev/null +++ b/bot/settings_banlist.go @@ -0,0 +1,74 @@ +package bot + +import ( + "net" + "time" + + "gitlab.com/etke.cc/postmoogle/utils" +) + +// account data key +const acBanlistKey = "cc.etke.postmoogle.banlist" + +type banList map[string]string + +// Slice returns slice of banlist items +func (b banList) Slice() []string { + slice := make([]string, 0, len(b)) + for item := range b { + slice = append(slice, item) + } + + return slice +} + +func (b banList) getKey(addr net.Addr) string { + key := addr.String() + host, _, _ := net.SplitHostPort(key) //nolint:errcheck // either way it's ok + if host != "" { + key = host + } + return key +} + +// Has addr in banlist +func (b banList) Has(addr net.Addr) bool { + _, ok := b[b.getKey(addr)] + return ok +} + +// Add an addr to banlist +func (b banList) Add(addr net.Addr) { + key := b.getKey(addr) + if _, ok := b[key]; ok { + return + } + + b[key] = time.Now().UTC().Format(time.RFC1123Z) +} + +// Remove an addr from banlist +func (b banList) Remove(addr net.Addr) { + key := b.getKey(addr) + if _, ok := b[key]; !ok { + return + } + + delete(b, key) +} + +func (b *Bot) getBanlist() banList { + config, err := b.lp.GetAccountData(acBanlistKey) + if err != nil { + b.log.Error("cannot get banlist: %v", utils.UnwrapError(err)) + } + if config == nil { + config = map[string]string{} + } + + return config +} + +func (b *Bot) setBanlist(cfg banList) error { + return utils.UnwrapError(b.lp.SetAccountData(acBanlistKey, cfg)) +} diff --git a/bot/settings_bot.go b/bot/settings_bot.go index b54d783..f91c57c 100644 --- a/bot/settings_bot.go +++ b/bot/settings_bot.go @@ -17,6 +17,7 @@ const ( botOptionDKIMPrivateKey = "dkim.pem" botOptionQueueBatch = "queue:batch" botOptionQueueRetries = "queue:retries" + botOptionBanlistEnabled = "banlist:enabled" ) type botSettings map[string]string @@ -50,6 +51,11 @@ func (s botSettings) CatchAll() string { return s.Get(botOptionCatchAll) } +// BanlistEnabled option +func (s botSettings) BanlistEnabled() bool { + return utils.Bool(s.Get(botOptionBanlistEnabled)) +} + // DKIMSignature (DNS TXT record) func (s botSettings) DKIMSignature() string { return s.Get(botOptionDKIMSignature) diff --git a/smtp/manager.go b/smtp/manager.go index 4381df8..ffcf6c0 100644 --- a/smtp/manager.go +++ b/smtp/manager.go @@ -40,6 +40,8 @@ type Manager struct { type matrixbot interface { AllowAuth(string, string) bool + IsBanned(net.Addr) bool + Ban(net.Addr) GetMapping(string) (id.RoomID, bool) GetIFOptions(id.RoomID) utils.IncomingFilteringOptions IncomingEmail(context.Context, *utils.Email) error diff --git a/smtp/server.go b/smtp/server.go index ba9fae2..f9b0c18 100644 --- a/smtp/server.go +++ b/smtp/server.go @@ -23,11 +23,17 @@ type mailServer struct { // Login used for outgoing mail submissions only (when you use postmoogle as smtp server in your scripts) func (m *mailServer) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) { m.log.Debug("Login state=%+v username=%+v", state, username) + if m.bot.IsBanned(state.RemoteAddr) { + return nil, errors.New("please, don't bother me anymore") + } + if !utils.AddressValid(username) { + m.bot.Ban(state.RemoteAddr) return nil, errors.New("please, provide an email address") } if !m.bot.AllowAuth(username, password) { + m.bot.Ban(state.RemoteAddr) return nil, errors.New("email or password is invalid") } @@ -44,6 +50,10 @@ func (m *mailServer) Login(state *smtp.ConnectionState, username, password strin // AnonymousLogin used for incoming mail submissions only func (m *mailServer) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) { m.log.Debug("AnonymousLogin state=%+v", state) + if m.bot.IsBanned(state.RemoteAddr) { + return nil, errors.New("please, don't bother me anymore") + } + return &incomingSession{ ctx: sentry.SetHubOnContext(context.Background(), sentry.CurrentHub().Clone()), getRoomID: m.bot.GetMapping, @@ -51,6 +61,7 @@ func (m *mailServer) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, receiveEmail: m.ReceiveEmail, log: m.log, domains: m.domains, + addr: state.RemoteAddr, }, nil } diff --git a/smtp/session.go b/smtp/session.go index 482c2d7..792506d 100644 --- a/smtp/session.go +++ b/smtp/session.go @@ -4,6 +4,7 @@ import ( "context" "errors" "io" + "net" "github.com/emersion/go-smtp" "github.com/getsentry/sentry-go" @@ -21,9 +22,11 @@ type incomingSession struct { getRoomID func(string) (id.RoomID, bool) getFilters func(id.RoomID) utils.IncomingFilteringOptions receiveEmail func(context.Context, *utils.Email) error + ban func(net.Addr) domains []string ctx context.Context + addr net.Addr to string from string } @@ -31,6 +34,7 @@ type incomingSession struct { func (s *incomingSession) Mail(from string, opts smtp.MailOptions) error { sentry.GetHubFromContext(s.ctx).Scope().SetTag("from", from) if !utils.AddressValid(from) { + s.ban(s.addr) return errors.New("please, provide email address") } s.from = from @@ -50,17 +54,20 @@ func (s *incomingSession) Rcpt(to string) error { } if !domainok { s.log.Debug("wrong domain of %s", to) + s.ban(s.addr) return smtp.ErrAuthRequired } roomID, ok := s.getRoomID(utils.Mailbox(to)) if !ok { s.log.Debug("mapping for %s not found", to) + s.ban(s.addr) return smtp.ErrAuthRequired } validations := s.getFilters(roomID) if !validateEmail(s.from, s.to, s.log, validations) { + s.ban(s.addr) return smtp.ErrAuthRequired }