Radio: den Schliesscode festhalten, statt UDP zu behaupten
Deploy / check (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s

Die Meldung "ausgehendes UDP dicht" war geraten. Am 14.08.2026 im laufenden
Container gegengemessen, und sie stimmt nicht:

  UDP raus (DNS)              1.1.1.1:53                 4 ms
  UDP auf hohem Port (STUN)   stun.l.google.com:19302    9 ms
  TCP zum Sprachserver        c-fra13-...discord.media:2096  offen
  Verschluesselung            libsodium-wrappers 0.8.4 geladen

UDP kommt also raus, auch auf hohen Ports und mit Rueckweg durchs NAT. Der
Satz stand trotzdem da, weil "Weg enthaelt connecting" als Beweis genommen
wurde. Ist es nicht: in @discordjs/voice 0.18 faellt die Verbindung bei
*jedem* Websocket-Schluss ausser 4014 stumm nach "signalling" zurueck
(onNetworkingClose) und wirft den Schliesscode dabei weg. Ob der UDP-Teil je
begonnen hat, steht nirgends -- die Pruefung konnte den Unterschied nicht
sehen und hat trotzdem geurteilt.

Jetzt wird das Netzteil selbst mitgeschrieben: seine Schrittnummer
(NetworkingStatusCode, nachgeschlagen -- Websocket auf / Anmeldung /
UDP-Handschlag / Protokollwahl) und der Schliesscode. Nicht ueber `debug`,
denn das druckt Token und Sitzungsschluessel mit. Daraus folgt die Diagnose:

  stehen bei UDP-Handschlag  -> jetzt darf sie UDP sagen
  zu vor dem UDP-Teil        -> Sitzung (4006/4009), kein Netzproblem
  stehen bei Protokollwahl   -> Verschluesselung
  kein Mitschnitt            -> "Ursache offen". Nicht mehr "UDP".

Warum der UDP-Fall so aussieht, als haenge er: performIPDiscovery hat in
0.18 keine Zeitschranke, und die UDP-Keepalives werden nicht mehr
ausgewertet. Bleibt die Antwort aus, steht der Schritt, bis Discord den
Websocket zumacht.

Drei Sachen noch, die beim Nachsehen auffielen:

- Ein Fehler aus dem Netzteil hat den ganzen Prozess beendet. VoiceConnection
  reicht ihn als 'error' weiter, und ein EventEmitter ohne 'error'-Hoerer
  wirft. Genau dieser Weg ist im Fehlerfall offen -- die UDP-Erkennung meldet
  sich darueber. Jetzt gibt es einen Hoerer.
- schritte.endpunkt wurde bei jedem VOICE_SERVER_UPDATE ueberschrieben. Bei
  einem Rueckfall nennt Discord den Server erneut, also verdeckte die zweite
  Meldung den Endpunkt, an dem es scheiterte. Jetzt gesammelt, und fuer die
  Diagnose zaehlt der erste echte.
- tools/ landete nie im Abbild (COPY src ./src). Der Aufruf im Kopf von
  udp-pruefen.mjs -- "docker exec ... node tools/udp-pruefen.mjs" -- lief ins
  Leere. Jetzt kopiert das Dockerfile es mit.

Die neun Diagnose-Zweige sind gegen die exportierten Funktionen im Container
durchgespielt, nicht gegen eine Kopie.

Was weiter offen bleibt: eine Sperre speziell auf Zielports 50000-65535 ist
nicht widerlegt -- dafuer fehlt eine Gegenstelle, die dort antwortet. Der
naechste Fehlversuch sagt es von selbst.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-14 12:31:27 +02:00
co-authored by Claude Opus 5
parent 228853febd
commit 484f2edc62
2 changed files with 106 additions and 12 deletions
+4
View File
@@ -24,6 +24,10 @@ COPY package.json package-lock.json ./
RUN npm ci --omit=dev RUN npm ci --omit=dev
COPY src ./src COPY src ./src
# Die Prüfwerkzeuge müssen dort laufen, wo der Fehler auftritt — im Container.
# Ohne diese Zeile lief `docker exec … node tools/udp-pruefen.mjs` ins Leere,
# obwohl genau das im Kopf des Skripts steht.
COPY tools ./tools
COPY --from=frontend /build/dist ./frontend/dist COPY --from=frontend /build/dist ./frontend/dist
# Laufzeitdaten (SQLite) landen in /app/data → Volume # Laufzeitdaten (SQLite) landen in /app/data → Volume
+102 -12
View File
@@ -169,7 +169,12 @@ function beobachteterAdapter(guild, schritte) {
// wenn es den Sprachserver gerade neu zuteilt. Die Bibliothek // wenn es den Sprachserver gerade neu zuteilt. Die Bibliothek
// wartet dann auf die zweite Meldung — kommt die nie, bleibt // wartet dann auf die zweite Meldung — kommt die nie, bleibt
// sie fuer immer bei "signalling" stehen, ohne Fehler. // sie fuer immer bei "signalling" stehen, ohne Fehler.
schritte.endpunkt = daten?.endpoint ?? null; //
// Gesammelt statt ueberschrieben: bei einem Rueckfall nach
// "signalling" nennt Discord den Server erneut. Vorher hat die
// zweite Meldung den Endpunkt verdeckt, an dem es wirklich
// gescheitert ist.
schritte.endpunkte.push(daten?.endpoint ?? null);
return methods.onVoiceServerUpdate(daten); return methods.onVoiceServerUpdate(daten);
}, },
}; };
@@ -185,7 +190,35 @@ function beobachteterAdapter(guild, schritte) {
}; };
} }
/** Aus den drei Häkchen wird ein Satz, der sagt, was zu tun ist */ /**
* Die Schritte des Netzteils der Bibliothek, in ihrer eigenen Zählung.
*
* Nachgesehen in `@discordjs/voice` 0.18 (`NetworkingStatusCode`), nicht
* geraten. Genau hier liegt der Unterschied, den `connecting → signalling`
* verschweigt: die Verbindung fällt bei *jedem* Websocket-Schluss ausser 4014
* stumm dorthin zurück, egal ob der UDP-Teil je begonnen hat.
*/
const NETZSCHRITT = ['Websocket auf', 'Anmeldung', 'UDP-Handschlag', 'Protokollwahl',
'bereit', 'Wiederaufnahme', 'zu'];
const ANMELDUNG = 1;
const UDP_HANDSCHLAG = 2;
const PROTOKOLLWAHL = 3;
// Damit dasselbe Netzteil nicht zweimal beobachtet wird. Bei einem Rückfall
// baut die Bibliothek ein neues — das soll durchaus wieder mitgeschrieben
// werden, deshalb sitzt die Markierung am Objekt und nicht an der Verbindung.
const BEOBACHTET = Symbol('radio-netzteil-beobachtet');
/** Der Netz-Mitschnitt als lesbare Zeile */
export function netzWeg(netz = []) {
return netz
.map((e) => (e.schliessung !== undefined
? `Websocket zu (${e.schliessung})`
: NETZSCHRITT[e.schritt] ?? `Schritt ${e.schritt}`))
.join(' → ');
}
/** Aus den Häkchen und dem Mitschnitt wird ein Satz, der sagt, was zu tun ist */
export function beitrittsDiagnose(schritte) { export function beitrittsDiagnose(schritte) {
if (schritte.angefragt === false) { if (schritte.angefragt === false) {
return 'Die Verbindung zu Discord war nicht bereit — der Beitritt ging gar nicht erst raus.'; return 'Die Verbindung zu Discord war nicht bereit — der Beitritt ging gar nicht erst raus.';
@@ -204,21 +237,54 @@ export function beitrittsDiagnose(schritte) {
} }
// Ab hier ist alles angekommen. Jetzt entscheidet der Weg, den die // Ab hier ist alles angekommen. Jetzt entscheidet der Weg, den die
// Verbindung genommen hat. // Verbindung genommen hat.
if (schritte.endpunkt === null) { const endpunkte = schritte.endpunkte ?? [];
const endpunkt = endpunkte.find(Boolean) ?? null;
if (endpunkt === null) {
return 'Discord hat den Beitritt bestätigt, aber keinen Sprachserver zugeteilt. ' return 'Discord hat den Beitritt bestätigt, aber keinen Sprachserver zugeteilt. '
+ 'Das passiert bei einem Regionswechsel — im Kanal unter *Region überschreiben* ' + 'Das passiert bei einem Regionswechsel — im Kanal unter *Region überschreiben* '
+ 'einmal eine feste Region wählen.'; + 'einmal eine feste Region wählen.';
} }
const kamAnsNetz = (schritte.weg ?? []).includes('connecting'); const kamAnsNetz = (schritte.weg ?? []).includes('connecting');
if (!kamAnsNetz) { if (!kamAnsNetz) {
return `Der Sprachserver (${schritte.endpunkt ?? '?'}) wurde genannt, aber die Verbindung ` return `Der Sprachserver (${endpunkt}) wurde genannt, aber die Verbindung `
+ 'kam nie bis zum Netzwerk-Schritt.'; + '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. // Sie war beim Netzwerk-Schritt und ist zurückgefallen. *Wo* sie dort
return `Der Austausch mit dem Sprachserver **${schritte.endpunkt}** scheitert. ` // stehen blieb, ist die eigentliche Auskunft — und die stand hier vorher
+ 'Discord-Sprache braucht **ausgehendes UDP** (Zielports 5000065535) — ' // nicht, weshalb an dieser Stelle „ausgehendes UDP dicht" behauptet wurde.
+ 'wenn nur TCP rausdarf, sieht es genau so aus.'; // Gemessen am 14.08.2026: UDP kam auf hohen Ports sehr wohl raus (STUN
// 19302 antwortet, TCP 2096 zum Sprachserver offen). Die Behauptung war
// also falsch. Jetzt entscheidet der Mitschnitt statt einer Vermutung.
const netz = schritte.netz ?? [];
const letzterSchritt = netz.filter((e) => e.schritt !== undefined).at(-1)?.schritt;
const schluss = netz.filter((e) => e.schliessung !== undefined).at(-1)?.schliessung;
const zu = schluss === undefined ? 'ohne Schliesscode' : `Schliesscode ${schluss}`;
if (letzterSchritt === UDP_HANDSCHLAG) {
// `performIPDiscovery` hat in 0.18 keine Zeitschranke und die
// UDP-Keepalives werden nicht mehr ausgewertet — bleibt die Antwort
// aus, hängt der Schritt, bis Discord den Websocket zumacht.
return `Der Sprachserver **${endpunkt}** wurde erreicht, aber auf den UDP-Handschlag `
+ `kam keine Antwort (${zu}). Discord-Sprache antwortet auf hohen Ports `
+ '(5000065535) — ausgehendes UDP dorthin freigeben. '
+ 'Gegenprobe: `node tools/udp-pruefen.mjs`.';
}
if (letzterSchritt === ANMELDUNG || letzterSchritt === 0) {
return `Der Websocket zu **${endpunkt}** ging wieder zu, bevor der UDP-Teil überhaupt `
+ `begann (${zu}). Das ist kein Netzproblem: 4006 heisst ungültige Sitzung, `
+ '4009 Zeitüberschreitung, 4011 unbekannter Server.';
}
if (letzterSchritt === PROTOKOLLWAHL) {
return `Der UDP-Handschlag mit **${endpunkt}** ging durch, aber Discord schickte keine `
+ `Sitzungsbeschreibung (${zu}). Das deutet auf den Verschlüsselungsmodus — `
+ '`generateDependencyReport()` zeigt, welche Bibliothek geladen ist.';
}
// Kein Mitschnitt vom Netzteil: dann ist die Ursache offen, und genau das
// wird gesagt. Eine Prüfung, die nichts sehen kann, meldet nicht "UDP".
return `Die Verbindung zu **${endpunkt}** kam bis zum Netzwerk-Schritt und fiel zurück, `
+ `aber wo genau, ist nicht erfasst (${netzWeg(netz) || 'kein Mitschnitt'}). `
+ 'Ursache offen.';
} }
/** Läuft gerade etwas auf diesem Server? */ /** Läuft gerade etwas auf diesem Server? */
@@ -249,7 +315,7 @@ export async function spielen(kanal, station) {
// Wird nur gefüllt, wenn wirklich neu verbunden wird // Wird nur gefüllt, wenn wirklich neu verbunden wird
const schritte = { const schritte = {
angefragt: null, eigenerStatus: false, sprachserver: false, angefragt: null, eigenerStatus: false, sprachserver: false,
endpunkt: undefined, weg: [], endpunkte: [], weg: [], netz: [],
}; };
let verbindung = getVoiceConnection(guildId); let verbindung = getVoiceConnection(guildId);
@@ -268,6 +334,28 @@ export async function spielen(kanal, station) {
verbindung.on('stateChange', (_, neuerStand) => { verbindung.on('stateChange', (_, neuerStand) => {
if (schritte.weg.at(-1) !== neuerStand.status) schritte.weg.push(neuerStand.status); if (schritte.weg.at(-1) !== neuerStand.status) schritte.weg.push(neuerStand.status);
}); });
// Der Weg allein reicht nicht: er sagt „connecting → signalling", aber
// nicht, ob der UDP-Teil je begonnen hat. Das weiss nur das Netzteil
// der Bibliothek, und das meldet seinen Fortschritt ausschliesslich
// über `debug` — was Token und Sitzungsschlüssel mitdruckt. Deshalb
// wird hier nur die Schrittnummer mitgeschrieben, nie die Nachricht.
verbindung.on(VoiceConnectionStatus.Connecting, () => {
const netzteil = verbindung.state.networking;
if (!netzteil || netzteil[BEOBACHTET]) return;
netzteil[BEOBACHTET] = true;
schritte.netz.push({ schritt: netzteil.state.code });
netzteil.on('stateChange', (_, neu) => {
if (schritte.netz.at(-1)?.schritt !== neu.code) schritte.netz.push({ schritt: neu.code });
});
// Den Schliesscode wirft die Bibliothek weg: ausser bei 4014 geht
// sie damit stumm zurück nach „signalling". Hier bleibt er stehen.
netzteil.on('close', (code) => schritte.netz.push({ schliessung: code }));
});
// Ohne diesen Hörer beendet ein Fehler aus dem Netzteil den ganzen
// Prozess: `VoiceConnection` reicht ihn als 'error' weiter, und ein
// EventEmitter ohne 'error'-Hörer wirft. Genau dieser Weg ist im
// Fehlerfall offen — die UDP-Erkennung meldet sich darüber.
verbindung.on('error', (e) => console.error('[radio] Verbindung:', e.message));
// Discord verschiebt Sprachserver im Betrieb; ohne dieses Zurückholen // Discord verschiebt Sprachserver im Betrieb; ohne dieses Zurückholen
// bleibt die Verbindung dann still stehen, statt sich zu erholen. // bleibt die Verbindung dann still stehen, statt sich zu erholen.
// //
@@ -314,8 +402,10 @@ export async function spielen(kanal, station) {
const grund = beitrittsDiagnose(schritte); const grund = beitrittsDiagnose(schritte);
console.error(`[radio] Beitritt steckt bei "${steckt}" — ` console.error(`[radio] Beitritt steckt bei "${steckt}" — `
+ `angefragt: ${schritte.angefragt}, eigener Status: ${schritte.eigenerStatus}, ` + `angefragt: ${schritte.angefragt}, eigener Status: ${schritte.eigenerStatus}, `
+ `Sprachserver: ${schritte.sprachserver}, Endpunkt: ${schritte.endpunkt ?? '—'}, ` + `Sprachserver: ${schritte.sprachserver}, `
+ `Weg: ${(schritte.weg ?? []).join(' ') || '(keiner)'}`); + `Endpunkt: ${schritte.endpunkte.map((e) => e ?? '(leer)').join(', ') || '—'}, `
+ `Weg: ${(schritte.weg ?? []).join(' → ') || '(keiner)'}, `
+ `Netzteil: ${netzWeg(schritte.netz) || '(nie erreicht)'}`);
throw new Error(`Sprachverbindung kam nicht zustande. ${grund}`); throw new Error(`Sprachverbindung kam nicht zustande. ${grund}`);
} }