From 228853febd05965622eeeb02b047e458a351e2fe Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Fri, 14 Aug 2026 12:15:14 +0200 Subject: [PATCH] =?UTF-8?q?Radio:=20die=20zweite=20Sackgasse=20von=20der?= =?UTF-8?q?=20ersten=20trennen=20=E2=80=94=20und=20UDP=20messbar=20machen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aus dem Betrieb: alle drei Beitrittsschritte durch, trotzdem "signalling". Das ist kein Widerspruch. In @discordjs/voice gibt es zwei Wege dorthin: - `configureNetworking` steigt aus, wenn der Endpunkt leer ist. Discord schickt das bei einem Regionswechsel; kommt keine zweite Meldung, bleibt die Verbindung fuer immer stehen. - Scheitert der Austausch mit dem Sprachserver, wirft die Bibliothek die Verbindung nach "signalling" zurueck und faengt von vorn an. Von aussen sieht das genauso aus. Deshalb schreibt der Adapter jetzt den Endpunkt mit, und die Verbindung protokolliert ihren Weg. War sie einmal bei "connecting" und ist zurueckgefallen, ist es der UDP-Austausch — dann steht der Endpunkt in der Meldung, damit man ihn von Hand pruefen kann. Dazu tools/udp-pruefen.mjs: im Container aufrufen und es sagt, ob UDP ueberhaupt rauskommt und ob auch auf hohen Ports. Genau das unterscheidet "Firewall zu" von "Firewall laesst nur die ueblichen Ports durch" — und Discord-Sprache braucht die hohen. Co-Authored-By: Claude Opus 5 --- src/bot/radio.js | 38 +++++++++++++++-- tools/udp-pruefen.mjs | 96 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 tools/udp-pruefen.mjs diff --git a/src/bot/radio.js b/src/bot/radio.js index 1b6e1dd..8fb4bc8 100644 --- a/src/bot/radio.js +++ b/src/bot/radio.js @@ -165,6 +165,11 @@ function beobachteterAdapter(guild, schritte) { }, onVoiceServerUpdate: (daten) => { schritte.sprachserver = true; + // Ein leerer Endpunkt ist kein Versehen: Discord schickt das, + // wenn es den Sprachserver gerade neu zuteilt. Die Bibliothek + // wartet dann auf die zweite Meldung — kommt die nie, bleibt + // sie fuer immer bei "signalling" stehen, ohne Fehler. + schritte.endpunkt = daten?.endpoint ?? null; return methods.onVoiceServerUpdate(daten); }, }; @@ -197,7 +202,23 @@ export function beitrittsDiagnose(schritte) { return 'Der Sprachserver steht, aber der eigene Sprachstatus kam nie an — ' + 'dem Bot fehlt der Intent **GuildVoiceStates**.'; } - return 'Der Austausch mit dem Sprachserver kam nicht zu Ende — ausgehendes UDP dicht?'; + // Ab hier ist alles angekommen. Jetzt entscheidet der Weg, den die + // Verbindung genommen hat. + if (schritte.endpunkt === null) { + return 'Discord hat den Beitritt bestätigt, aber keinen Sprachserver zugeteilt. ' + + 'Das passiert bei einem Regionswechsel — im Kanal unter *Region überschreiben* ' + + 'einmal eine feste Region wählen.'; + } + const kamAnsNetz = (schritte.weg ?? []).includes('connecting'); + if (!kamAnsNetz) { + return `Der Sprachserver (${schritte.endpunkt ?? '?'}) wurde genannt, aber die Verbindung ` + + 'kam nie bis zum Netzwerk-Schritt.'; + } + // Sie war beim Netzwerk-Schritt und ist zurückgefallen: der Austausch mit + // dem Sprachserver selbst scheitert. Das ist UDP, und zwar ausgehend. + return `Der Austausch mit dem Sprachserver **${schritte.endpunkt}** scheitert. ` + + 'Discord-Sprache braucht **ausgehendes UDP** (Zielports 50000–65535) — ' + + 'wenn nur TCP rausdarf, sieht es genau so aus.'; } /** Läuft gerade etwas auf diesem Server? */ @@ -226,7 +247,10 @@ export async function spielen(kanal, station) { const vorher = laufend.get(guildId); // Wird nur gefüllt, wenn wirklich neu verbunden wird - const schritte = { angefragt: null, eigenerStatus: false, sprachserver: false }; + const schritte = { + angefragt: null, eigenerStatus: false, sprachserver: false, + endpunkt: undefined, weg: [], + }; let verbindung = getVoiceConnection(guildId); if (!verbindung || verbindung.joinConfig.channelId !== kanal.id) { @@ -237,6 +261,13 @@ export async function spielen(kanal, station) { adapterCreator: beobachteterAdapter(kanal.guild, schritte), selfDeaf: true, // der Bot muss nicht hören, nur senden }); + // Der Weg, den die Verbindung nimmt, unterscheidet zwei Sackgassen, + // die von aussen gleich aussehen: nie über „signalling" hinaus (dann + // fehlt der Sprachserver) oder immer wieder dorthin zurück (dann + // scheitert der UDP-Austausch und die Bibliothek fängt von vorn an). + verbindung.on('stateChange', (_, neuerStand) => { + if (schritte.weg.at(-1) !== neuerStand.status) schritte.weg.push(neuerStand.status); + }); // Discord verschiebt Sprachserver im Betrieb; ohne dieses Zurückholen // bleibt die Verbindung dann still stehen, statt sich zu erholen. // @@ -283,7 +314,8 @@ export async function spielen(kanal, station) { const grund = beitrittsDiagnose(schritte); console.error(`[radio] Beitritt steckt bei "${steckt}" — ` + `angefragt: ${schritte.angefragt}, eigener Status: ${schritte.eigenerStatus}, ` - + `Sprachserver: ${schritte.sprachserver}`); + + `Sprachserver: ${schritte.sprachserver}, Endpunkt: ${schritte.endpunkt ?? '—'}, ` + + `Weg: ${(schritte.weg ?? []).join(' → ') || '(keiner)'}`); throw new Error(`Sprachverbindung kam nicht zustande. ${grund}`); } diff --git a/tools/udp-pruefen.mjs b/tools/udp-pruefen.mjs new file mode 100644 index 0000000..ac6dec1 --- /dev/null +++ b/tools/udp-pruefen.mjs @@ -0,0 +1,96 @@ +// Kommt UDP aus diesem Container heraus? +// +// Discord-Sprache läuft über UDP auf hohen Zielports. Ist das dicht, sitzt +// der Bot sichtbar im Sprachkanal und schweigt — im Log steht dann +// „Der Austausch mit dem Sprachserver scheitert". +// +// Aufruf im Container: +// docker exec -it node tools/udp-pruefen.mjs +// +// Geprüft wird in zwei Stufen, weil das den Unterschied ausmacht: +// 1. UDP auf Port 53 (DNS) — geht überhaupt UDP raus? +// 2. UDP auf einen hohen Port (STUN, 19302) — oder nur die üblichen? +// +// Beide grün und der Bot schweigt trotzdem? Dann liegt es nicht am UDP. +import dgram from 'node:dgram'; + +/** Ein Paket hinschicken und auf Antwort warten */ +function versuch({ host, port, paket, timeoutMs = 4000 }) { + return new Promise((fertig) => { + const sock = dgram.createSocket('udp4'); + const start = Date.now(); + const schluss = (ergebnis) => { + clearTimeout(timer); + try { sock.close(); } catch { /* schon zu */ } + fertig(ergebnis); + }; + const timer = setTimeout(() => schluss({ ok: false, grund: 'keine Antwort' }), timeoutMs); + sock.on('message', () => schluss({ ok: true, ms: Date.now() - start })); + sock.on('error', (e) => schluss({ ok: false, grund: e.message })); + sock.send(paket, port, host, (e) => { + if (e) schluss({ ok: false, grund: `Senden ging nicht: ${e.message}` }); + }); + }); +} + +// Eine DNS-Anfrage nach example.com — kürzestmöglich von Hand gebaut +const dnsPaket = Buffer.concat([ + Buffer.from([0x12, 0x34, 0x01, 0x00, 0, 1, 0, 0, 0, 0, 0, 0]), + Buffer.from([7]), Buffer.from('example'), + Buffer.from([3]), Buffer.from('com'), + Buffer.from([0, 0, 1, 0, 1]), +]); + +// Eine STUN-Bindungsanfrage: 20 Byte Kopf, fester Magic Cookie +const stunPaket = Buffer.concat([ + Buffer.from([0x00, 0x01, 0x00, 0x00]), + Buffer.from([0x21, 0x12, 0xa4, 0x42]), + Buffer.from('d4rkbotudpprue'.slice(0, 12)), +]); + +const proben = [ + { name: 'UDP raus (DNS)', host: '1.1.1.1', port: 53, paket: dnsPaket }, + { name: 'UDP raus (DNS, zweiter)', host: '8.8.8.8', port: 53, paket: dnsPaket }, + { name: 'UDP auf hohem Port (STUN)', host: 'stun.l.google.com', port: 19302, paket: stunPaket }, + { name: 'UDP auf hohem Port (STUN 2)', host: 'stun1.l.google.com', port: 19302, paket: stunPaket }, +]; + +console.log('Prüfe ausgehendes UDP …\n'); +const ergebnisse = []; +for (const p of proben) { + const r = await versuch(p); + ergebnisse.push({ ...p, ...r }); + console.log(` ${r.ok ? '✅' : '❌'} ${p.name.padEnd(28)} ${p.host}:${p.port} ` + + (r.ok ? `${r.ms} ms` : r.grund)); +} + +const niedrig = ergebnisse.filter((r) => r.port < 1024).some((r) => r.ok); +const hoch = ergebnisse.filter((r) => r.port >= 1024).some((r) => r.ok); + +console.log(''); +if (hoch) { + console.log('UDP kommt auf hohen Ports raus. Wenn das Radio trotzdem schweigt,'); + console.log('liegt es nicht an ausgehendem UDP — dann den Endpunkt aus dem Log'); + console.log('mit dem nächsten Abschnitt prüfen.'); +} else if (niedrig) { + console.log('UDP geht nur auf niedrigen Ports raus. Genau das bricht Discord-Sprache:'); + console.log('die Sprachserver antworten auf hohen Ports. In der Firewall ausgehendes'); + console.log('UDP zu 50000–65535 freigeben.'); +} else { + console.log('Es kommt gar kein UDP heraus. Discord-Sprache kann so nicht funktionieren.'); + console.log('Ausgehendes UDP in der Firewall des Rootservers freigeben.'); +} + +// Endpunkt aus dem Log mitgeben: node tools/udp-pruefen.mjs frankfurt1234.discord.media +const ziel = process.argv[2]; +if (ziel) { + console.log(`\nZusätzlich: ${ziel} auf Port 50000 …`); + // Discord antwortet auf ein Zufallspaket nicht — hier zählt nur, ob das + // Senden selbst durchgeht und ob eine Fehlermeldung zurückkommt. + const r = await versuch({ host: ziel, port: 50000, paket: Buffer.alloc(74), timeoutMs: 3000 }); + console.log(r.ok + ? ' Antwort erhalten (ungewöhnlich, aber gut).' + : ` ${r.grund === 'keine Antwort' + ? 'keine Antwort — das ist hier normal, ein Fehler waere aussagekräftiger' + : `Fehler: ${r.grund}`}`); +}