feat(call): серверная выдача ICE/TURN и подключение в WebRTC

This commit is contained in:
AidarKC
2026-04-22 18:11:47 +03:00
parent d7c7bb3c23
commit a905822515
8 changed files with 388 additions and 1 deletions
@@ -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)
);
@@ -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<String> stunUrls = parseUrls(
readStr("call.ice.stun.urls", "stun:stun.l.google.com:19302")
);
List<String> 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<String> parseUrls(String raw) {
Set<String> 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);
}
}
}
@@ -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 {
}
@@ -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<String> stunUrls = new ArrayList<>();
private List<String> turnUrls = new ArrayList<>();
private String turnUsername = "";
private String turnPassword = "";
private boolean turnEnabled;
private long generatedAtMs;
private long expiresAtMs;
private int ttlSec;
public List<String> getStunUrls() { return stunUrls; }
public void setStunUrls(List<String> stunUrls) { this.stunUrls = stunUrls; }
public List<String> getTurnUrls() { return turnUrls; }
public void setTurnUrls(List<String> 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; }
}