From a905822515b7ab6e67cb6fa2d7430807302b674ef5089284433c15279385e89e Mon Sep 17 00:00:00 2001 From: AidarKC Date: Wed, 22 Apr 2026 18:11:47 +0300 Subject: [PATCH] =?UTF-8?q?feat(call):=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BD=D0=B0=D1=8F=20=D0=B2=D1=8B=D0=B4=D0=B0=D1=87=D0=B0?= =?UTF-8?q?=20ICE/TURN=20=D0=B8=20=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B2=20WebRTC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/setup_turn_coturn.sh | 89 +++++++++++ shine-UI/js/services/auth-service.js | 7 + shine-UI/js/services/call-service.js | 66 +++++++- .../ws_protocol/JSON/JsonHandlerRegistry.java | 4 + .../system/Net_GetCallIceConfig_Handler.java | 149 ++++++++++++++++++ .../Net_GetCallIceConfig_Request.java | 15 ++ .../Net_GetCallIceConfig_Response.java | 42 +++++ src/main/resources/application.properties | 17 ++ 8 files changed, 388 insertions(+), 1 deletion(-) create mode 100755 scripts/setup_turn_coturn.sh create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/Net_GetCallIceConfig_Handler.java create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_GetCallIceConfig_Request.java create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_GetCallIceConfig_Response.java diff --git a/scripts/setup_turn_coturn.sh b/scripts/setup_turn_coturn.sh new file mode 100755 index 0000000..5a98944 --- /dev/null +++ b/scripts/setup_turn_coturn.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Установка coturn с REST-auth (временные credentials по shared-secret). +# Запускать НА TURN-сервере под root. +# +# Пример: +# sudo bash scripts/setup_turn_coturn.sh --secret "CHANGE_ME_LONG_SECRET" --realm "shineup.me" + +SECRET="" +REALM="shineup.me" +MIN_PORT="49160" +MAX_PORT="49200" + +while [[ $# -gt 0 ]]; do + case "$1" in + --secret) + SECRET="${2:-}" + shift 2 + ;; + --realm) + REALM="${2:-}" + shift 2 + ;; + --min-port) + MIN_PORT="${2:-49160}" + shift 2 + ;; + --max-port) + MAX_PORT="${2:-49200}" + shift 2 + ;; + *) + echo "Неизвестный аргумент: $1" >&2 + exit 1 + ;; + esac +done + +if [[ -z "${SECRET}" ]]; then + echo "Нужно передать --secret" >&2 + exit 1 +fi + +export DEBIAN_FRONTEND=noninteractive +apt-get update -y +apt-get install -y coturn + +PUBLIC_IP="$(hostname -I | awk '{print $1}')" +if [[ -z "${PUBLIC_IP}" ]]; then + echo "Не удалось определить public ip автоматически, укажите вручную в /etc/turnserver.conf" >&2 + PUBLIC_IP="0.0.0.0" +fi + +cat >/etc/turnserver.conf < ({ ...row })); +} + +function parseIceUrls(raw) { + if (Array.isArray(raw)) { + return raw + .map((item) => String(item || '').trim()) + .filter((item) => item.length > 0); + } + const single = String(raw || '').trim(); + if (!single) return []; + return [single]; +} + +function uniqueUrls(urls = []) { + const out = []; + const seen = new Set(); + urls.forEach((url) => { + const clean = String(url || '').trim(); + if (!clean || seen.has(clean)) return; + seen.add(clean); + out.push(clean); + }); + return out; +} + +async function resolveIceServers(call) { + try { + const payload = await authService.getCallIceConfig(); + const stunUrls = uniqueUrls(parseIceUrls(payload?.stunUrls)); + const turnUrls = uniqueUrls(parseIceUrls(payload?.turnUrls)); + const turnUsername = String(payload?.turnUsername || '').trim(); + const turnPassword = String(payload?.turnPassword || '').trim(); + + const iceServers = []; + if (stunUrls.length > 0) { + iceServers.push({ urls: stunUrls.length === 1 ? stunUrls[0] : stunUrls }); + } + if (turnUrls.length > 0 && turnUsername && turnPassword) { + iceServers.push({ + urls: turnUrls.length === 1 ? turnUrls[0] : turnUrls, + username: turnUsername, + credential: turnPassword, + }); + } + + if (iceServers.length === 0) { + await emitDebug(call, 'warn', 'call_ice_empty_from_server', 'using_default_stun'); + return cloneDefaultIceServers(); + } + await emitDebug(call, 'info', 'call_ice_loaded_from_server', `stun=${stunUrls.length}; turn=${turnUrls.length}`); + return iceServers; + } catch (error) { + await emitDebug(call, 'warn', 'call_ice_load_failed', toErrorText(error)); + return cloneDefaultIceServers(); + } +} + function ensureAudioContext() { if (audioContext) return audioContext; const Ctx = window.AudioContext || window.webkitAudioContext; @@ -365,8 +428,9 @@ async function sendSignal(call, type, data = '') { async function ensurePeerConnection(call) { if (call.pc) return call.pc; + const iceServers = await resolveIceServers(call); const pc = new RTCPeerConnection({ - iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], + iceServers, }); if (call.debugMode && call.debugRole === 'initiator') { 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 d9f12a4..b22f920 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 @@ -79,11 +79,13 @@ 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_GetCallIceConfig_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_GetCallIceConfig_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; @@ -145,6 +147,7 @@ public final class JsonHandlerRegistry { // --- system --- Map.entry("Ping", new Net_Ping_Handler()), Map.entry("GetServerInfo", new Net_GetServerInfo_Handler()), + Map.entry("GetCallIceConfig", new Net_GetCallIceConfig_Handler()), Map.entry("ClientErrorLog", new Net_ClientErrorLog_Handler()), Map.entry("ClientDebugLog", new Net_ClientDebugLog_Handler()) @@ -198,6 +201,7 @@ public final class JsonHandlerRegistry { // --- system --- Map.entry("Ping", Net_Ping_Request.class), Map.entry("GetServerInfo", Net_GetServerInfo_Request.class), + Map.entry("GetCallIceConfig", Net_GetCallIceConfig_Request.class), Map.entry("ClientErrorLog", Net_ClientErrorLog_Request.class), Map.entry("ClientDebugLog", Net_ClientDebugLog_Request.class) ); diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/Net_GetCallIceConfig_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/Net_GetCallIceConfig_Handler.java new file mode 100644 index 0000000..003e27e --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/Net_GetCallIceConfig_Handler.java @@ -0,0 +1,149 @@ +package server.logic.ws_protocol.JSON.handlers.system; + +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_GetCallIceConfig_Request; +import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_GetCallIceConfig_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import utils.config.AppConfig; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * Выдаёт ICE-конфиг для звонка. + * Поддерживает два режима TURN: + * 1) Временные учётки через shared-secret (рекомендуется). + * 2) Статический username/password (fallback). + */ +public class Net_GetCallIceConfig_Handler implements JsonMessageHandler { + private static final AppConfig CONFIG = AppConfig.getInstance(); + private static final String HMAC_SHA1 = "HmacSHA1"; + private static final int DEFAULT_TTL_SEC = 600; + private static final int MIN_TTL_SEC = 60; + private static final int MAX_TTL_SEC = 3600; + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { + Net_GetCallIceConfig_Request req = (Net_GetCallIceConfig_Request) baseRequest; + if (ctx == null || !ctx.isAuthenticatedUser()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация"); + } + + int ttlSec = clampTtlSec(readInt("call.ice.turn.ttlSec", DEFAULT_TTL_SEC)); + long nowMs = System.currentTimeMillis(); + long expiresAtMs = nowMs + ttlSec * 1000L; + + List stunUrls = parseUrls( + readStr("call.ice.stun.urls", "stun:stun.l.google.com:19302") + ); + List turnUrls = parseUrls(readStr("call.ice.turn.urls", "")); + + String turnUsername = ""; + String turnPassword = ""; + + String sharedSecret = readStr("call.ice.turn.sharedSecret", ""); + String staticUsername = readStr("call.ice.turn.username", ""); + String staticPassword = readStr("call.ice.turn.password", ""); + + if (!turnUrls.isEmpty()) { + if (!sharedSecret.isBlank()) { + long expiresEpochSec = nowMs / 1000L + ttlSec; + expiresAtMs = expiresEpochSec * 1000L; + String prefix = readStr("call.ice.turn.userPrefix", "shine"); + String safeLogin = sanitizeLogin(ctx.getLogin()); + turnUsername = expiresEpochSec + ":" + prefix + "_" + safeLogin; + turnPassword = makeTurnRestPassword(sharedSecret, turnUsername); + } else if (!staticUsername.isBlank() && !staticPassword.isBlank()) { + turnUsername = staticUsername; + turnPassword = staticPassword; + } + } + + Net_GetCallIceConfig_Response resp = new Net_GetCallIceConfig_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setStunUrls(stunUrls); + resp.setTurnUrls(turnUrls); + resp.setTurnUsername(turnUsername); + resp.setTurnPassword(turnPassword); + resp.setTurnEnabled(!turnUrls.isEmpty() && !turnUsername.isBlank() && !turnPassword.isBlank()); + resp.setGeneratedAtMs(nowMs); + resp.setExpiresAtMs(expiresAtMs); + resp.setTtlSec(ttlSec); + return resp; + } + + private static int readInt(String key, int fallback) { + String value = CONFIG.getParam(key); + if (value == null || value.isBlank()) return fallback; + try { + return Integer.parseInt(value.trim()); + } catch (Exception e) { + return fallback; + } + } + + private static String readStr(String key, String fallback) { + String value = CONFIG.getParam(key); + if (value == null) return fallback; + String trimmed = value.trim(); + return trimmed.isEmpty() ? fallback : trimmed; + } + + private static int clampTtlSec(int ttlSec) { + if (ttlSec < MIN_TTL_SEC) return MIN_TTL_SEC; + return Math.min(ttlSec, MAX_TTL_SEC); + } + + private static List parseUrls(String raw) { + Set out = new LinkedHashSet<>(); + if (raw == null || raw.isBlank()) return new ArrayList<>(); + + String[] chunks = raw.split("[,\\n\\r]+"); + for (String chunk : chunks) { + String val = chunk == null ? "" : chunk.trim(); + if (val.isEmpty()) continue; + out.add(val); + } + return new ArrayList<>(out); + } + + private static String sanitizeLogin(String login) { + String value = login == null ? "" : login.trim().toLowerCase(Locale.ROOT); + if (value.isEmpty()) return "user"; + StringBuilder sb = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char ch = value.charAt(i); + boolean ok = (ch >= 'a' && ch <= 'z') + || (ch >= '0' && ch <= '9') + || ch == '_' || ch == '-' || ch == '.'; + sb.append(ok ? ch : '_'); + } + return sb.toString(); + } + + private static String makeTurnRestPassword(String sharedSecret, String username) { + try { + Mac mac = Mac.getInstance(HMAC_SHA1); + SecretKeySpec keySpec = new SecretKeySpec(sharedSecret.getBytes(StandardCharsets.UTF_8), HMAC_SHA1); + mac.init(keySpec); + byte[] digest = mac.doFinal(username.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(digest); + } catch (Exception e) { + throw new IllegalStateException("Не удалось сгенерировать TURN пароль", e); + } + } +} + diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_GetCallIceConfig_Request.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_GetCallIceConfig_Request.java new file mode 100644 index 0000000..e67a994 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_GetCallIceConfig_Request.java @@ -0,0 +1,15 @@ +package server.logic.ws_protocol.JSON.handlers.system.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +/** + * Пустой запрос: + * { + * "op": "GetCallIceConfig", + * "requestId": "req-1", + * "payload": {} + * } + */ +public class Net_GetCallIceConfig_Request extends Net_Request { +} + diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_GetCallIceConfig_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_GetCallIceConfig_Response.java new file mode 100644 index 0000000..7c773a5 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/system/entyties/Net_GetCallIceConfig_Response.java @@ -0,0 +1,42 @@ +package server.logic.ws_protocol.JSON.handlers.system.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +import java.util.ArrayList; +import java.util.List; + +public class Net_GetCallIceConfig_Response extends Net_Response { + private List stunUrls = new ArrayList<>(); + private List turnUrls = new ArrayList<>(); + private String turnUsername = ""; + private String turnPassword = ""; + private boolean turnEnabled; + private long generatedAtMs; + private long expiresAtMs; + private int ttlSec; + + public List getStunUrls() { return stunUrls; } + public void setStunUrls(List stunUrls) { this.stunUrls = stunUrls; } + + public List getTurnUrls() { return turnUrls; } + public void setTurnUrls(List turnUrls) { this.turnUrls = turnUrls; } + + public String getTurnUsername() { return turnUsername; } + public void setTurnUsername(String turnUsername) { this.turnUsername = turnUsername; } + + public String getTurnPassword() { return turnPassword; } + public void setTurnPassword(String turnPassword) { this.turnPassword = turnPassword; } + + public boolean isTurnEnabled() { return turnEnabled; } + public void setTurnEnabled(boolean turnEnabled) { this.turnEnabled = turnEnabled; } + + public long getGeneratedAtMs() { return generatedAtMs; } + public void setGeneratedAtMs(long generatedAtMs) { this.generatedAtMs = generatedAtMs; } + + public long getExpiresAtMs() { return expiresAtMs; } + public void setExpiresAtMs(long expiresAtMs) { this.expiresAtMs = expiresAtMs; } + + public int getTtlSec() { return ttlSec; } + public void setTtlSec(int ttlSec) { this.ttlSec = ttlSec; } +} + diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 336b5ab..b1e9e86 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -18,6 +18,23 @@ webpush.vapid.public=BOdoWZndZRaNe9kyUFsJ5-xEfFABXNKennAKg15Z7ycAwUIQ7yDV_sIWWYJ webpush.vapid.private=3hCt7XxTvLzuoxinjT5QcKRQEBnGZHXn8ZilU31RPNE webpush.vapid.subject=mailto:admin@shine.local +# ------------------------------------------------------------ +# ICE/TURN для звонков (операция GetCallIceConfig) +# Рекомендуемый режим: +# - задать call.ice.turn.sharedSecret +# - на coturn включить use-auth-secret и static-auth-secret=тот же secret +# Тогда сервер будет выдавать временный username/password (TTL). +# ------------------------------------------------------------ +call.ice.stun.urls=stun:stun.l.google.com:19302 +call.ice.turn.urls=turn:37.214.58.208:3478?transport=udp,turn:37.214.58.208:3478?transport=tcp +call.ice.turn.ttlSec=600 +call.ice.turn.userPrefix=shine +call.ice.turn.sharedSecret= + +# fallback на случай если shared-secret не используется +call.ice.turn.username= +call.ice.turn.password= + # ------------------------------------------------------------ # Временные debug HTTP API для тестирования соединений # true - endpoint'ы /debug/ws/* включены (только при наличии .debug-token)