Raid-Schutz: erkennt Beitritts-Wellen und sperrt voruebergehend
Neues Modul anti_raid, standardmaessig AUS. Zaehlt Beitritte in einem Zeitfenster; reisst es, geht der Server dicht: Verifizierung auf Hoch, Einladungen gesperrt, DM an den Owner und Eintrag ins Protokoll. Bewusst zurueckhaltend — es wird niemand gekickt oder gebannt. Bei einem Fehlalarm, etwa nach einem Stream-Shoutout, waere ein Massenkick schlimmer als der Raid. Beide Massnahmen sind reversibel und treffen niemanden, der schon drin ist. Der Sperrzustand liegt in den Einstellungen, nicht nur im Speicher: startet der Bot mitten in einer Sperre neu, waere der Timer sonst weg und der Server bliebe fuer immer dicht. Beim Start wird die Restzeit abgesessen oder, wenn laengst abgelaufen, sofort aufgehoben. Drei Werte im Panel: Schwelle (8), Fenster (20 s), Sperrdauer (15 min). disableInvites() gibt es erst ab discord.js 14.4 und nur mit ManageGuild — beides wird geprueft, statt im Ernstfall einen Fehler zu werfen. Die Fenster-Logik ist als reine Funktion herausgezogen und mit neun Faellen geprueft: langsame Beitritte loesen nicht aus, die Welle schon, der Rand des Zeitfensters zaehlt nicht mehr mit, und nach Ruhe faengt das Fenster von vorn. Offen: ein Befehl zum sofortigen Aufheben. Bis dahin laeuft die Sperre aus oder man setzt die Verifizierung in Discord selbst zurueck. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -278,6 +278,8 @@ export default {
|
|||||||
|
|
||||||
'feat.moderation': 'Moderation',
|
'feat.moderation': 'Moderation',
|
||||||
'feat.moderation.desc': 'Warnings, timeouts, cleanup — with history and a log.',
|
'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': 'Audit log',
|
||||||
'feat.modlog.desc': 'Deleted and edited messages, joins, name and role changes.',
|
'feat.modlog.desc': 'Deleted and edited messages, joins, name and role changes.',
|
||||||
'feat.modmail': 'Modmail',
|
'feat.modmail': 'Modmail',
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const MODULE_ICONS = {
|
|||||||
voting: IconThumbsUp, giveaways: IconGiveaway, birthdays: IconBirthday,
|
voting: IconThumbsUp, giveaways: IconGiveaway, birthdays: IconBirthday,
|
||||||
events: IconEvents, playtester: IconGame, alpha_keys: IconKey,
|
events: IconEvents, playtester: IconGame, alpha_keys: IconKey,
|
||||||
role_menus: IconRoles, applications: IconApplications,
|
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,
|
tickets: IconSupport, welcome: IconMagic, autorole: IconTeam,
|
||||||
sticky_roles: IconRefresh, bug_reports: IconWarning,
|
sticky_roles: IconRefresh, bug_reports: IconWarning,
|
||||||
server_monitor: IconServer, watchdog: IconMonitor, temp_voice: IconVoice,
|
server_monitor: IconServer, watchdog: IconMonitor, temp_voice: IconVoice,
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { tuning } from '../tuning.js';
|
|||||||
import { archiveDevlogMessage, removeDevlog } from './devlog-archive.js';
|
import { archiveDevlogMessage, removeDevlog } from './devlog-archive.js';
|
||||||
import { registerCommunityListeners } from './community.js';
|
import { registerCommunityListeners } from './community.js';
|
||||||
import { registerModTools } from './mod-tools.js';
|
import { registerModTools } from './mod-tools.js';
|
||||||
|
import { registerAntiRaid } from './anti-raid.js';
|
||||||
import { registerLevels } from './levels.js';
|
import { registerLevels } from './levels.js';
|
||||||
import { registerPresence } from './presence.js';
|
import { registerPresence } from './presence.js';
|
||||||
import { registerExtras } from './extras.js';
|
import { registerExtras } from './extras.js';
|
||||||
@@ -348,6 +349,8 @@ export async function startBot() {
|
|||||||
|
|
||||||
registerCommunityListeners(client);
|
registerCommunityListeners(client);
|
||||||
registerModTools(client);
|
registerModTools(client);
|
||||||
|
// Vor den anderen Beitritts-Handlern egal — die Erkennung zaehlt nur mit
|
||||||
|
registerAntiRaid(client);
|
||||||
registerLevels(client);
|
registerLevels(client);
|
||||||
registerPresence(client);
|
registerPresence(client);
|
||||||
registerExtras(client);
|
registerExtras(client);
|
||||||
|
|||||||
@@ -151,6 +151,11 @@ export const MODULES = [
|
|||||||
tab: 'support', default: true,
|
tab: 'support', default: true,
|
||||||
tool: { tab: 'support', label: 'Moderation' },
|
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',
|
id: 'modlog', group: 'moderation', name: 'Protokoll',
|
||||||
desc: 'Gelöschte und bearbeitete Nachrichten, Beitritte, Namens- und Rollenwechsel.',
|
desc: 'Gelöschte und bearbeitete Nachrichten, Beitritte, Namens- und Rollenwechsel.',
|
||||||
|
|||||||
@@ -94,6 +94,20 @@ export const TUNING = [
|
|||||||
},
|
},
|
||||||
|
|
||||||
/* ── Obergrenzen ──────────────────────────────────── */
|
/* ── 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',
|
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.',
|
hint: 'Ältere fallen weg. Hoch gesetzt dauert das Schließen länger.',
|
||||||
|
|||||||
Reference in New Issue
Block a user