feat(call): серверная выдача ICE/TURN и подключение в WebRTC
This commit is contained in:
parent
d7c7bb3c23
commit
a905822515
89
scripts/setup_turn_coturn.sh
Executable file
89
scripts/setup_turn_coturn.sh
Executable 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}"
|
||||
@ -1412,6 +1412,13 @@ export class AuthService {
|
||||
if (response.status !== 200) throw opError('CallSignalToSession', response);
|
||||
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() {
|
||||
const response = await this.ws.request('ListContacts', {});
|
||||
if (response.status !== 200) throw opError('ListContacts', response);
|
||||
|
||||
@ -23,6 +23,10 @@ let toneTimerId = null;
|
||||
let toneName = '';
|
||||
let toneFlip = false;
|
||||
|
||||
const DEFAULT_ICE_SERVERS = Object.freeze([
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
]);
|
||||
|
||||
function nowMs() {
|
||||
return Date.now();
|
||||
}
|
||||
@ -55,6 +59,65 @@ function formatDuration(ms) {
|
||||
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() {
|
||||
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') {
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user