TURN реле¶
TurnRelayServer реализует релей по RFC 5766 на чистом Dart. Клиенты
подключаются по TCP, а для пиров сервер выделяет либо UDP-сокеты, либо TCP-листенеры.
Листенер обрабатывает TURN-запросы Allocate, следит за разрешениями и каналами и
преобразует трафик от пиров в TURN Data или ChannelData кадры. Реализация
поставляется в составе rpc_dart_transports, но не зависит от rpc_dart, поэтому её
можно встраивать в любой UDP- или TCP-сервис, которому требуется обход NAT.
Основные возможности¶
- Помощники для TURN/STUN —
TurnMessage,TurnAttributeи утилиты кодируют XOR-адреса, DATA-атрибуты, время жизни и информацию о каналах, так что не приходится собирать бинарные буферы вручную. - Управление жизненным циклом allocation —
TurnAllocationотслеживает клиентские сокеты или TCP-листенеры, таймеры, разрешения на пиры и channel binding’и согласно RFC 5766. - Полная поддержка методов TURN — сервер обрабатывает
Allocate,Refresh,CreatePermission,ChannelBindиSend, а входящий пиринговый трафик отправляет клиенту в виде Data-индикаций или ChannelData-сообщений. - Встраиваемый логгер —
TurnRelayLoggerпозволяет адаптировать вывод под вашу систему логирования без дополнительных пакетов.
Запуск релея¶
import 'package:rpc_dart_transports/rpc_dart_transports.dart';
import 'package:universal_io/io.dart';
Future<void> main() async {
final relay = TurnRelayServer(
bindAddress: InternetAddress.anyIPv4,
bindPort: 3478,
logger: TurnRelayLogger(
scope: 'turn',
onInfo: (message) => print('[INFO] $message'),
onWarning: (message) => print('[WARN] $message'),
onError: (message, {error, stackTrace}) {
print('[ERROR] $message');
if (error != null) {
print(' error: $error');
}
if (stackTrace != null) {
print(' stack: $stackTrace');
}
},
),
);
await relay.start();
print('TURN relay listening on ${relay.bindAddress.address}:${relay.port}');
// ... держим процесс живым ...
await relay.stop();
}
Если листенер привязан к приватному адресу, передайте relayAddress, чтобы в
XOR-RELAYED-ADDRESS возвращался внешний IP.
Жизненный цикл allocation¶
Каждый успешный Allocate создаёт TurnAllocation:
clientAddress/clientPort— TCP-подключение клиента;relayPort— UDP- или TCP-порт релея, на который должны подключаться пиры;addPermission,hasPermissionиbindChannelреализуют правила безопасности TURN для разрешённых пиров и привязанных каналов;onPeerDataполучает датаграммы с relay-сокета и преобразует их обратно в TURN-сообщения.
Allocation истекает через allocationLifetime (по умолчанию 10 минут). Запрос
Refresh продлевает срок, а нулевой lifetime завершает allocation сразу.
Разрешения и каналы очищаются лениво по мере истечения TTL.
Как работает клиент¶
- Allocate — отправить запрос
Allocateс нужнымREQUESTED-TRANSPORT(по умолчанию UDP, для потокового транспорта — TCP). В успешном ответе придётXOR-RELAYED-ADDRESS. - CreatePermission — разрешить нужных пиров отдельными запросами.
- Передача данных — использовать индикации
Send(XOR-PEER-ADDRESS+DATA) или выполнитьChannelBindи слать ChannelData ради меньших накладных расходов. - Приём трафика от пиров — релей возвращает Data-индикации или ChannelData в зависимости от наличия channel binding’а на пира.
- Refresh / завершение —
Refreshпродлевает срок жизни, а запрос с нулевой длительностью закрывает allocation.
Интеграционный тест turn_relay_server_test.dart показывает этот сценарий: TURN
клиент общается с пиром через релей.
Настройки клиента¶
TurnRelayClient.connect принимает объект TurnRelayClientOptions, если нужно
переопределить таймауты, параметры времени жизни, локальный адрес сокета,
используемый транспорт пиров или поведение по созданию разрешений:
final client = await TurnRelayClient.connect(
serverAddress: server.bindAddress,
serverPort: server.port,
options: const TurnRelayClientOptions(
requestTimeout: Duration(seconds: 3),
allocationRefreshMargin: Duration(seconds: 10),
autoCreatePermission: false,
requestedTransport: TurnRequestedTransport.tcp,
),
);
Если параметр опустить, используются стандартные значения: 5-секундный таймаут запросов, автоматическое создание разрешений и продление allocation за 30 секунд до истечения срока.
Использование RpcTurnRelayPeer¶
RpcTurnRelayPeer облегчает настройку RPC-пира поверх TURN. Объект сначала
создаёт подключение к релею и регистрирует контракты, а реальный адрес пира
можно указать позднее, когда второй участник сообщит relayAddress и
relayPort.
final peer = await RpcTurnRelayPeer.connectToRelay(
serverAddress: relay.bindAddress,
serverPort: relay.port,
responderContracts: [EchoResponderContract()],
);
print('Мой TURN адрес: ${peer.relayAddress.address}:${peer.relayPort}');
// ... передайте адрес другому клиенту и получите его реквизиты ...
await peer.connectPeer(
peerAddress: otherPeerAddress,
peerPort: otherPeerPort,
);
final response = await peer.callerEndpoint.unaryRequest<RpcString, RpcString>(
serviceName: 'echo',
methodName: 'echo',
requestCodec: RpcString.codec,
responseCodec: RpcString.codec,
request: RpcString('ping'),
);
print(response.value);
Метод connectPeer() лениво создаёт транспорты и endpoints. После него можно
использовать callerEndpoint для исходящих RPC-вызовов, а responderEndpoint
автоматически обслужит зарегистрированные контракты. Метод close() аккуратно
освобождает все ресурсы и может вызываться несколько раз подряд.
Обмен приглашениями через QR¶
Всю процедуру подключения можно оставить внутри RPC-протокола, не поднимая отдельный сигналинговый сервис. Последовательность будет такой:
- Оба участника на старте приложения вызывают
RpcTurnRelayPeer.connectToRelay(...)и регистрируют контракт с методом вродеincomingInvite. - Каждый клиент генерирует QR-код с собственными
relayAddress,relayPortи дополнительными данными (например, именем пользователя или токеном проверки). - Когда пользователь А сканирует QR пользователя B, приложение А сразу
выполняет
peer.connectPeer(peerAddress: bAddress, peerPort: bPort), а затем делает RPC-вызовincomingInvite, передавая свои координаты. - Обработчик на стороне B показывает пользователю запрос. Если тот соглашается,
код вызывает
peer.connectPeer(...)с адресом и портом А из полученных данных и только после этого возвращает положительный ответ. - После взаимного подтверждения обе стороны продолжают работать через те же caller/responder endpoints в рамках основной сессии.
Контракт может декодировать полезную нагрузку и устанавливать транспорт только после явного согласия пользователя:
import 'dart:convert';
import 'package:universal_io/io.dart';
class ConnectionInvitationContract extends RpcResponderContract {
ConnectionInvitationContract(this.peer, this.showPrompt)
: super('ConnectionInvitation');
final RpcTurnRelayPeer peer;
final Future<bool> Function(Map<String, Object?> payload) showPrompt;
@override
void setup() {
addUnaryMethod<RpcString, RpcBool>(
methodName: 'incomingInvite',
requestCodec: RpcString.codec,
responseCodec: RpcBool.codec,
handler: _handleInvite,
);
}
Future<RpcBool> _handleInvite(
RpcString request, {
RpcContext? context,
}) async {
final payload = jsonDecode(request.value) as Map<String, Object?>;
final accepted = await showPrompt(payload);
if (accepted) {
await peer.connectPeer(
peerAddress: InternetAddress(payload['peerAddress']! as String),
peerPort: payload['peerPort']! as int,
);
}
return RpcBool(accepted);
}
}
Пользователь А отправляет приглашение со своими координатами, чтобы B смог подключиться после подтверждения:
final inviteAccepted = await peer.callerEndpoint.unaryRequest<RpcString, RpcBool>(
serviceName: 'ConnectionInvitation',
methodName: 'incomingInvite',
requestCodec: RpcString.codec,
responseCodec: RpcBool.codec,
request: RpcString(jsonEncode({
'peerAddress': peer.relayAddress.address,
'peerPort': peer.relayPort,
'displayName': currentUserName,
})),
);
if (!inviteAccepted.value) {
// пользователь на другой стороне отклонил подключение
return;
}
Так QR-обмен, подтверждение и запуск сессии остаются в рамках одного RPC-протокола, а TURN-релей продолжает транслировать зашифрованный трафик транспортного уровня.
Уведомление пира через relay¶
Если хочется, чтобы relay само уведомило участника о попытке подключения,
воспользуйтесь новой процедурой CONNECT-REQUEST, доступной через
RpcTurnRelayPeer.
// Боб подписывается на уведомления до показа своего QR.
peer.connectRequests.listen((TurnConnectRequest request) async {
final shouldAccept = await showPrompt(request);
if (!shouldAccept) {
return;
}
await peer.connectPeer(
peerAddress: request.peerAddress,
peerPort: request.peerPort,
);
});
// Алиса сканирует QR Боба и просит relay уведомить его.
await peer.requestPeerConnection(
peerAddress: bobAddress,
peerPort: bobPort,
payload: utf8.encode(jsonEncode({
'displayName': currentUserName,
})),
);
Relay отправляет индикацию с координатами allocation инициатора и, при желании, payload. Так шаг «постучаться и подключиться обратно» остаётся внутри TURN без отдельного уровня сигнализации.
Вспомогательные API¶
В turn_message.dart лежат функции, которые помогают собирать и разбирать TURN
кадры:
TurnMessage.encode()/TurnMessage.decode(...);encodeXorAddress/decodeXorAddress;encodeLifetime/decodeLifetime;encodeData.
Этого достаточно для UDP-профиля RFC 5766; обрамление TCP-потока выполняет
TurnTcpFrameDecoder внутри релея. В режиме TCP allocation поднимает
листенер и прокидывает байты через установленные подключения.
Ограничения¶
- Управление подключением происходит по TCP. Для пиров доступны UDP- и TCP-режимы, однако TLS и DTLS пока не реализованы, а TCP-сценарий предполагает, что пиры могут самостоятельно подключиться к опубликованному релейному порту.
- Аутентификация (долгосрочные/краткосрочные креденшелы) отсутствует, поэтому ограничивайте доступ на сетевом уровне или добавляйте собственную логику.
- Нет квот и механизма ALTERNATE-SERVER.
Даже с этими ограничениями релей подходит для тестов, прототипов и закрытых развёртываний, а также как основа для более функционального TURN-сервиса.