From bd0c3dba5023340398594d4d53b0b17e1c69d0e661c1c24ae1e029e1a02298fa Mon Sep 17 00:00:00 2001 From: ai5590 Date: Tue, 21 Apr 2026 19:52:25 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9?= =?UTF-8?q?=20debug=20API=20=D0=B4=D0=BB=D1=8F=20=D0=B0=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D0=B0=20WebRTC=20=D0=B8=20runbook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + AGENT_DEBUG_RUNBOOK.md | 18 ++ DEBUG_CONNECTION_TESTING.md | 89 ++++++++ debug-token.example | 14 ++ shine-UI/js/app.js | 112 +++++++++- shine-UI/js/services/auth-service.js | 10 + shine-UI/js/services/call-service.js | 206 +++++++++++++++--- .../JSON/ActiveConnectionsRegistry.java | 8 + .../ws_protocol/JSON/JsonHandlerRegistry.java | 8 +- .../system/Net_ClientDebugLog_Handler.java | 49 +++++ .../entyties/Net_ClientDebugLog_Request.java | 22 ++ .../entyties/Net_ClientDebugLog_Response.java | 14 ++ .../server/debug/DebugApiConfigurator.java | 27 +++ .../server/debug/DebugClientsServlet.java | 140 ++++++++++++ .../server/debug/DebugConnectServlet.java | 148 +++++++++++++ .../java/server/debug/DebugLogsServlet.java | 100 +++++++++ .../java/server/debug/DebugRunLogBuffer.java | 81 +++++++ .../java/server/debug/DebugTokenProvider.java | 54 +++++ src/main/java/server/ws/WsServer.java | 4 + 19 files changed, 1070 insertions(+), 37 deletions(-) create mode 100644 AGENT_DEBUG_RUNBOOK.md create mode 100644 DEBUG_CONNECTION_TESTING.md create mode 100644 debug-token.example create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/Net_ClientDebugLog_Handler.java create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_ClientDebugLog_Request.java create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_ClientDebugLog_Response.java create mode 100644 src/main/java/server/debug/DebugApiConfigurator.java create mode 100644 src/main/java/server/debug/DebugClientsServlet.java create mode 100644 src/main/java/server/debug/DebugConnectServlet.java create mode 100644 src/main/java/server/debug/DebugLogsServlet.java create mode 100644 src/main/java/server/debug/DebugRunLogBuffer.java create mode 100644 src/main/java/server/debug/DebugTokenProvider.java diff --git a/.gitignore b/.gitignore index 30b48e1..dd98de0 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ bin/ ### Mac OS ### .DS_Store + +# временный debug token +.debug-token diff --git a/AGENT_DEBUG_RUNBOOK.md b/AGENT_DEBUG_RUNBOOK.md new file mode 100644 index 0000000..d68075c --- /dev/null +++ b/AGENT_DEBUG_RUNBOOK.md @@ -0,0 +1,18 @@ +# Runbook для агента: тест сетевого соединения + +## Быстрый цикл +1. Убедись, что сервер запущен. +2. Скажи пользователю: «Запусти двух клиентов и напиши “продолжай”». +3. По команде «продолжай»: + - вызови `GET /debug/ws/clients`, + - выбери 2 активные сессии (предпочтительно разных логинов), + - вызови `POST /debug/ws/connect`, + - получи `runId`. +4. Читай `GET /debug/ws/logs?limit=200&runId=` и сообщай прогресс. +5. Если неуспех — покажи ошибки, предложи перезапуск 2 клиентов и повтори. + +## Формат взаимодействия с пользователем +- Старт: «Сервер готов. Запусти двух клиентов и скажи “продолжай”.» +- После старта run: «Тест запущен, runId=..., проверяю логи.» +- Успех: «Соединение установлено, вижу connected.» +- Неуспех: «Соединение не поднялось, причины: ... Предлагаю перезапустить клиентов и повторить.» diff --git a/DEBUG_CONNECTION_TESTING.md b/DEBUG_CONNECTION_TESTING.md new file mode 100644 index 0000000..22ed591 --- /dev/null +++ b/DEBUG_CONNECTION_TESTING.md @@ -0,0 +1,89 @@ +# DEBUG: тестирование сетевого соединения между двумя клиентами + +Документ описывает временный debug-контур для проверки WebRTC соединения между двумя активными WS-сессиями. + +## 1) Подготовка + +1. Создай файл `.debug-token` в корне проекта на основе `debug-token.example`. +2. В `.debug-token` должна быть одна строка: секретный токен. +3. Перезапусти сервер. + +## 2) API debug + +Базовый заголовок для всех запросов: + +```bash +-H "Authorization: Bearer " +``` + +### 2.1 Получить список живых клиентов + +```bash +curl -s \ + -H "Authorization: Bearer " \ + http://localhost:7070/debug/ws/clients | jq +``` + +Ответ содержит `sessionId`, `login`, `ip`, `userAgent`, и клиентскую информацию. + +### 2.2 Запустить debug-соединение между двумя сессиями + +```bash +curl -s -X POST \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "initiatorSessionId": "SESSION_ID_A", + "responderSessionId": "SESSION_ID_B", + "clearDebugLog": false + }' \ + http://localhost:7070/debug/ws/connect | jq +``` + +В ответе придёт `runId`. Его используй для фильтра логов. + +### 2.3 Читать последние N debug-логов + +```bash +curl -s \ + -H "Authorization: Bearer " \ + "http://localhost:7070/debug/ws/logs?limit=200&runId=" | jq +``` + +## 3) Операционный сценарий “Codex + пользователь” + +1. Codex поднимает сервер и сообщает пользователю ссылку на UI. +2. Codex пишет пользователю: **«Запусти двух клиентов и скажи “продолжай”»**. +3. Пользователь запускает два клиента (лучше под разными логинами). +4. Пользователь пишет: **«продолжай»**. +5. Codex: + - вызывает `/debug/ws/clients`, + - выбирает 2 сессии, + - вызывает `/debug/ws/connect`, + - получает `runId`, + - читает `/debug/ws/logs?runId=...` и сообщает прогресс. +6. Если соединение не удалось: + - Codex сообщает ошибки по логам, + - при необходимости просит перезапустить 2 клиента, + - повторяет запуск debug-run. + +## 4) Какие сообщения считать успехом + +- `peer_connection_connected` +- `debug_connection_success` +- `signal_sent_200/210/220` без ошибок + +## 5) Что говорить пользователю в ходе прогона (через «колонку»/чат) + +Рекомендуемые фразы: + +- «Сервер запущен. Запусти двух клиентов и напиши “продолжай”.» +- «Вижу 2 активные сессии, запускаю тест соединения.» +- «Тест запущен, runId=... Сейчас проверяю логи.» +- «Соединение установлено / не установлено. Ниже причины и следующий шаг.» + +## 6) Ограничения + +- Механизм временный, не для production-эксплуатации. +- Доступ к debug API имеет любой, кто знает токен. +- Рекомендуется тестить между разными логинами. diff --git a/debug-token.example b/debug-token.example new file mode 100644 index 0000000..59fa847 --- /dev/null +++ b/debug-token.example @@ -0,0 +1,14 @@ +# Временный debug token для /debug/ws/* API. +# +# Как использовать: +# 1) Скопируй этот файл в .debug-token +# 2) Замени значение ниже на секретную строку +# 3) Перезапусти сервер +# 4) Передавай токен в заголовке: +# Authorization: Bearer +# +# ВНИМАНИЕ: +# - файл .debug-token не должен попадать в git +# - механизм временный, для ручных/автотестов соединения + +CHANGE_ME_DEBUG_TOKEN diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 26c379b..69eedff 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -2,7 +2,13 @@ import { navigate, getRoute, PRE_AUTH_PAGES } from './router.js'; import { renderToolbar } from './components/toolbar.js'; import { captureClientError, setClientErrorTransport } from './services/client-error-reporter.js'; import { initPwaPush } from './services/pwa-push-service.js'; -import { handleIncomingCallInvite, handleIncomingCallSignal } from './services/call-service.js'; +import { + handleIncomingCallInvite, + handleIncomingCallSignal, + setCallDebugReporter, + startDebugConnectionAsInitiator, + startDebugConnectionAsResponder, +} from './services/call-service.js'; import { authService, addAppLogEntry, @@ -91,9 +97,21 @@ const toolbarEl = document.getElementById('toolbar-slot'); let currentCleanup = null; let pingIntervalId = null; +let reconnectIntervalId = null; let sessionRuntimeStarted = false; setClientErrorTransport((payload) => authService.reportClientError(payload)); +setCallDebugReporter((payload) => authService.reportClientDebug(payload)); + +function isReconnectAllowedNow() { + const pageId = getRoute().pageId || ''; + return !PRE_AUTH_PAGES.includes(pageId); +} + +function wsIsOpen() { + const ws = authService?.ws?.ws; + return !!(ws && ws.readyState === WebSocket.OPEN); +} function showGlobalErrorAlert(title, details = {}) { const lines = [title]; @@ -298,6 +316,32 @@ async function ensureSessionRuntimeStarted() { // silent keep-alive } }, 60_000); + + if (reconnectIntervalId) { + window.clearInterval(reconnectIntervalId); + reconnectIntervalId = null; + } + reconnectIntervalId = window.setInterval(async () => { + if (!state.session.isAuthorized) return; + if (!isReconnectAllowedNow()) return; + if (wsIsOpen()) return; + + try { + await authService.ws.open(); + addAppLogEntry({ + level: 'info', + source: 'ws-reconnect', + message: 'WS переподключен автоматически', + }); + } catch (error) { + addAppLogEntry({ + level: 'warn', + source: 'ws-reconnect', + message: 'Попытка автопереподключения не удалась', + details: { error: error?.message || 'unknown' }, + }); + } + }, 15_000); } async function init() { @@ -313,6 +357,10 @@ async function init() { window.clearInterval(pingIntervalId); pingIntervalId = null; } + if (reconnectIntervalId) { + window.clearInterval(reconnectIntervalId); + reconnectIntervalId = null; + } navigate('start-view'); }); @@ -391,6 +439,68 @@ async function init() { try { await handleIncomingCallSignal(evt); } catch {} }); + authService.onEvent('DebugConnectPrepareResponder', async (evt) => { + try { + const p = evt?.payload || {}; + await startDebugConnectionAsResponder({ + runId: p.runId, + callId: p.callId, + peerLogin: p.peerLogin, + peerSessionId: p.peerSessionId, + }); + addAppLogEntry({ + level: 'info', + source: 'debug-connect', + message: 'Получена команда debug responder', + details: p, + }); + } catch (error) { + addAppLogEntry({ + level: 'error', + source: 'debug-connect', + message: 'Ошибка запуска debug responder', + details: { error: error?.message || 'unknown' }, + }); + await authService.reportClientDebug({ + runId: evt?.payload?.runId || '', + level: 'error', + message: 'debug_responder_failed', + details: error?.message || 'unknown', + }); + } + }); + + authService.onEvent('DebugConnectStartInitiator', async (evt) => { + try { + const p = evt?.payload || {}; + await startDebugConnectionAsInitiator({ + runId: p.runId, + callId: p.callId, + peerLogin: p.peerLogin, + peerSessionId: p.peerSessionId, + }); + addAppLogEntry({ + level: 'info', + source: 'debug-connect', + message: 'Получена команда debug initiator', + details: p, + }); + } catch (error) { + addAppLogEntry({ + level: 'error', + source: 'debug-connect', + message: 'Ошибка запуска debug initiator', + details: { error: error?.message || 'unknown' }, + }); + await authService.reportClientDebug({ + runId: evt?.payload?.runId || '', + level: 'error', + message: 'debug_initiator_failed', + details: error?.message || 'unknown', + }); + } + }); + await tryAutoLogin(); await ensureSessionRuntimeStarted(); diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 614e746..948271e 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -1409,6 +1409,16 @@ export class AuthService { return response.payload || {}; } + + async reportClientDebug({ runId = '', level = 'info', message = '', details = '' } = {}) { + try { + const response = await this.ws.request('ClientDebugLog', { runId, level, message, details }, 3000); + return response?.status === 200; + } catch { + return false; + } + } + async reportClientError(details) { try { const response = await this.ws.request('ClientErrorLog', details || {}, 3000); diff --git a/shine-UI/js/services/call-service.js b/shine-UI/js/services/call-service.js index 4182596..b2a081e 100644 --- a/shine-UI/js/services/call-service.js +++ b/shine-UI/js/services/call-service.js @@ -1,4 +1,4 @@ -import { addChatMessage, state, authService } from '../state.js'; +import { addChatMessage, authService } from '../state.js'; const TYPES = { INVITE: 100, @@ -14,6 +14,7 @@ const TYPES = { const calls = new Map(); let activeCallId = ''; +let debugReporter = null; function nowMs() { return Date.now(); @@ -27,9 +28,26 @@ function getCall(callId) { return calls.get(callId) || null; } +function toErrorText(error) { + return error?.message || String(error || 'unknown'); +} + +async function emitDebug(call, level, message, details = '') { + if (!call?.debugRunId || typeof debugReporter !== 'function') return; + try { + await debugReporter({ + runId: call.debugRunId, + level, + message, + details, + }); + } catch {} +} + function setStatus(call, text) { call.status = text; - addChatMessage(call.peerLogin, `[call] ${text}`); + addChatMessage(call.peerLogin || 'debug-peer', `[call] ${text}`); + void emitDebug(call, 'info', `call_status: ${text}`, `callId=${call.callId}`); } function cleanupTimers(call) { @@ -61,10 +79,30 @@ async function finishCall(call, reason, notifyRemote = false) { } await closeMedia(call); setStatus(call, `завершен: ${reason}`); + if (String(reason || '').toLowerCase().includes('connected')) { + await emitDebug(call, 'info', 'debug_connection_success', reason); + } calls.delete(call.callId); if (activeCallId === call.callId) activeCallId = ''; } +async function sendSignal(call, type, data = '') { + if (!call.remoteSessionId) return; + try { + await authService.callSignalToSession({ + toLogin: call.peerLogin, + targetSessionId: call.remoteSessionId, + callId: call.callId, + type, + data, + }); + await emitDebug(call, 'info', `signal_sent_${type}`, `len=${String(data || '').length}`); + } catch (error) { + await emitDebug(call, 'error', `signal_send_failed_${type}`, toErrorText(error)); + throw error; + } +} + async function ensurePeerConnection(call) { if (call.pc) return call.pc; @@ -72,25 +110,45 @@ async function ensurePeerConnection(call) { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], }); + if (call.debugMode && call.debugRole === 'initiator') { + const dc = pc.createDataChannel('debug-ping'); + dc.onopen = () => { + try { dc.send('ping'); } catch {} + void emitDebug(call, 'info', 'debug_datachannel_open', 'sent ping'); + }; + dc.onmessage = (evt) => { + void emitDebug(call, 'info', 'debug_datachannel_message', String(evt?.data || '')); + }; + } + + pc.ondatachannel = (evt) => { + const ch = evt?.channel; + if (!ch) return; + ch.onmessage = (msg) => { + const incoming = String(msg?.data || ''); + void emitDebug(call, 'info', 'debug_datachannel_message_in', incoming); + if (incoming === 'ping') { + try { ch.send('pong'); } catch {} + } + }; + }; + pc.onicecandidate = async (event) => { if (!event.candidate || !call.remoteSessionId) return; try { - await authService.callSignalToSession({ - toLogin: call.peerLogin, - targetSessionId: call.remoteSessionId, - callId: call.callId, - type: TYPES.ICE, - data: JSON.stringify(event.candidate), - }); + await sendSignal(call, TYPES.ICE, JSON.stringify(event.candidate)); } catch {} }; pc.onconnectionstatechange = () => { - if (pc.connectionState === 'connected') { + const state = pc.connectionState; + if (state === 'connected') { setStatus(call, 'соединение установлено'); + void emitDebug(call, 'info', 'peer_connection_connected', `callId=${call.callId}`); } - if (pc.connectionState === 'failed' || pc.connectionState === 'closed' || pc.connectionState === 'disconnected') { - finishCall(call, `state=${pc.connectionState}`, false); + if (state === 'failed' || state === 'closed' || state === 'disconnected') { + void emitDebug(call, 'warn', 'peer_connection_closed', `state=${state}`); + finishCall(call, `state=${state}`, false); } }; @@ -100,6 +158,7 @@ async function ensurePeerConnection(call) { stream.getTracks().forEach((track) => pc.addTrack(track, stream)); } catch (e) { setStatus(call, `нет доступа к микрофону: ${e?.message || 'unknown'}`); + await emitDebug(call, 'warn', 'microphone_access_failed', toErrorText(e)); } pc.ontrack = (evt) => { @@ -113,17 +172,6 @@ async function ensurePeerConnection(call) { return pc; } -async function sendSignal(call, type, data = '') { - if (!call.remoteSessionId) return; - await authService.callSignalToSession({ - toLogin: call.peerLogin, - targetSessionId: call.remoteSessionId, - callId: call.callId, - type, - data, - }); -} - async function onAccept(call) { cleanupTimers(call); const pc = await ensurePeerConnection(call); @@ -133,6 +181,72 @@ async function onAccept(call) { setStatus(call, 'отправлен offer'); } +export function setCallDebugReporter(fn) { + debugReporter = typeof fn === 'function' ? fn : null; +} + +export async function startDebugConnectionAsResponder({ runId, callId, peerLogin, peerSessionId }) { + const cleanCallId = String(callId || '').trim(); + const cleanPeerLogin = String(peerLogin || '').trim(); + const cleanPeerSessionId = String(peerSessionId || '').trim(); + if (!cleanCallId || !cleanPeerLogin || !cleanPeerSessionId) return; + + let call = getCall(cleanCallId); + if (!call) { + call = { + callId: cleanCallId, + peerLogin: cleanPeerLogin, + direction: 'in', + state: 'accepted', + remoteSessionId: cleanPeerSessionId, + timers: {}, + startedAtMs: nowMs(), + pc: null, + localStream: null, + debugMode: true, + debugRunId: String(runId || '').trim(), + debugRole: 'responder', + }; + calls.set(cleanCallId, call); + } + + activeCallId = cleanCallId; + await emitDebug(call, 'info', 'debug_prepare_responder', `peerSessionId=${cleanPeerSessionId}`); + setStatus(call, 'debug: responder готов, ждём offer'); +} + +export async function startDebugConnectionAsInitiator({ runId, callId, peerLogin, peerSessionId }) { + const cleanCallId = String(callId || '').trim(); + const cleanPeerLogin = String(peerLogin || '').trim(); + const cleanPeerSessionId = String(peerSessionId || '').trim(); + if (!cleanCallId || !cleanPeerLogin || !cleanPeerSessionId) return; + + const call = { + callId: cleanCallId, + peerLogin: cleanPeerLogin, + direction: 'out', + state: 'accepted', + remoteSessionId: cleanPeerSessionId, + timers: {}, + startedAtMs: nowMs(), + pc: null, + localStream: null, + debugMode: true, + debugRunId: String(runId || '').trim(), + debugRole: 'initiator', + }; + + calls.set(cleanCallId, call); + activeCallId = cleanCallId; + await emitDebug(call, 'info', 'debug_start_initiator', `peerSessionId=${cleanPeerSessionId}`); + try { + await onAccept(call); + } catch (error) { + await emitDebug(call, 'error', 'debug_initiator_start_failed', toErrorText(error)); + await finishCall(call, `debug start failed: ${toErrorText(error)}`, false); + } +} + export async function startOutgoingCall(peerLogin) { const cleanPeer = String(peerLogin || '').trim(); if (!cleanPeer) return; @@ -153,6 +267,9 @@ export async function startOutgoingCall(peerLogin) { startedAtMs: nowMs(), pc: null, localStream: null, + debugMode: false, + debugRunId: '', + debugRole: '', }; calls.set(callId, call); activeCallId = callId; @@ -203,6 +320,9 @@ export async function handleIncomingCallInvite(evt) { startedAtMs: nowMs(), pc: null, localStream: null, + debugMode: false, + debugRunId: '', + debugRole: '', }; calls.set(callId, call); } @@ -273,25 +393,43 @@ export async function handleIncomingCallSignal(evt) { } if (type === TYPES.OFFER) { - const pc = await ensurePeerConnection(call); - await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data))); - const answer = await pc.createAnswer(); - await pc.setLocalDescription(answer); - await sendSignal(call, TYPES.ANSWER, JSON.stringify(answer)); - setStatus(call, 'получен offer, отправлен answer'); + try { + const pc = await ensurePeerConnection(call); + await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data))); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + await sendSignal(call, TYPES.ANSWER, JSON.stringify(answer)); + setStatus(call, 'получен offer, отправлен answer'); + await emitDebug(call, 'info', 'offer_processed', 'answer sent'); + } catch (error) { + await emitDebug(call, 'error', 'offer_process_failed', toErrorText(error)); + throw error; + } return; } if (type === TYPES.ANSWER) { - const pc = await ensurePeerConnection(call); - await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data))); - setStatus(call, 'получен answer'); + try { + const pc = await ensurePeerConnection(call); + await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data))); + setStatus(call, 'получен answer'); + await emitDebug(call, 'info', 'answer_processed', 'remote description set'); + } catch (error) { + await emitDebug(call, 'error', 'answer_process_failed', toErrorText(error)); + throw error; + } return; } if (type === TYPES.ICE) { - const pc = await ensurePeerConnection(call); - await pc.addIceCandidate(new RTCIceCandidate(JSON.parse(data))); + try { + const pc = await ensurePeerConnection(call); + await pc.addIceCandidate(new RTCIceCandidate(JSON.parse(data))); + await emitDebug(call, 'info', 'ice_processed', 'candidate added'); + } catch (error) { + await emitDebug(call, 'error', 'ice_process_failed', toErrorText(error)); + throw error; + } } } diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/ActiveConnectionsRegistry.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/ActiveConnectionsRegistry.java index 6387b23..82bafe8 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/ActiveConnectionsRegistry.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/ActiveConnectionsRegistry.java @@ -145,6 +145,14 @@ public final class ActiveConnectionsRegistry { return (set == null) ? Set.of() : set; // CopyOnWriteArraySet можно отдавать как есть } + + /** + * Снимок всех активных контекстов на текущий момент. + */ + public Set getAllConnectionsSnapshot() { + return Set.copyOf(bySessionId.values()); + } + private static String toLoginKey(String login) { return login.trim().toLowerCase(Locale.ROOT); } diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index c0e2e9c..311c3ad 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -72,8 +72,10 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_UpsertPushToken_Reque // --- NEW: Ping --- import server.logic.ws_protocol.JSON.handlers.system.Net_GetServerInfo_Handler; import server.logic.ws_protocol.JSON.handlers.system.Net_ClientErrorLog_Handler; +import server.logic.ws_protocol.JSON.handlers.system.Net_ClientDebugLog_Handler; import server.logic.ws_protocol.JSON.handlers.system.Net_Ping_Handler; import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientErrorLog_Request; +import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientDebugLog_Request; import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetServerInfo_Request; import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_Ping_Request; @@ -131,7 +133,8 @@ public final class JsonHandlerRegistry { // --- system --- Map.entry("Ping", new Net_Ping_Handler()), Map.entry("GetServerInfo", new Net_GetServerInfo_Handler()), - Map.entry("ClientErrorLog", new Net_ClientErrorLog_Handler()) + Map.entry("ClientErrorLog", new Net_ClientErrorLog_Handler()), + Map.entry("ClientDebugLog", new Net_ClientDebugLog_Handler()) // --- subscriptions --- // Map.entry("ListSubscribedChannels", new Net_GetSubscribedChannels_Handler()) @@ -179,7 +182,8 @@ public final class JsonHandlerRegistry { // --- system --- Map.entry("Ping", Net_Ping_Request.class), Map.entry("GetServerInfo", Net_GetServerInfo_Request.class), - Map.entry("ClientErrorLog", Net_ClientErrorLog_Request.class) + Map.entry("ClientErrorLog", Net_ClientErrorLog_Request.class), + Map.entry("ClientDebugLog", Net_ClientDebugLog_Request.class) ); private JsonHandlerRegistry() { } diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/Net_ClientDebugLog_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/Net_ClientDebugLog_Handler.java new file mode 100644 index 0000000..d830fe1 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/Net_ClientDebugLog_Handler.java @@ -0,0 +1,49 @@ +package server.logic.ws_protocol.JSON.handlers.system; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.debug.DebugRunLogBuffer; +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientDebugLog_Request; +import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientDebugLog_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; + +public class Net_ClientDebugLog_Handler implements JsonMessageHandler { + private static final Logger log = LoggerFactory.getLogger(Net_ClientDebugLog_Handler.class); + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { + Net_ClientDebugLog_Request req = (Net_ClientDebugLog_Request) baseRequest; + String message = safe(req.getMessage()); + if (message.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "Поле message обязательно"); + } + + String runId = safe(req.getRunId()); + String level = safe(req.getLevel()); + String sessionId = safe(ctx != null ? ctx.getSessionId() : ""); + String login = safe(ctx != null ? ctx.getLogin() : ""); + String details = safe(req.getDetails()); + + DebugRunLogBuffer.getInstance().add(level, runId, "client", sessionId, login, message, details); + + log.info("CLIENT_DEBUG runId={} level={} login={} sessionId={} message={} details={}", + runId, level, login, sessionId, message, details); + + Net_ClientDebugLog_Response resp = new Net_ClientDebugLog_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setAccepted(true); + resp.setServerTs(System.currentTimeMillis()); + return resp; + } + + private static String safe(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_ClientDebugLog_Request.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_ClientDebugLog_Request.java new file mode 100644 index 0000000..43fbdb8 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_ClientDebugLog_Request.java @@ -0,0 +1,22 @@ +package server.logic.ws_protocol.JSON.handlers.system.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_ClientDebugLog_Request extends Net_Request { + private String runId; + private String level; + private String message; + private String details; + + public String getRunId() { return runId; } + public void setRunId(String runId) { this.runId = runId; } + + public String getLevel() { return level; } + public void setLevel(String level) { this.level = level; } + + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + + public String getDetails() { return details; } + public void setDetails(String details) { this.details = details; } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_ClientDebugLog_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_ClientDebugLog_Response.java new file mode 100644 index 0000000..d632c86 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_ClientDebugLog_Response.java @@ -0,0 +1,14 @@ +package server.logic.ws_protocol.JSON.handlers.system.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_ClientDebugLog_Response extends Net_Response { + private boolean accepted; + private long serverTs; + + public boolean isAccepted() { return accepted; } + public void setAccepted(boolean accepted) { this.accepted = accepted; } + + public long getServerTs() { return serverTs; } + public void setServerTs(long serverTs) { this.serverTs = serverTs; } +} diff --git a/src/main/java/server/debug/DebugApiConfigurator.java b/src/main/java/server/debug/DebugApiConfigurator.java new file mode 100644 index 0000000..e65aab6 --- /dev/null +++ b/src/main/java/server/debug/DebugApiConfigurator.java @@ -0,0 +1,27 @@ +package server.debug; + +import jakarta.servlet.Servlet; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.servlet.ServletHolder; + +/** + * Регистрирует HTTP debug endpoints. + */ +public final class DebugApiConfigurator { + + private DebugApiConfigurator() { + } + + public static void register(ServletContextHandler context) { + DebugTokenProvider tokenProvider = DebugTokenProvider.loadFromProjectRoot(); + + addServlet(context, new DebugClientsServlet(tokenProvider), "/debug/ws/clients"); + addServlet(context, new DebugConnectServlet(tokenProvider), "/debug/ws/connect"); + addServlet(context, new DebugLogsServlet(tokenProvider), "/debug/ws/logs"); + } + + private static void addServlet(ServletContextHandler context, Servlet servlet, String path) { + ServletHolder holder = new ServletHolder(servlet); + context.addServlet(holder, path); + } +} diff --git a/src/main/java/server/debug/DebugClientsServlet.java b/src/main/java/server/debug/DebugClientsServlet.java new file mode 100644 index 0000000..9747b1c --- /dev/null +++ b/src/main/java/server/debug/DebugClientsServlet.java @@ -0,0 +1,140 @@ +package server.debug; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.eclipse.jetty.websocket.api.Session; +import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; +import server.logic.ws_protocol.JSON.ConnectionContext; +import shine.db.dao.ActiveSessionsDAO; +import shine.db.entities.ActiveSessionEntry; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * GET /debug/ws/clients + */ +public class DebugClientsServlet extends HttpServlet { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final DebugTokenProvider tokenProvider; + + public DebugClientsServlet(DebugTokenProvider tokenProvider) { + this.tokenProvider = tokenProvider; + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + if (!tokenProvider.isEnabled()) { + writeError(resp, 503, "DEBUG_DISABLED", "Debug API отключен: .debug-token не найден или пуст"); + return; + } + if (!tokenProvider.matchesBearerHeader(req.getHeader("Authorization"))) { + writeError(resp, 401, "UNAUTHORIZED", "Неверный Bearer token"); + return; + } + + List contexts = ActiveConnectionsRegistry.getInstance().getAllConnectionsSnapshot(); + List rows = new ArrayList<>(); + for (ConnectionContext ctx : contexts) { + Session ws = ctx.getWsSession(); + if (ws == null || !ws.isOpen()) continue; + + ObjectNode row = MAPPER.createObjectNode(); + String sessionId = safe(ctx.getSessionId()); + row.put("sessionId", sessionId); + row.put("login", safe(ctx.getLogin())); + row.put("authStatus", ctx.getAuthenticationStatus()); + row.put("wsOpen", ws.isOpen()); + + String remote = safeRemote(ws.getRemoteAddress()); + row.put("remoteAddress", remote); + row.put("ip", extractIp(ws.getRemoteAddress())); + row.put("userAgent", safeUserAgent(ws)); + + ActiveSessionEntry active = null; + try { + if (!sessionId.isBlank()) { + active = ActiveSessionsDAO.getInstance().getBySessionId(sessionId); + } + } catch (Exception ignored) { + } + + row.put("clientInfoFromClient", active != null ? safe(active.getClientInfoFromClient()) : ""); + row.put("clientInfoFromRequest", active != null ? safe(active.getClientInfoFromRequest()) : ""); + row.put("userLanguage", active != null ? safe(active.getUserLanguage()) : ""); + row.put("sessionCreatedAtMs", active != null ? active.getSessionCreatedAtMs() : 0L); + + rows.add(row); + } + + rows.sort(Comparator.comparing(a -> a.path("sessionId").asText(""))); + + ArrayNode clients = MAPPER.createArrayNode(); + rows.forEach(clients::add); + + ObjectNode payload = MAPPER.createObjectNode(); + payload.put("count", clients.size()); + payload.set("clients", clients); + + writeOk(resp, payload); + } + + private static String safeUserAgent(Session ws) { + try { + if (ws.getUpgradeRequest() == null) return ""; + return safe(ws.getUpgradeRequest().getHeader("User-Agent")); + } catch (Throwable ignored) { + return ""; + } + } + + private static String extractIp(SocketAddress addr) { + if (addr instanceof InetSocketAddress inet) { + if (inet.getAddress() != null) { + return safe(inet.getAddress().getHostAddress()); + } + return safe(inet.getHostString()); + } + return ""; + } + + private static String safeRemote(SocketAddress addr) { + return addr == null ? "" : safe(addr.toString()); + } + + private static String safe(String value) { + return value == null ? "" : value.trim(); + } + + private static void writeOk(HttpServletResponse resp, ObjectNode payload) throws IOException { + ObjectNode root = MAPPER.createObjectNode(); + root.put("ok", true); + root.set("payload", payload == null ? MAPPER.createObjectNode() : payload); + writeJson(resp, 200, root); + } + + private static void writeError(HttpServletResponse resp, int status, String code, String message) throws IOException { + ObjectNode root = MAPPER.createObjectNode(); + root.put("ok", false); + root.put("code", code); + root.put("message", message); + writeJson(resp, status, root); + } + + private static void writeJson(HttpServletResponse resp, int status, ObjectNode root) throws IOException { + resp.setStatus(status); + resp.setCharacterEncoding("UTF-8"); + resp.setContentType("application/json; charset=UTF-8"); + resp.getWriter().write(MAPPER.writeValueAsString(root)); + } +} diff --git a/src/main/java/server/debug/DebugConnectServlet.java b/src/main/java/server/debug/DebugConnectServlet.java new file mode 100644 index 0000000..8765a11 --- /dev/null +++ b/src/main/java/server/debug/DebugConnectServlet.java @@ -0,0 +1,148 @@ +package server.debug; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.push.WsEventSender; + +import java.io.IOException; + +/** + * POST /debug/ws/connect + */ +public class DebugConnectServlet extends HttpServlet { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final DebugTokenProvider tokenProvider; + + public DebugConnectServlet(DebugTokenProvider tokenProvider) { + this.tokenProvider = tokenProvider; + } + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { + if (!tokenProvider.isEnabled()) { + writeError(resp, 503, "DEBUG_DISABLED", "Debug API отключен: .debug-token не найден или пуст"); + return; + } + if (!tokenProvider.matchesBearerHeader(req.getHeader("Authorization"))) { + writeError(resp, 401, "UNAUTHORIZED", "Неверный Bearer token"); + return; + } + + JsonNode body; + try { + body = MAPPER.readTree(req.getInputStream()); + } catch (Exception e) { + writeError(resp, 400, "BAD_JSON", "Тело запроса должно быть JSON"); + return; + } + + String initiatorSessionId = text(body, "initiatorSessionId"); + String responderSessionId = text(body, "responderSessionId"); + boolean clearDebugLog = body != null && body.path("clearDebugLog").asBoolean(false); + + if (initiatorSessionId.isBlank() || responderSessionId.isBlank()) { + writeError(resp, 400, "BAD_FIELDS", "initiatorSessionId и responderSessionId обязательны"); + return; + } + if (initiatorSessionId.equals(responderSessionId)) { + writeError(resp, 400, "BAD_FIELDS", "Нужны две разные sessionId"); + return; + } + + ConnectionContext initiator = ActiveConnectionsRegistry.getInstance().getBySessionId(initiatorSessionId); + ConnectionContext responder = ActiveConnectionsRegistry.getInstance().getBySessionId(responderSessionId); + + if (initiator == null || initiator.getWsSession() == null || !initiator.getWsSession().isOpen()) { + writeError(resp, 404, "INITIATOR_NOT_FOUND", "Сессия initiator не найдена или неактивна"); + return; + } + if (responder == null || responder.getWsSession() == null || !responder.getWsSession().isOpen()) { + writeError(resp, 404, "RESPONDER_NOT_FOUND", "Сессия responder не найдена или неактивна"); + return; + } + + DebugRunLogBuffer logs = DebugRunLogBuffer.getInstance(); + if (clearDebugLog) { + logs.clear(); + } + String runId = logs.newRunId(); + String callId = "debug-call-" + System.currentTimeMillis(); + + ObjectNode responderPayload = MAPPER.createObjectNode(); + responderPayload.put("runId", runId); + responderPayload.put("callId", callId); + responderPayload.put("peerSessionId", initiatorSessionId); + responderPayload.put("peerLogin", safe(initiator.getLogin())); + responderPayload.put("role", "responder"); + responderPayload.put("issuedAtMs", System.currentTimeMillis()); + + ObjectNode initiatorPayload = MAPPER.createObjectNode(); + initiatorPayload.put("runId", runId); + initiatorPayload.put("callId", callId); + initiatorPayload.put("peerSessionId", responderSessionId); + initiatorPayload.put("peerLogin", safe(responder.getLogin())); + initiatorPayload.put("role", "initiator"); + initiatorPayload.put("issuedAtMs", System.currentTimeMillis()); + + boolean responderOk = WsEventSender.sendEvent(responder, "DebugConnectPrepareResponder", "evt-" + callId + "-r", responderPayload); + boolean initiatorOk = WsEventSender.sendEvent(initiator, "DebugConnectStartInitiator", "evt-" + callId + "-i", initiatorPayload); + + logs.add("info", runId, "debug-connect", initiatorSessionId, safe(initiator.getLogin()), + "Запуск debug-run", "initiator->" + responderSessionId + ", sentResponder=" + responderOk + ", sentInitiator=" + initiatorOk); + + ObjectNode payload = MAPPER.createObjectNode(); + payload.put("runId", runId); + payload.put("callId", callId); + payload.put("accepted", responderOk && initiatorOk); + payload.put("initiatorSessionId", initiatorSessionId); + payload.put("responderSessionId", responderSessionId); + payload.put("initiatorLogin", safe(initiator.getLogin())); + payload.put("responderLogin", safe(responder.getLogin())); + if (!safe(initiator.getLogin()).equalsIgnoreCase(safe(responder.getLogin()))) { + payload.put("mode", "cross-login"); + } else { + payload.put("warning", "Сессии принадлежат одному login, рекомендован тест между разными login"); + } + + writeOk(resp, payload); + } + + private static String text(JsonNode node, String field) { + if (node == null) return ""; + return safe(node.path(field).asText("")); + } + + private static String safe(String value) { + return value == null ? "" : value.trim(); + } + + private static void writeOk(HttpServletResponse resp, ObjectNode payload) throws IOException { + ObjectNode root = MAPPER.createObjectNode(); + root.put("ok", true); + root.set("payload", payload == null ? MAPPER.createObjectNode() : payload); + writeJson(resp, 200, root); + } + + private static void writeError(HttpServletResponse resp, int status, String code, String message) throws IOException { + ObjectNode root = MAPPER.createObjectNode(); + root.put("ok", false); + root.put("code", code); + root.put("message", message); + writeJson(resp, status, root); + } + + private static void writeJson(HttpServletResponse resp, int status, ObjectNode root) throws IOException { + resp.setStatus(status); + resp.setCharacterEncoding("UTF-8"); + resp.setContentType("application/json; charset=UTF-8"); + resp.getWriter().write(MAPPER.writeValueAsString(root)); + } +} diff --git a/src/main/java/server/debug/DebugLogsServlet.java b/src/main/java/server/debug/DebugLogsServlet.java new file mode 100644 index 0000000..9e22170 --- /dev/null +++ b/src/main/java/server/debug/DebugLogsServlet.java @@ -0,0 +1,100 @@ +package server.debug; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; +import java.util.List; + +/** + * GET /debug/ws/logs?limit=200&runId=dbg-... + */ +public class DebugLogsServlet extends HttpServlet { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final DebugTokenProvider tokenProvider; + + public DebugLogsServlet(DebugTokenProvider tokenProvider) { + this.tokenProvider = tokenProvider; + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + if (!tokenProvider.isEnabled()) { + writeError(resp, 503, "DEBUG_DISABLED", "Debug API отключен: .debug-token не найден или пуст"); + return; + } + if (!tokenProvider.matchesBearerHeader(req.getHeader("Authorization"))) { + writeError(resp, 401, "UNAUTHORIZED", "Неверный Bearer token"); + return; + } + + int limit = parseLimit(req.getParameter("limit")); + String runId = safe(req.getParameter("runId")); + + List list = DebugRunLogBuffer.getInstance().tail(limit, runId); + + ArrayNode rows = MAPPER.createArrayNode(); + for (DebugRunLogBuffer.Entry e : list) { + ObjectNode row = MAPPER.createObjectNode(); + row.put("ts", e.ts); + row.put("level", e.level); + row.put("runId", e.runId); + row.put("source", e.source); + row.put("sessionId", e.sessionId); + row.put("login", e.login); + row.put("message", e.message); + row.put("details", e.details); + rows.add(row); + } + + ObjectNode payload = MAPPER.createObjectNode(); + payload.put("count", rows.size()); + payload.put("limit", limit); + payload.put("runIdFilter", runId); + payload.set("logs", rows); + + writeOk(resp, payload); + } + + private static int parseLimit(String raw) { + try { + int n = Integer.parseInt(String.valueOf(raw)); + if (n < 1) return 100; + return Math.min(n, 1000); + } catch (Exception e) { + return 100; + } + } + + private static String safe(String value) { + return value == null ? "" : value.trim(); + } + + private static void writeOk(HttpServletResponse resp, ObjectNode payload) throws IOException { + ObjectNode root = MAPPER.createObjectNode(); + root.put("ok", true); + root.set("payload", payload == null ? MAPPER.createObjectNode() : payload); + writeJson(resp, 200, root); + } + + private static void writeError(HttpServletResponse resp, int status, String code, String message) throws IOException { + ObjectNode root = MAPPER.createObjectNode(); + root.put("ok", false); + root.put("code", code); + root.put("message", message); + writeJson(resp, status, root); + } + + private static void writeJson(HttpServletResponse resp, int status, ObjectNode root) throws IOException { + resp.setStatus(status); + resp.setCharacterEncoding("UTF-8"); + resp.setContentType("application/json; charset=UTF-8"); + resp.getWriter().write(MAPPER.writeValueAsString(root)); + } +} diff --git a/src/main/java/server/debug/DebugRunLogBuffer.java b/src/main/java/server/debug/DebugRunLogBuffer.java new file mode 100644 index 0000000..57e3b39 --- /dev/null +++ b/src/main/java/server/debug/DebugRunLogBuffer.java @@ -0,0 +1,81 @@ +package server.debug; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.UUID; + +/** + * Потокобезопасный in-memory буфер debug-логов. + */ +public final class DebugRunLogBuffer { + + public static final class Entry { + public long ts; + public String level; + public String runId; + public String source; + public String sessionId; + public String login; + public String message; + public String details; + } + + private static final DebugRunLogBuffer INSTANCE = new DebugRunLogBuffer(5_000); + + public static DebugRunLogBuffer getInstance() { + return INSTANCE; + } + + private final int capacity; + private final Deque entries = new ArrayDeque<>(); + + private DebugRunLogBuffer(int capacity) { + this.capacity = Math.max(100, capacity); + } + + public synchronized String newRunId() { + return "dbg-" + UUID.randomUUID(); + } + + public synchronized void clear() { + entries.clear(); + } + + public synchronized void add(String level, String runId, String source, String sessionId, String login, String message, String details) { + Entry e = new Entry(); + e.ts = System.currentTimeMillis(); + e.level = safe(level, "info"); + e.runId = safe(runId, ""); + e.source = safe(source, "server"); + e.sessionId = safe(sessionId, ""); + e.login = safe(login, ""); + e.message = safe(message, ""); + e.details = safe(details, ""); + + entries.addLast(e); + while (entries.size() > capacity) { + entries.removeFirst(); + } + } + + public synchronized List tail(int limit, String runIdFilter) { + int capped = Math.max(1, Math.min(limit, 1000)); + String filter = safe(runIdFilter, ""); + + List out = new ArrayList<>(capped); + Object[] arr = entries.toArray(); + for (int i = arr.length - 1; i >= 0 && out.size() < capped; i--) { + Entry e = (Entry) arr[i]; + if (!filter.isBlank() && !filter.equals(e.runId)) continue; + out.add(e); + } + return out; + } + + private static String safe(String value, String fallback) { + String s = value == null ? "" : value.trim(); + return s.isBlank() ? fallback : s; + } +} diff --git a/src/main/java/server/debug/DebugTokenProvider.java b/src/main/java/server/debug/DebugTokenProvider.java new file mode 100644 index 0000000..d53a134 --- /dev/null +++ b/src/main/java/server/debug/DebugTokenProvider.java @@ -0,0 +1,54 @@ +package server.debug; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Загружает debug token из файла .debug-token в корне проекта. + */ +public final class DebugTokenProvider { + private static final Logger log = LoggerFactory.getLogger(DebugTokenProvider.class); + private static final String TOKEN_FILE_NAME = ".debug-token"; + + private final String token; + + private DebugTokenProvider(String token) { + this.token = token; + } + + public static DebugTokenProvider loadFromProjectRoot() { + Path tokenPath = Path.of(TOKEN_FILE_NAME).toAbsolutePath().normalize(); + String loaded = ""; + try { + if (Files.exists(tokenPath)) { + loaded = Files.readString(tokenPath, StandardCharsets.UTF_8).trim(); + } + } catch (IOException e) { + log.error("Не удалось прочитать debug token из {}", tokenPath, e); + } + + if (loaded.isBlank()) { + log.warn("⚠️ Debug API отключены: файл {} отсутствует или пуст.", tokenPath); + } else { + log.info("✅ Debug API включены, token файл найден: {}", tokenPath); + } + return new DebugTokenProvider(loaded); + } + + public boolean isEnabled() { + return token != null && !token.isBlank(); + } + + public boolean matchesBearerHeader(String authorizationHeader) { + if (!isEnabled()) return false; + if (authorizationHeader == null || authorizationHeader.isBlank()) return false; + if (!authorizationHeader.startsWith("Bearer ")) return false; + String actual = authorizationHeader.substring("Bearer ".length()).trim(); + return token.equals(actual); + } +} diff --git a/src/main/java/server/ws/WsServer.java b/src/main/java/server/ws/WsServer.java index cb4c177..31bf3df 100644 --- a/src/main/java/server/ws/WsServer.java +++ b/src/main/java/server/ws/WsServer.java @@ -5,6 +5,7 @@ import org.eclipse.jetty.servlet.ServletContextHandler; import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import server.debug.DebugApiConfigurator; import utils.config.AppConfig; import java.time.Duration; @@ -58,6 +59,9 @@ public final class WsServer { context.setContextPath("/"); server.setHandler(context); + // HTTP debug API + DebugApiConfigurator.register(context); + // Инициализация контейнера WebSocket JettyWebSocketServletContainerInitializer.configure(context, (servletContext, wsContainer) -> { // Таймаут простоя соединения (Jetty 11 синтаксис)