diff --git a/frontend/src/locales/en.js b/frontend/src/locales/en.js index d014071..4f131ab 100644 --- a/frontend/src/locales/en.js +++ b/frontend/src/locales/en.js @@ -278,6 +278,8 @@ export default { 'feat.moderation': 'Moderation', 'feat.moderation.desc': 'Warnings, timeouts, cleanup — with history and a log.', + 'feat.anti_raid': 'Raid protection', + 'feat.anti_raid.desc': 'Spots waves of joins and locks the server down for a while.', 'feat.modlog': 'Audit log', 'feat.modlog.desc': 'Deleted and edited messages, joins, name and role changes.', 'feat.modmail': 'Modmail', diff --git a/frontend/src/module-icons.jsx b/frontend/src/module-icons.jsx index 5d74718..63d57a0 100644 --- a/frontend/src/module-icons.jsx +++ b/frontend/src/module-icons.jsx @@ -19,7 +19,7 @@ export const MODULE_ICONS = { voting: IconThumbsUp, giveaways: IconGiveaway, birthdays: IconBirthday, events: IconEvents, playtester: IconGame, alpha_keys: IconKey, role_menus: IconRoles, applications: IconApplications, - moderation: IconShield, modlog: IconArchive, modmail: IconMail, + moderation: IconShield, anti_raid: IconLock, modlog: IconArchive, modmail: IconMail, tickets: IconSupport, welcome: IconMagic, autorole: IconTeam, sticky_roles: IconRefresh, bug_reports: IconWarning, server_monitor: IconServer, watchdog: IconMonitor, temp_voice: IconVoice, diff --git a/src/bot/anti-raid.js b/src/bot/anti-raid.js new file mode 100644 index 0000000..5cc861d --- /dev/null +++ b/src/bot/anti-raid.js @@ -0,0 +1,176 @@ +// Anti-Raid: erkennt Beitritts-Wellen und macht den Server dicht. +// +// Bewusst zurückhaltend: es wird niemand gekickt oder gebannt. Bei einem +// Fehlalarm — ein Stream-Shoutout, ein geteilter Invite — wäre ein Massenkick +// schlimmer als der Raid selbst. Stattdessen wird die Verifizierungsstufe +// hochgesetzt und der Invite-Zugang gesperrt, beides ist reversibel und +// trifft niemanden, der schon drin ist. +// +// Der Sperrzustand steht in den Einstellungen, nicht nur im Speicher: startet +// der Bot mitten in einer Sperre neu, wäre der Timer sonst weg und der Server +// bliebe für immer dicht. +import { EmbedBuilder, Events, GuildVerificationLevel } from 'discord.js'; +import { getSetting, setSetting } from '../db.js'; +import { config } from '../config.js'; +import { moduleEnabled } from '../modules.js'; +import { tuning } from '../tuning.js'; +import { brandColor2, brandFooter, modlogChannelId } from '../runtime-settings.js'; + +const BIS = 'antiraid_locked_until'; // ISO-Zeit, leer = offen +const VORHER = 'antiraid_prev_level'; // Verifizierungsstufe vor der Sperre + +// Zeitstempel der letzten Beitritte. Nur im Speicher: ein Raid spielt sich in +// Sekunden ab, über einen Neustart hinweg ist das Fenster wertlos. +let beitritte = []; +let aufhebeTimer = null; + +function jetzt() { + return Date.now(); +} + +/** Meldung an Protokoll-Kanal und per DM an den Owner */ +async function melden(client, titel, text) { + const embed = new EmbedBuilder() + .setColor(brandColor2()) + .setTitle(titel) + .setDescription(text) + .setFooter({ text: brandFooter('ANTI-RAID') }) + .setTimestamp(); + + const channelId = modlogChannelId(); + if (channelId) { + const channel = await client.channels.fetch(channelId).catch(() => null); + if (channel?.isTextBased()) await channel.send({ embeds: [embed] }).catch(() => {}); + } + // Die DM ist der eigentliche Alarm — der Protokoll-Kanal wird nachts nicht gelesen + if (config.adminDiscordId) { + const user = await client.users.fetch(config.adminDiscordId).catch(() => null); + await user?.send({ embeds: [embed] }).catch(() => {}); + } +} + +export function istGesperrt() { + const bis = getSetting(BIS); + return Boolean(bis) && new Date(bis).getTime() > jetzt(); +} + +/** Server dichtmachen. Gibt zurück, was tatsächlich griff. */ +async function sperren(client, guild, anzahl, fenster) { + const minuten = tuning('raid_lockdown'); + const vorher = guild.verificationLevel; + const getan = []; + + const grund = `Anti-Raid: ${anzahl} Beitritte in ${fenster} s`; + + if (vorher < GuildVerificationLevel.High) { + const ok = await guild.setVerificationLevel(GuildVerificationLevel.High, grund) + .then(() => true) + .catch((e) => { console.error('[antiraid] Verifizierung:', e.message); return false; }); + if (ok) getan.push('Verifizierung auf **Hoch**'); + } + + // Erst ab discord.js 14.4 vorhanden und nur mit ManageGuild — beides prüfen, + // statt dem Bot beim Ernstfall einen Fehler zu werfen + if (typeof guild.disableInvites === 'function') { + const ok = await guild.disableInvites(true) + .then(() => true) + .catch((e) => { console.error('[antiraid] Invites:', e.message); return false; }); + if (ok) getan.push('Einladungen **gesperrt**'); + } + + setSetting(VORHER, String(vorher)); + setSetting(BIS, new Date(jetzt() + minuten * 60000).toISOString()); + planeAufhebung(client, minuten * 60000); + + await melden( + client, + '🚨 Raid-Verdacht — Server gesperrt', + `**${anzahl} Beitritte in ${fenster} Sekunden.**\n\n` + + (getan.length ? `${getan.map((g) => `• ${g}`).join('\n')}\n\n` : '_Keine Maßnahme griff — fehlen dem Bot Rechte?_\n\n') + + `Wird in **${minuten} Minuten** automatisch aufgehoben.\n` + + 'Es wurde niemand gekickt oder gebannt.' + ); + console.warn(`[antiraid] Sperre aktiv: ${anzahl} Beitritte in ${fenster}s`); +} + +/** Sperre zurücknehmen — auch beim Start, falls über einen Neustart hinweg */ +export async function entsperren(client, { melde = true } = {}) { + if (!getSetting(BIS)) return false; + + const guild = client.guilds.cache.first(); + const zurueck = Number(getSetting(VORHER)); + const getan = []; + + if (guild) { + if (Number.isInteger(zurueck) && zurueck !== guild.verificationLevel) { + const ok = await guild.setVerificationLevel(zurueck, 'Anti-Raid: Sperre aufgehoben') + .then(() => true).catch(() => false); + if (ok) getan.push('Verifizierung zurückgesetzt'); + } + if (typeof guild.disableInvites === 'function') { + const ok = await guild.disableInvites(false).then(() => true).catch(() => false); + if (ok) getan.push('Einladungen wieder offen'); + } + } + + setSetting(BIS, ''); + setSetting(VORHER, ''); + beitritte = []; + if (aufhebeTimer) { clearTimeout(aufhebeTimer); aufhebeTimer = null; } + + if (melde) { + await melden(client, '✅ Sperre aufgehoben', + getan.length ? getan.map((g) => `• ${g}`).join('\n') : 'Nichts zurückzunehmen.'); + } + console.log('[antiraid] Sperre aufgehoben'); + return true; +} + +function planeAufhebung(client, ms) { + if (aufhebeTimer) clearTimeout(aufhebeTimer); + // setTimeout kippt über ~24,8 Tagen ins Negative und feuert sofort + aufhebeTimer = setTimeout(() => { + entsperren(client).catch((e) => console.error('[antiraid]', e)); + }, Math.min(Math.max(ms, 0), 2 ** 31 - 1)); +} + +/** + * Einen Beitritt verbuchen und prüfen, ob das Fenster überläuft. + * Exportiert, damit sich die Erkennung ohne Discord testen lässt. + */ +export function beitrittZaehlen(zeit = jetzt(), { schwelle = tuning('raid_joins'), fenster = tuning('raid_window') } = {}) { + const grenze = zeit - fenster * 1000; + beitritte = beitritte.filter((t) => t > grenze); + beitritte.push(zeit); + return { anzahl: beitritte.length, ausgeloest: beitritte.length >= schwelle, fenster }; +} + +/** Nur für Tests: Fenster leeren */ +export function fensterLeeren() { + beitritte = []; +} + +export function registerAntiRaid(client) { + client.on(Events.GuildMemberAdd, async (member) => { + try { + if (!moduleEnabled('anti_raid')) return; + if (istGesperrt()) return; // schon dicht, nicht doppelt melden + + const { anzahl, ausgeloest, fenster } = beitrittZaehlen(); + if (ausgeloest) await sperren(client, member.guild, anzahl, fenster); + } catch (error) { + console.error('[antiraid]', error); + } + }); + + // Nach einem Neustart: hängt noch eine Sperre, den Rest der Zeit absitzen + // und danach aufheben. Ist sie längst abgelaufen, sofort. + client.once(Events.ClientReady, () => { + const bis = getSetting(BIS); + if (!bis) return; + const rest = new Date(bis).getTime() - jetzt(); + console.log(`[antiraid] Sperre aus vorherigem Lauf, Rest ${Math.round(rest / 1000)}s`); + if (rest > 0) planeAufhebung(client, rest); + else entsperren(client, { melde: false }).catch(() => {}); + }); +} diff --git a/src/bot/client.js b/src/bot/client.js index 1a1759d..6eae950 100644 --- a/src/bot/client.js +++ b/src/bot/client.js @@ -7,6 +7,7 @@ import { tuning } from '../tuning.js'; import { archiveDevlogMessage, removeDevlog } from './devlog-archive.js'; import { registerCommunityListeners } from './community.js'; import { registerModTools } from './mod-tools.js'; +import { registerAntiRaid } from './anti-raid.js'; import { registerLevels } from './levels.js'; import { registerPresence } from './presence.js'; import { registerExtras } from './extras.js'; @@ -348,6 +349,8 @@ export async function startBot() { registerCommunityListeners(client); registerModTools(client); + // Vor den anderen Beitritts-Handlern egal — die Erkennung zaehlt nur mit + registerAntiRaid(client); registerLevels(client); registerPresence(client); registerExtras(client); diff --git a/src/modules.js b/src/modules.js index 3c90920..cca311d 100644 --- a/src/modules.js +++ b/src/modules.js @@ -151,6 +151,11 @@ export const MODULES = [ tab: 'support', default: true, tool: { tab: 'support', label: 'Moderation' }, }, + { + id: 'anti_raid', group: 'moderation', name: 'Raid-Schutz', + desc: 'Erkennt Beitritts-Wellen und macht den Server vorübergehend dicht.', + tab: 'support', setting: 'anti_raid_enabled', defaultOn: false, + }, { id: 'modlog', group: 'moderation', name: 'Protokoll', desc: 'Gelöschte und bearbeitete Nachrichten, Beitritte, Namens- und Rollenwechsel.', diff --git a/src/tuning.js b/src/tuning.js index 3089c7c..1c9f275 100644 --- a/src/tuning.js +++ b/src/tuning.js @@ -94,6 +94,20 @@ export const TUNING = [ }, /* ── Obergrenzen ──────────────────────────────────── */ + { + id: 'raid_joins', module: 'anti_raid', group: 'grenzen', label: 'Raid-Verdacht ab', + hint: 'So viele Beitritte im Zeitfenster lösen die Sperre aus. Zu niedrig, und ein Stream-Shoutout reicht schon.', + unit: 'Beitritte', default: 8, min: 3, max: 100, + }, + { + id: 'raid_window', module: 'anti_raid', group: 'grenzen', label: 'Raid — Zeitfenster', + unit: 'Sekunden', default: 20, min: 5, max: 600, + }, + { + id: 'raid_lockdown', module: 'anti_raid', group: 'zeiten', label: 'Raid-Sperre dauert', + hint: 'Danach hebt der Bot sie von selbst wieder auf.', + unit: 'Minuten', default: 15, min: 1, max: 720, + }, { id: 'ticket_transcript_max', module: 'tickets', group: 'grenzen', label: 'Nachrichten im Ticket-Protokoll', hint: 'Ältere fallen weg. Hoch gesetzt dauert das Schließen länger.',