Files
velox-bot/internal/security/raid.go
FernandoJVideiraandClaude Opus 5.5 3f87e68a2c Add anti-raid and anti-nuke protection
Join Gate (account age, avatar, advertising and username filters, bot
checks), raid detection with invite pausing, anti-nuke quarantine of
admins who mass delete, manual and automatic lockdowns, a decaying heat
score against spam, role and channel snapshots with restore, and alert
DMs to the owner and trusted users. Everything that weakens protection
is limited to the owner and the trusted list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 21:59:52 +01:00

334 lines
9.7 KiB
Go

package security
import (
"context"
"errors"
"fmt"
"log"
"strings"
"sync"
"time"
"velox-bot/internal/db/repos/securityrepo"
"github.com/bwmarrin/discordgo"
)
// Memory bounds per server, so a huge raid can't exhaust the bot.
const (
maxRecentJoins = 500
maxCaught = 20000
)
// CustomIDEndRaid is the alert's "End raid" button.
const CustomIDEndRaid = "security:endraid"
type joinRec struct {
user *discordgo.User
at time.Time
handled bool // already removed by the join lock or Join Gate
}
type caughtRec struct {
userID, name, action string
}
type raid struct {
mu sync.Mutex
joins []joinRec
active bool
started time.Time
lastJoin time.Time
caught []caughtRec
caughtTotal int
pausedInvites bool
}
func (e *Engine) raidFor(guildID string) *raid {
e.mu.Lock()
defer e.mu.Unlock()
r, ok := e.raids[guildID]
if !ok {
r = &raid{}
e.raids[guildID] = r
}
return r
}
// RaidActive reports whether raid mode is on in a guild.
func (e *Engine) RaidActive(guildID string) bool {
e.mu.Lock()
r, ok := e.raids[guildID]
e.mu.Unlock()
if !ok {
return false
}
r.mu.Lock()
defer r.mu.Unlock()
return r.active
}
// raidJoin records a join and, during a raid, acts on the raiders. It returns
// the action taken on this member ("" when none).
func (e *Engine) raidJoin(ctx context.Context, guildID string, s *securityrepo.Settings, m *discordgo.Member, handled bool) string {
if !s.AntiRaidEnabled {
return ""
}
now := e.now()
window := time.Duration(s.RaidJoinSeconds) * time.Second
r := e.raidFor(guildID)
r.mu.Lock()
r.joins = append(r.joins, joinRec{user: m.User, at: now, handled: handled})
cut := 0
for cut < len(r.joins) && now.Sub(r.joins[cut].at) > window {
cut++
}
r.joins = r.joins[cut:]
if len(r.joins) > maxRecentJoins {
r.joins = r.joins[len(r.joins)-maxRecentJoins:]
}
r.lastJoin = now
var targets []joinRec
starting := false
switch {
case !r.active && len(r.joins) >= s.RaidJoinCount:
// Raid starts: everyone in the burst that triggered it is a raider.
starting = true
r.active, r.started, r.caught, r.caughtTotal, r.pausedInvites = true, now, nil, 0, false
targets = append(targets, r.joins...)
case r.active:
targets = []joinRec{{user: m.User, at: now, handled: handled}}
}
r.mu.Unlock()
if starting {
e.startRaid(ctx, guildID, s, len(targets))
}
action := s.RaidAction
thisMember := ""
for _, t := range targets {
did := action
if t.handled || action == securityrepo.ActionAlert {
did = "flagged"
} else if err := e.punish(guildID, t.user, action, "raid"); errors.Is(err, errProtected) {
did = "skipped"
} else if err != nil {
log.Printf("security: raid action on %s in guild %s: %v", t.user.ID, guildID, err)
did = "failed"
}
r.mu.Lock()
r.caughtTotal++
keep := len(r.caught) < maxCaught
if keep {
r.caught = append(r.caught, caughtRec{userID: t.user.ID, name: safeName(t.user), action: did})
}
r.mu.Unlock()
if keep {
if err := e.st.AddRaidCaught(ctx, parseID(guildID), securityrepo.Caught{UserID: parseID(t.user.ID), Action: did, Name: safeName(t.user)}); err != nil {
log.Printf("security: recording raider %s in guild %s: %v", t.user.ID, guildID, err)
}
}
if t.user.ID == m.User.ID && did == action {
thisMember = action
}
}
if len(targets) > 0 {
e.persistRaid(ctx, guildID, r)
}
return thisMember
}
// persistRaid writes the raid's state so a restart can resume it.
func (e *Engine) persistRaid(ctx context.Context, guildID string, r *raid) {
r.mu.Lock()
x := securityrepo.Raid{GuildID: parseID(guildID), StartedAt: r.started, LastJoinAt: r.lastJoin, InvitesPaused: r.pausedInvites, CaughtTotal: r.caughtTotal}
active := r.active
r.mu.Unlock()
if !active {
return
}
if err := e.st.SaveRaid(ctx, x); err != nil {
log.Printf("security: saving raid state for guild %s: %v", guildID, err)
}
}
// ResumeRaids reloads raids that were in progress when the bot stopped, so
// raid mode stays on, its quiet timer keeps counting from the last join, and
// paused invites are resumed when it ends.
func (e *Engine) ResumeRaids(ctx context.Context) {
raids, err := e.st.ListRaids(ctx)
if err != nil {
log.Printf("security: loading raids in progress: %v", err)
return
}
for _, x := range raids {
r := e.raidFor(id(x.GuildID))
r.mu.Lock()
r.active, r.started, r.lastJoin, r.pausedInvites, r.caughtTotal = true, x.StartedAt, x.LastJoinAt, x.InvitesPaused, x.CaughtTotal
r.mu.Unlock()
}
}
func (e *Engine) startRaid(ctx context.Context, guildID string, s *securityrepo.Settings, joins int) {
e.event(ctx, guildID, securityrepo.EventRaidStarted, e.botID, "", fmt.Sprintf("%d joins in %ds", joins, s.RaidJoinSeconds))
paused := false
if s.RaidPauseInvites {
if err := e.setInvitesPaused(guildID, true); err != nil {
log.Printf("security: pausing invites in guild %s: %v", guildID, err)
} else {
paused = true
r := e.raidFor(guildID)
r.mu.Lock()
r.pausedInvites = true
r.mu.Unlock()
}
}
e.persistRaid(ctx, guildID, e.raidFor(guildID))
what := map[string]string{
securityrepo.ActionAlert: "nobody is being removed (alert only)", securityrepo.ActionTimeout: "raiders are being timed out",
securityrepo.ActionKick: "raiders are being kicked", securityrepo.ActionBan: "raiders are being banned",
}[s.RaidAction]
desc := fmt.Sprintf("%d members joined within %d seconds. Raid mode is on: %s.", joins, s.RaidJoinSeconds, what)
if paused {
desc += "\nInvites are paused until the raid ends."
}
desc += fmt.Sprintf("\nIt ends after %d minutes without a join, or with the button below (owner or trusted only).", s.RaidQuietMinutes)
e.send(ctx, guildID, s, alert{
ping: true, critical: true,
embed: &discordgo.MessageEmbed{
Title: "🚨 Raid detected", Description: desc, Color: colorAlert,
Timestamp: e.now().UTC().Format(time.RFC3339),
},
components: []discordgo.MessageComponent{discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.Button{Label: "End raid", Style: discordgo.SecondaryButton, CustomID: CustomIDEndRaid},
}}},
})
}
// EndRaid turns raid mode off. by is who ended it ("" for the quiet timer).
// Ending a raid relaxes protection, so callers must check the actor first.
func (e *Engine) EndRaid(ctx context.Context, guildID, by string) error {
e.mu.Lock()
r, ok := e.raids[guildID]
e.mu.Unlock()
if !ok {
return UserError("There's no raid going on.")
}
r.mu.Lock()
if !r.active {
r.mu.Unlock()
return UserError("There's no raid going on.")
}
started, caught, total, paused := r.started, r.caught, r.caughtTotal, r.pausedInvites
r.active, r.caught, r.caughtTotal, r.pausedInvites, r.joins = false, nil, 0, false, nil
r.mu.Unlock()
if paused {
if err := e.setInvitesPaused(guildID, false); err != nil {
log.Printf("security: resuming invites in guild %s: %v", guildID, err)
}
}
how := "after a quiet period"
if by != "" {
how = "by <@" + by + ">"
}
e.event(ctx, guildID, securityrepo.EventRaidEnded, by, "", fmt.Sprintf("%d raiders caught", total))
// The database has the full list even across a restart; memory is the
// fallback if it can't be read.
if saved, err := e.st.ListRaidCaught(ctx, parseID(guildID), maxCaught); err == nil && len(saved) >= len(caught) {
caught = caught[:0]
for _, c := range saved {
caught = append(caught, caughtRec{userID: id(c.UserID), action: c.Action, name: c.Name})
}
}
if err := e.st.DeleteRaid(ctx, parseID(guildID)); err != nil {
log.Printf("security: clearing raid state for guild %s: %v", guildID, err)
}
var list strings.Builder
fmt.Fprintf(&list, "Raid from %s to %s (UTC)\n\n", started.UTC().Format("2006-01-02 15:04:05"), e.now().UTC().Format("15:04:05"))
for _, c := range caught {
fmt.Fprintf(&list, "%s\t%s\t%s\n", c.userID, c.action, c.name)
}
if total > len(caught) {
fmt.Fprintf(&list, "... and %d more\n", total-len(caught))
}
desc := fmt.Sprintf("Raid mode ended %s. %d accounts were caught; the list is attached.", how, total)
if paused {
desc += "\nInvites are open again."
}
if s, _, err := e.load(ctx, guildID); err == nil {
e.send(ctx, guildID, s, alert{
critical: true,
embed: &discordgo.MessageEmbed{Title: "Raid over", Description: desc, Color: colorOK},
file: textFile("raid-"+started.UTC().Format("20060102-1504")+".txt", list.String()),
})
}
return nil
}
// WatchRaids ends raids after their quiet period and frees memory for
// servers that have gone quiet. It runs until ctx ends.
func (e *Engine) WatchRaids(ctx context.Context, every time.Duration) {
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
e.checkRaids(ctx)
}
}
}
func (e *Engine) checkRaids(ctx context.Context) {
e.mu.Lock()
guilds := make([]string, 0, len(e.raids))
for g := range e.raids {
guilds = append(guilds, g)
}
e.mu.Unlock()
now := e.now()
for _, g := range guilds {
s, _, err := e.load(ctx, g)
if err != nil {
continue
}
r := e.raidFor(g)
r.mu.Lock()
active, last := r.active, r.lastJoin
r.mu.Unlock()
switch {
case active && now.Sub(last) >= time.Duration(s.RaidQuietMinutes)*time.Minute:
_ = e.EndRaid(ctx, g, "")
case !active && now.Sub(last) > 10*time.Minute:
e.mu.Lock()
delete(e.raids, g)
e.mu.Unlock()
}
}
}
// setInvitesPaused uses Discord's built-in "pause invites" (guild incident
// actions). Paused for up to 24 hours, Discord's maximum; resumed when the
// raid ends.
func (e *Engine) setInvitesPaused(guildID string, paused bool) error {
body := map[string]any{"invites_disabled_until": nil}
if paused {
body["invites_disabled_until"] = e.now().Add(24 * time.Hour).UTC().Format(time.RFC3339)
}
url := discordgo.EndpointGuild(guildID) + "/incident-actions"
_, err := e.d.RequestWithBucketID("PUT", url, body, url)
return err
}