Files
velox-bot/internal/security/quarantine.go
T
FernandoJVideiraandClaude Sonnet 5.5 132a2cd986 Commands for everything a dashboard can do
- /music skip, pause, resume, stop, repeat, remove, move (DJ role)
- /config view, clear, toggletwitch, streamers, beta-features (server owner)
- /slvl status, remove-reward; /moderation bans, timeouts; /security history
- /beta is now the bot owner's DM-only command: set, off, servers, remove-server, status
- betasettings service and repo methods, with integration tests

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 02:10:05 +01:00

193 lines
6.4 KiB
Go

package security
import (
"context"
"errors"
"fmt"
"log"
"time"
"velox-bot/internal/db/repos/securityrepo"
"github.com/bwmarrin/discordgo"
)
// usableQuarantineRole returns the configured quarantine role if it is safe
// to hand out right now: it exists, isn't @everyone or managed by an
// integration, and has none of the dangerous permissions. Checked at use, so
// a role that gained permissions since it was configured is never assigned.
func usableQuarantineRole(s *securityrepo.Settings, guildID string, roles []*discordgo.Role) string {
if s.QuarantineRoleID == 0 || id(s.QuarantineRoleID) == guildID {
return ""
}
for _, r := range roles {
if r.ID == id(s.QuarantineRoleID) {
if r.Managed || r.Permissions&DangerousPerms != 0 {
return ""
}
return r.ID
}
}
return ""
}
// Quarantine neutralises a member: every role it can remove is taken away
// (and saved, so it can be given back), the quarantine role is added if one
// is configured, and the member is timed out for the longest Discord allows.
// Bots are kicked instead, since their own integration role can't be
// removed. The owner and the bot itself are never touched.
func (e *Engine) Quarantine(ctx context.Context, guildID, userID, reason string) error {
if userID == "" || userID == e.botID || e.IsOwner(guildID, userID) {
return errors.New("refusing to quarantine the owner or the bot")
}
s, _, err := e.load(ctx, guildID)
if err != nil {
return err
}
m, err := e.d.GuildMember(guildID, userID)
if err != nil {
return fmt.Errorf("looking up the member: %w", err)
}
if m.User != nil && m.User.Bot {
return e.d.GuildMemberDeleteWithReason(guildID, userID, clip("Velox anti-nuke: "+reason, 400))
}
roles, err := e.d.GuildRoles(guildID)
if err != nil {
return fmt.Errorf("listing roles: %w", err)
}
managed := map[string]bool{}
for _, r := range roles {
if r.Managed {
managed[r.ID] = true
}
}
var keep []string
var previous []int64
for _, r := range m.Roles {
if managed[r] {
keep = append(keep, r) // can't be removed (e.g. a Nitro booster role)
} else {
previous = append(previous, parseID(r))
}
}
qRole := usableQuarantineRole(s, guildID, roles)
withRole := keep
if qRole != "" {
withRole = append(append([]string(nil), keep...), qRole)
}
// Save first: if Discord fails half-way, the roles are still on record.
if err := e.st.SaveQuarantine(ctx, securityrepo.Quarantine{
GuildID: parseID(guildID), UserID: parseID(userID), PreviousRoleIDs: previous, Reason: clip(reason, 300),
}); err != nil {
return fmt.Errorf("saving the quarantine: %w", err)
}
audit := discordgo.WithAuditLogReason(clip("Velox anti-nuke: "+reason, 400))
var errs []error
if _, err := e.d.GuildMemberEdit(guildID, userID, &discordgo.GuildMemberParams{Roles: &withRole}, audit); err != nil {
// A quarantine role Velox can't assign (e.g. moved above its own
// role) must not stop the roles from being removed: retry without it.
if qRole == "" {
errs = append(errs, fmt.Errorf("removing roles: %w", err))
} else if _, err2 := e.d.GuildMemberEdit(guildID, userID, &discordgo.GuildMemberParams{Roles: &keep}, audit); err2 != nil {
errs = append(errs, fmt.Errorf("removing roles: %w", err2))
} else {
log.Printf("security: quarantine role %s couldn't be assigned in guild %s (%v); roles were still removed", qRole, guildID, err)
}
}
until := e.now().Add(timeoutQuarantine)
if err := e.d.GuildMemberTimeout(guildID, userID, &until, audit); err != nil {
errs = append(errs, fmt.Errorf("timing out: %w", err))
}
e.event(ctx, guildID, securityrepo.EventQuarantined, e.botID, userID, reason)
return errors.Join(errs...)
}
// Release undoes a quarantine: the saved roles that still exist are given
// back, the quarantine role and the timeout are removed. Releasing relaxes
// protection, so callers must check the actor first.
func (e *Engine) Release(ctx context.Context, guildID, userID, by string) error {
rec, err := e.st.GetQuarantine(ctx, parseID(guildID), parseID(userID))
if err != nil {
return err
}
if rec == nil {
return UserError("That member isn't quarantined.")
}
s, _, err := e.load(ctx, guildID)
if err != nil {
return err
}
m, err := e.d.GuildMember(guildID, userID)
if err != nil {
// They left: there's nothing to give back.
if derr := e.st.DeleteQuarantine(ctx, parseID(guildID), parseID(userID)); derr != nil {
return derr
}
e.event(ctx, guildID, securityrepo.EventReleased, by, userID, "member had left")
return nil
}
roles, err := e.d.GuildRoles(guildID)
if err != nil {
return fmt.Errorf("listing roles: %w", err)
}
exists := map[string]bool{}
for _, r := range roles {
if !r.Managed && r.ID != guildID {
exists[r.ID] = true
}
}
qRole := ""
if s.QuarantineRoleID != 0 {
qRole = id(s.QuarantineRoleID)
}
seen := map[string]bool{}
var next []string
add := func(r string) {
if !seen[r] && r != qRole {
seen[r] = true
next = append(next, r)
}
}
for _, r := range m.Roles {
add(r)
}
for _, r := range rec.PreviousRoleIDs {
if exists[id(r)] {
add(id(r))
}
}
audit := discordgo.WithAuditLogReason("Velox security: quarantine released")
if _, err := e.d.GuildMemberEdit(guildID, userID, &discordgo.GuildMemberParams{Roles: &next}, audit); err != nil {
return fmt.Errorf("giving the roles back: %w", err)
}
if err := e.d.GuildMemberTimeout(guildID, userID, nil, audit); err != nil {
log.Printf("security: removing the timeout of %s in guild %s: %v", userID, guildID, err)
}
if err := e.st.DeleteQuarantine(ctx, parseID(guildID), parseID(userID)); err != nil {
return err
}
e.event(ctx, guildID, securityrepo.EventReleased, by, userID, fmt.Sprintf("%d roles given back", len(rec.PreviousRoleIDs)))
e.send(ctx, guildID, s, alert{embed: &discordgo.MessageEmbed{
Title: "Quarantine released",
Description: fmt.Sprintf("<@%s> was released by <@%s> and got their roles back.", userID, by),
Color: colorOK,
Timestamp: e.now().UTC().Format(time.RFC3339),
}})
return nil
}
// Quarantines lists who is quarantined in a guild.
// Events returns the server's recent security history, newest first.
func (e *Engine) Events(ctx context.Context, guildID string, limit int) ([]securityrepo.Event, error) {
return e.st.ListEvents(ctx, parseID(guildID), limit)
}
func (e *Engine) Quarantines(ctx context.Context, guildID string) ([]securityrepo.Quarantine, error) {
return e.st.ListQuarantines(ctx, parseID(guildID))
}