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

89
scripts/setup_turn_coturn.sh Executable file
View File

@ -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 <<EOF
listening-port=3478
fingerprint
lt-cred-mech
use-auth-secret
static-auth-secret=${SECRET}
realm=${REALM}
total-quota=200
stale-nonce=600
no-multicast-peers
no-loopback-peers
no-cli
simple-log
external-ip=${PUBLIC_IP}
listening-ip=0.0.0.0
relay-ip=${PUBLIC_IP}
min-port=${MIN_PORT}
max-port=${MAX_PORT}
EOF
if [[ -f /etc/default/coturn ]]; then
sed -i 's/^#\?TURNSERVER_ENABLED=.*/TURNSERVER_ENABLED=1/' /etc/default/coturn || true
fi
systemctl enable coturn
systemctl restart coturn
systemctl --no-pager --full status coturn
echo
echo "coturn настроен."
echo "Откройте firewall: 3478/tcp, 3478/udp, ${MIN_PORT}-${MAX_PORT}/udp"
echo "Для SHiNE-сервера задайте такой же shared-secret в параметре:"
echo " -Dcall.ice.turn.sharedSecret=${SECRET}"

View File

@ -1412,6 +1412,13 @@ export class AuthService {
if (response.status !== 200) throw opError('CallSignalToSession', response); if (response.status !== 200) throw opError('CallSignalToSession', response);
return response.payload || {}; return response.payload || {};
} }
async getCallIceConfig() {
const response = await this.ws.request('GetCallIceConfig', {});
if (response.status !== 200) throw opError('GetCallIceConfig', response);
return response.payload || {};
}
async listContacts() { async listContacts() {
const response = await this.ws.request('ListContacts', {}); const response = await this.ws.request('ListContacts', {});
if (response.status !== 200) throw opError('ListContacts', response); if (response.status !== 200) throw opError('ListContacts', response);

View File

@ -23,6 +23,10 @@ let toneTimerId = null;
let toneName = ''; let toneName = '';
let toneFlip = false; let toneFlip = false;
const DEFAULT_ICE_SERVERS = Object.freeze([
{ urls: 'stun:stun.l.google.com:19302' },
]);
function nowMs() { function nowMs() {
return Date.now(); return Date.now();
} }
@ -55,6 +59,65 @@ function formatDuration(ms) {
return `${sec}с`; return `${sec}с`;
} }
function cloneDefaultIceServers() {
return DEFAULT_ICE_SERVERS.map((row) => ({ ...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() { function ensureAudioContext() {
if (audioContext) return audioContext; if (audioContext) return audioContext;
const Ctx = window.AudioContext || window.webkitAudioContext; const Ctx = window.AudioContext || window.webkitAudioContext;
@ -365,8 +428,9 @@ async function sendSignal(call, type, data = '') {
async function ensurePeerConnection(call) { async function ensurePeerConnection(call) {
if (call.pc) return call.pc; if (call.pc) return call.pc;
const iceServers = await resolveIceServers(call);
const pc = new RTCPeerConnection({ const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], iceServers,
}); });
if (call.debugMode && call.debugRole === 'initiator') { if (call.debugMode && call.debugRole === 'initiator') {

View File

@ -79,11 +79,13 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_UpsertPushToken_Reque
// --- NEW: Ping --- // --- NEW: Ping ---
import server.logic.ws_protocol.JSON.handlers.system.Net_GetServerInfo_Handler; 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_ClientErrorLog_Handler;
import server.logic.ws_protocol.JSON.handlers.system.Net_ClientDebugLog_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.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_ClientErrorLog_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_ClientDebugLog_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_GetServerInfo_Request;
import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_Ping_Request; import server.logic.ws_protocol.JSON.handlers.system.entyties.Net_Ping_Request;
@ -145,6 +147,7 @@ public final class JsonHandlerRegistry {
// --- system --- // --- system ---
Map.entry("Ping", new Net_Ping_Handler()), Map.entry("Ping", new Net_Ping_Handler()),
Map.entry("GetServerInfo", new Net_GetServerInfo_Handler()), Map.entry("GetServerInfo", new Net_GetServerInfo_Handler()),
Map.entry("GetCallIceConfig", new Net_GetCallIceConfig_Handler()),
Map.entry("ClientErrorLog", new Net_ClientErrorLog_Handler()), Map.entry("ClientErrorLog", new Net_ClientErrorLog_Handler()),
Map.entry("ClientDebugLog", new Net_ClientDebugLog_Handler()) Map.entry("ClientDebugLog", new Net_ClientDebugLog_Handler())
@ -198,6 +201,7 @@ public final class JsonHandlerRegistry {
// --- system --- // --- system ---
Map.entry("Ping", Net_Ping_Request.class), Map.entry("Ping", Net_Ping_Request.class),
Map.entry("GetServerInfo", Net_GetServerInfo_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("ClientErrorLog", Net_ClientErrorLog_Request.class),
Map.entry("ClientDebugLog", Net_ClientDebugLog_Request.class) Map.entry("ClientDebugLog", Net_ClientDebugLog_Request.class)
); );

View File

@ -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);
}
}
}

View File

@ -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 {
}

View File

@ -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; }
}

View File

@ -18,6 +18,23 @@ webpush.vapid.public=BOdoWZndZRaNe9kyUFsJ5-xEfFABXNKennAKg15Z7ycAwUIQ7yDV_sIWWYJ
webpush.vapid.private=3hCt7XxTvLzuoxinjT5QcKRQEBnGZHXn8ZilU31RPNE webpush.vapid.private=3hCt7XxTvLzuoxinjT5QcKRQEBnGZHXn8ZilU31RPNE
webpush.vapid.subject=mailto:admin@shine.local 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 для тестирования соединений # Временные debug HTTP API для тестирования соединений
# true - endpoint'ы /debug/ws/* включены (только при наличии .debug-token) # true - endpoint'ы /debug/ws/* включены (только при наличии .debug-token)