- /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]>
357 lines
13 KiB
Go
357 lines
13 KiB
Go
// Package security is the bot's anti-raid and anti-nuke system, modelled on
|
||
// Wick:
|
||
//
|
||
// - Join Gate: checks on every join (no avatar, new account, invite links
|
||
// or blocked words in the name, unverified bots), each with its own
|
||
// action, plus kicking bots added by anyone who isn't trusted.
|
||
// - Raid mode: a burst of joins starts it; raiders are timed out, kicked or
|
||
// banned, invites can be paused, staff are alerted, and it ends after a
|
||
// quiet period or when staff end it.
|
||
// - Anti-nuke: destructive actions (deleting channels or roles, mass bans
|
||
// and kicks, webhooks, dangerous permissions) are counted per person from
|
||
// the audit log; anyone who isn't the owner or trusted and goes over a
|
||
// limit is quarantined: roles removed and saved, timed out.
|
||
// - Lockdown: lock a channel, every channel, or joins, and unlock exactly
|
||
// back to how they were.
|
||
//
|
||
// Safety rules every entry point follows:
|
||
//
|
||
// - Turning protection off, changing limits, releasing a quarantine and
|
||
// unlocking need the server owner or someone on the trusted list.
|
||
// Editing the trusted list needs the owner. Discord's command
|
||
// permissions are never relied on, since admins can change them.
|
||
// - The owner and the bot itself are never punished.
|
||
// - Configured roles are checked when used, not just when saved: a
|
||
// quarantine role that gained dangerous permissions is not assigned.
|
||
// - Messages only ever ping the configured alert role, and user-controlled
|
||
// text (names) is escaped.
|
||
// - Memory per server is bounded, and settings are cached briefly so a
|
||
// raid of thousands of joins doesn't turn into thousands of queries.
|
||
package security
|
||
|
||
import (
|
||
"context"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"velox-bot/internal/db/repos/securityrepo"
|
||
|
||
"github.com/bwmarrin/discordgo"
|
||
)
|
||
|
||
// Store is the subset of securityrepo.Repo the system uses.
|
||
type Store interface {
|
||
GetSettings(ctx context.Context, guildID int64) (*securityrepo.Settings, error)
|
||
SetEnabled(ctx context.Context, guildID int64, feature string, on bool) error
|
||
SetAlerts(ctx context.Context, guildID, channelID, roleID int64) error
|
||
SetQuarantineRole(ctx context.Context, guildID, roleID int64) error
|
||
SetJoinLock(ctx context.Context, guildID int64, mode string) error
|
||
UpdateTuning(ctx context.Context, guildID int64, t securityrepo.Tuning) error
|
||
ListTrusted(ctx context.Context, guildID int64) ([]securityrepo.Trusted, error)
|
||
AddTrusted(ctx context.Context, guildID int64, t securityrepo.Trusted) error
|
||
RemoveTrusted(ctx context.Context, guildID, targetID int64) (bool, error)
|
||
SaveQuarantine(ctx context.Context, q securityrepo.Quarantine) error
|
||
GetQuarantine(ctx context.Context, guildID, userID int64) (*securityrepo.Quarantine, error)
|
||
ListQuarantines(ctx context.Context, guildID int64) ([]securityrepo.Quarantine, error)
|
||
DeleteQuarantine(ctx context.Context, guildID, userID int64) error
|
||
SaveLockdown(ctx context.Context, l securityrepo.Lockdown) error
|
||
GetLockdown(ctx context.Context, guildID, channelID int64) (*securityrepo.Lockdown, error)
|
||
ListLockdowns(ctx context.Context, guildID int64, serverWideOnly bool) ([]securityrepo.Lockdown, error)
|
||
DeleteLockdown(ctx context.Context, guildID, channelID int64) error
|
||
AddEvent(ctx context.Context, e securityrepo.Event) error
|
||
ListEvents(ctx context.Context, guildID int64, limit int) ([]securityrepo.Event, error)
|
||
ListUnprocessedCommands(ctx context.Context) ([]securityrepo.Command, error)
|
||
MarkCommandProcessed(ctx context.Context, id int64) error
|
||
SaveRaid(ctx context.Context, x securityrepo.Raid) error
|
||
ListRaids(ctx context.Context) ([]securityrepo.Raid, error)
|
||
DeleteRaid(ctx context.Context, guildID int64) error
|
||
AddRaidCaught(ctx context.Context, guildID int64, c securityrepo.Caught) error
|
||
ListRaidCaught(ctx context.Context, guildID int64, limit int) ([]securityrepo.Caught, error)
|
||
SaveSnapshot(ctx context.Context, s securityrepo.Snapshot) error
|
||
LatestSnapshot(ctx context.Context, guildID int64) (*securityrepo.Snapshot, error)
|
||
GuildsDueForSnapshot(ctx context.Context, maxAge time.Duration) ([]int64, error)
|
||
}
|
||
|
||
// Discord is the subset of *discordgo.Session the system uses.
|
||
type Discord interface {
|
||
Guild(guildID string, options ...discordgo.RequestOption) (*discordgo.Guild, error)
|
||
UserChannelCreate(recipientID string, options ...discordgo.RequestOption) (*discordgo.Channel, error)
|
||
User(userID string, options ...discordgo.RequestOption) (*discordgo.User, error)
|
||
GuildRoles(guildID string, options ...discordgo.RequestOption) ([]*discordgo.Role, error)
|
||
GuildChannels(guildID string, options ...discordgo.RequestOption) ([]*discordgo.Channel, error)
|
||
GuildMember(guildID, userID string, options ...discordgo.RequestOption) (*discordgo.Member, error)
|
||
GuildMemberEdit(guildID, userID string, data *discordgo.GuildMemberParams, options ...discordgo.RequestOption) (*discordgo.Member, error)
|
||
GuildMemberTimeout(guildID, userID string, until *time.Time, options ...discordgo.RequestOption) error
|
||
GuildMemberDeleteWithReason(guildID, userID, reason string, options ...discordgo.RequestOption) error
|
||
GuildBanCreateWithReason(guildID, userID, reason string, days int, options ...discordgo.RequestOption) error
|
||
Channel(channelID string, options ...discordgo.RequestOption) (*discordgo.Channel, error)
|
||
ChannelPermissionSet(channelID, targetID string, targetType discordgo.PermissionOverwriteType, allow, deny int64, options ...discordgo.RequestOption) error
|
||
ChannelPermissionDelete(channelID, targetID string, options ...discordgo.RequestOption) error
|
||
ChannelMessageSendComplex(channelID string, data *discordgo.MessageSend, options ...discordgo.RequestOption) (*discordgo.Message, error)
|
||
RequestWithBucketID(method, urlStr string, data interface{}, bucketID string, options ...discordgo.RequestOption) ([]byte, error)
|
||
ChannelMessageDelete(channelID, messageID string, options ...discordgo.RequestOption) error
|
||
GuildRoleCreate(guildID string, data *discordgo.RoleParams, options ...discordgo.RequestOption) (*discordgo.Role, error)
|
||
GuildRoleEdit(guildID, roleID string, data *discordgo.RoleParams, options ...discordgo.RequestOption) (*discordgo.Role, error)
|
||
GuildChannelCreateComplex(guildID string, data discordgo.GuildChannelCreateData, options ...discordgo.RequestOption) (*discordgo.Channel, error)
|
||
}
|
||
|
||
// UserError is shown to the person who acted, as written.
|
||
type UserError string
|
||
|
||
func (e UserError) Error() string { return string(e) }
|
||
|
||
const (
|
||
ErrNotTrusted = UserError("Only the server owner or someone on the security trusted list can do that.")
|
||
ErrNotOwner = UserError("Only the server owner can change the trusted list.")
|
||
)
|
||
|
||
// Dangerous permissions: what a nuker needs, and what no quarantine or alert
|
||
// role may have.
|
||
const DangerousPerms = discordgo.PermissionAdministrator | discordgo.PermissionManageGuild |
|
||
discordgo.PermissionManageRoles | discordgo.PermissionManageWebhooks
|
||
|
||
const (
|
||
timeoutJoin = 24 * time.Hour
|
||
timeoutQuarantine = 28*24*time.Hour - time.Hour // Discord's maximum is 28 days
|
||
settingsTTL = 15 * time.Second
|
||
ownerTTL = 10 * time.Minute
|
||
)
|
||
|
||
// Engine holds the system's state. One per bot.
|
||
type Engine struct {
|
||
d Discord
|
||
st Store
|
||
botID string
|
||
now func() time.Time
|
||
|
||
mu sync.Mutex
|
||
settings map[string]cachedSettings
|
||
owners map[string]cachedOwner
|
||
raids map[string]*raid
|
||
nukes *nukeCounter
|
||
roleCache map[string]cachedRoles
|
||
heat map[string]*guildHeat
|
||
restoring map[string]bool
|
||
|
||
// syncDMs sends alert DMs before returning (tests); normally they go out
|
||
// in the background so an alert never waits on them.
|
||
syncDMs bool
|
||
|
||
// ChannelPerms reports a member's permissions in a channel from the
|
||
// gateway cache (no REST call), used to exempt moderators from heat.
|
||
// Nil means nobody is exempt by permission.
|
||
ChannelPerms func(userID, channelID string) (int64, error)
|
||
}
|
||
|
||
type cachedSettings struct {
|
||
s *securityrepo.Settings
|
||
trusted []securityrepo.Trusted
|
||
at time.Time
|
||
}
|
||
|
||
type cachedOwner struct {
|
||
id string
|
||
name string
|
||
at time.Time
|
||
}
|
||
|
||
func New(d Discord, st Store, botID string) *Engine {
|
||
return &Engine{
|
||
d: d, st: st, botID: botID, now: time.Now,
|
||
settings: map[string]cachedSettings{},
|
||
owners: map[string]cachedOwner{},
|
||
raids: map[string]*raid{},
|
||
nukes: newNukeCounter(),
|
||
heat: map[string]*guildHeat{},
|
||
restoring: map[string]bool{},
|
||
}
|
||
}
|
||
|
||
var (
|
||
defaultEngine *Engine
|
||
pendingStore Store
|
||
)
|
||
|
||
// Configure sets the store; Start creates the engine once the bot knows its
|
||
// own user ID (after the gateway connects).
|
||
func Configure(st Store) { pendingStore = st }
|
||
|
||
// Start creates the default engine for a connected session. It returns nil
|
||
// when no store was configured.
|
||
func Start(d Discord, botID string) *Engine {
|
||
if pendingStore == nil {
|
||
return nil
|
||
}
|
||
defaultEngine = New(d, pendingStore, botID)
|
||
return defaultEngine
|
||
}
|
||
|
||
func Init(e *Engine) { defaultEngine = e }
|
||
|
||
func Default() *Engine { return defaultEngine }
|
||
|
||
func parseID(s string) int64 {
|
||
n, _ := strconv.ParseInt(s, 10, 64)
|
||
return n
|
||
}
|
||
|
||
func id(n int64) string { return strconv.FormatInt(n, 10) }
|
||
|
||
// load returns the guild's settings and trusted list, cached briefly.
|
||
func (e *Engine) load(ctx context.Context, guildID string) (*securityrepo.Settings, []securityrepo.Trusted, error) {
|
||
e.mu.Lock()
|
||
c, ok := e.settings[guildID]
|
||
e.mu.Unlock()
|
||
if ok && e.now().Sub(c.at) < settingsTTL {
|
||
return c.s, c.trusted, nil
|
||
}
|
||
g := parseID(guildID)
|
||
s, err := e.st.GetSettings(ctx, g)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
trusted, err := e.st.ListTrusted(ctx, g)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
e.mu.Lock()
|
||
e.settings[guildID] = cachedSettings{s: s, trusted: trusted, at: e.now()}
|
||
e.mu.Unlock()
|
||
return s, trusted, nil
|
||
}
|
||
|
||
// Invalidate drops the cached settings after a change, so it applies at once.
|
||
func (e *Engine) Invalidate(guildID string) {
|
||
e.mu.Lock()
|
||
delete(e.settings, guildID)
|
||
e.mu.Unlock()
|
||
}
|
||
|
||
// Settings returns the current settings (for status displays).
|
||
func (e *Engine) Settings(ctx context.Context, guildID string) (*securityrepo.Settings, []securityrepo.Trusted, error) {
|
||
return e.load(ctx, guildID)
|
||
}
|
||
|
||
// OwnerID returns the guild owner's ID, cached.
|
||
func (e *Engine) OwnerID(guildID string) (string, error) {
|
||
e.mu.Lock()
|
||
c, ok := e.owners[guildID]
|
||
e.mu.Unlock()
|
||
if ok && e.now().Sub(c.at) < ownerTTL {
|
||
return c.id, nil
|
||
}
|
||
g, err := e.d.Guild(guildID)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
e.mu.Lock()
|
||
e.owners[guildID] = cachedOwner{id: g.OwnerID, name: g.Name, at: e.now()}
|
||
e.mu.Unlock()
|
||
return g.OwnerID, nil
|
||
}
|
||
|
||
// guildName returns the guild's name (cached with the owner), or "".
|
||
func (e *Engine) guildName(guildID string) string {
|
||
if _, err := e.OwnerID(guildID); err != nil {
|
||
return ""
|
||
}
|
||
e.mu.Lock()
|
||
defer e.mu.Unlock()
|
||
return e.owners[guildID].name
|
||
}
|
||
|
||
// trustedBy reports whether a user (with their role IDs) is on the list.
|
||
func trustedBy(list []securityrepo.Trusted, userID string, roles []string) bool {
|
||
uid := parseID(userID)
|
||
for _, t := range list {
|
||
switch t.Kind {
|
||
case "user":
|
||
if t.TargetID == uid {
|
||
return true
|
||
}
|
||
case "role":
|
||
for _, r := range roles {
|
||
if parseID(r) == t.TargetID {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// IsTrusted reports whether a user is exempt from anti-nuke and may change
|
||
// security settings: the owner, the bot itself, or someone on the trusted
|
||
// list (directly or by role). When roles is nil the member is looked up.
|
||
// Any lookup failure counts as not trusted.
|
||
func (e *Engine) IsTrusted(ctx context.Context, guildID, userID string, roles []string) bool {
|
||
if userID == "" {
|
||
return false
|
||
}
|
||
if userID == e.botID {
|
||
return true
|
||
}
|
||
if owner, err := e.OwnerID(guildID); err == nil && owner == userID {
|
||
return true
|
||
}
|
||
_, trusted, err := e.load(ctx, guildID)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
if roles == nil {
|
||
m, err := e.d.GuildMember(guildID, userID)
|
||
if err != nil {
|
||
return trustedBy(trusted, userID, nil)
|
||
}
|
||
roles = m.Roles
|
||
}
|
||
return trustedBy(trusted, userID, roles)
|
||
}
|
||
|
||
// IsOwner reports whether the user owns the guild. Lookup failures are "no".
|
||
func (e *Engine) IsOwner(guildID, userID string) bool {
|
||
owner, err := e.OwnerID(guildID)
|
||
return err == nil && owner != "" && owner == userID
|
||
}
|
||
|
||
// RequireTrusted returns ErrNotTrusted unless the actor is owner or trusted.
|
||
func (e *Engine) RequireTrusted(ctx context.Context, guildID string, actor *discordgo.Member) error {
|
||
if actor == nil || actor.User == nil || !e.IsTrusted(ctx, guildID, actor.User.ID, actor.Roles) {
|
||
return ErrNotTrusted
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (e *Engine) event(ctx context.Context, guildID string, kind string, actor, target string, detail string) {
|
||
_ = e.st.AddEvent(ctx, securityrepo.Event{
|
||
GuildID: parseID(guildID), Kind: kind, ActorID: parseID(actor), TargetID: parseID(target), Detail: clip(detail, 1000),
|
||
})
|
||
}
|
||
|
||
func clip(s string, n int) string {
|
||
if r := []rune(s); len(r) > n {
|
||
return string(r[:n-1]) + "…"
|
||
}
|
||
return s
|
||
}
|
||
|
||
// safeName renders a user-controlled name for an embed: markdown escaped and
|
||
// mentions defused (allowed mentions are restricted too, this is belt and
|
||
// braces).
|
||
func safeName(u *discordgo.User) string {
|
||
if u == nil {
|
||
return "unknown"
|
||
}
|
||
name := u.Username
|
||
if u.GlobalName != "" && u.GlobalName != u.Username {
|
||
name = u.GlobalName + " (" + u.Username + ")"
|
||
}
|
||
r := strings.NewReplacer(
|
||
`\`, `\\`, `*`, `\*`, `_`, `\_`, "`", "\\`", `~`, `\~`, `|`, `\|`, `>`, `\>`, `#`, `\#`,
|
||
`[`, `\[`, `]`, `\]`, `(`, `\(`, `)`, `\)`, `@`, "@", `<`, "<",
|
||
)
|
||
return clip(r.Replace(name), 80)
|
||
}
|