Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/selkies/ice/turn.py: 25%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# This Source Code Form is subject to the terms of the Mozilla Public
2# License, v. 2.0. If a copy of the MPL was not distributed with this
3# file, You can obtain one at https://mozilla.org/MPL/2.0/.
4#
5# This file incorporates work covered by the following copyright and
6# permission notice:
7#
8# Copyright (c) 2018-2019 Jeremy Lainé.
9# All rights reserved.
10#
11# Redistribution and use in source and binary forms, with or without
12# modification, are permitted provided that the following conditions are met:
13#
14# * Redistributions of source code must retain the above copyright notice,
15# this list of conditions and the following disclaimer.
16# * Redistributions in binary form must reproduce the above copyright notice,
17# this list of conditions and the following disclaimer in the documentation
18# and/or other materials provided with the distribution.
19# * Neither the name of aioice nor the names of its contributors may
20# be used to endorse or promote products derived from this software without
21# specific prior written permission.
22#
23# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
24# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
25# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
26# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
27# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
28# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
29# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
30# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
31# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
32# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
34import asyncio
35import hashlib
36import logging
37import socket
38import ssl
39import struct
40import time
41from collections.abc import Callable
42from typing import Any, Optional, TypeVar, Union, cast
44from . import stun
45from .utils import random_transaction_id
47logger = logging.getLogger(__name__)
49DEFAULT_CHANNEL_REFRESH_TIME = 500
50DEFAULT_ALLOCATION_LIFETIME = 600
51TCP_TRANSPORT = 0x06000000
52UDP_TRANSPORT = 0x11000000
53# Asked for both directions, the size libwebrtc gives its own UDP sockets; the
54# kernel clamps it to its per-socket ceiling where that is lower.
55UDP_SOCKET_BUFFER_SIZE = 262144
58def size_udp_socket(sock: socket.socket) -> None:
59 """Give an ICE or relay socket its receive and send buffers."""
60 for option in (socket.SO_RCVBUF, socket.SO_SNDBUF):
61 sock.setsockopt(socket.SOL_SOCKET, option, UDP_SOCKET_BUFFER_SIZE)
63_ProtocolT = TypeVar("_ProtocolT", bound=asyncio.DatagramProtocol)
66def is_channel_data(data: bytes) -> bool:
67 return (data[0] & 0xC0) == 0x40
70def make_integrity_key(username: str, realm: str, password: str) -> bytes:
71 return hashlib.md5(":".join([username, realm, password]).encode("utf8")).digest()
74class TurnStreamMixin:
75 datagram_received: Callable[[bytes, Any], None]
76 transport: asyncio.BaseTransport
78 def data_received(self, data: bytes) -> None:
79 if not hasattr(self, "buffer"):
80 self.buffer = b""
81 self.buffer += data
83 while len(self.buffer) >= 4:
84 _, length = struct.unpack("!HH", self.buffer[0:4])
85 length += stun.padding_length(length)
86 if is_channel_data(self.buffer):
87 full_length = 4 + length
88 else:
89 full_length = 20 + length
90 if len(self.buffer) < full_length:
91 break
93 addr = self.transport.get_extra_info("peername")
94 self.datagram_received(self.buffer[0:full_length], addr)
95 self.buffer = self.buffer[full_length:]
97 def _padded(self, data: bytes) -> bytes:
98 # TCP and TCP-over-TLS must pad messages to 4-byte boundaries.
99 padding = stun.padding_length(len(data))
100 if padding:
101 data += bytes(padding)
102 return data
105class TurnClientMixin:
106 _send: Callable[[bytes], None]
108 def __init__(
109 self,
110 server: tuple[str, int],
111 username: Optional[str],
112 password: Optional[str],
113 lifetime: int,
114 channel_refresh_time: int,
115 ) -> None:
116 self.channel_refresh_at: dict[int, float] = {}
117 self.channel_to_peer: dict[int, tuple[str, int]] = {}
118 self.peer_connect_waiters: dict[
119 tuple[str, int], list[asyncio.Future[None]]
120 ] = {}
121 self.peer_to_channel: dict[tuple[str, int], int] = {}
123 self.channel_number = 0x4000
124 self.channel_refresh_time = channel_refresh_time
125 self.integrity_key: Optional[bytes] = None
126 self.lifetime = lifetime
127 self.nonce: Optional[bytes] = None
128 self.password = password
129 self.receiver: Optional[asyncio.DatagramProtocol] = None
130 self.realm: Optional[str] = None
131 self.refresh_task: Optional[asyncio.Task] = None
132 self.relayed_address: Optional[tuple[str, int]] = None
133 self.server = server
134 self.transactions: dict[bytes, stun.Transaction] = {}
135 self.username = username
137 async def channel_bind(self, channel_number: int, addr: tuple[str, int]) -> None:
138 request = stun.Message(
139 message_method=stun.Method.CHANNEL_BIND, message_class=stun.Class.REQUEST
140 )
141 request.attributes["CHANNEL-NUMBER"] = channel_number
142 request.attributes["XOR-PEER-ADDRESS"] = addr
143 await self.request_with_retry(request)
144 logger.info("TURN channel bound %d %s", channel_number, addr)
146 async def connect(self) -> tuple[str, int]:
147 """
148 Create a TURN allocation.
149 """
150 request = stun.Message(
151 message_method=stun.Method.ALLOCATE, message_class=stun.Class.REQUEST
152 )
153 request.attributes["LIFETIME"] = self.lifetime
154 request.attributes["REQUESTED-TRANSPORT"] = UDP_TRANSPORT
155 response, _ = await self.request_with_retry(request)
157 time_to_expiry = response.attributes["LIFETIME"]
158 self.relayed_address = response.attributes["XOR-RELAYED-ADDRESS"]
159 logger.info(
160 "TURN allocation created %s (expires in %d seconds)",
161 self.relayed_address,
162 time_to_expiry,
163 )
165 # periodically refresh allocation
166 self.refresh_task = asyncio.create_task(self.refresh(time_to_expiry))
168 return self.relayed_address
170 def connection_lost(self, exc: Exception) -> None:
171 logger.debug("%s connection_lost(%s)", self, exc)
172 if self.receiver is not None:
173 self.receiver.connection_lost(exc)
175 def connection_made(self, transport: asyncio.BaseTransport) -> None:
176 logger.debug("%s connection_made(%s)", self, transport)
177 self.transport = transport
179 def datagram_received(self, data: Union[bytes, str], addr: tuple[str, int]) -> None:
180 data = cast(bytes, data)
182 # demultiplex channel data
183 if len(data) >= 4 and is_channel_data(data):
184 channel, length = struct.unpack("!HH", data[0:4])
186 if len(data) >= length + 4 and self.receiver is not None:
187 peer_address = self.channel_to_peer.get(channel)
188 if peer_address:
189 payload = data[4 : 4 + length]
190 self.receiver.datagram_received(payload, peer_address)
192 return
194 try:
195 message = stun.parse_message(data)
196 logger.debug("%s < %s %s", self, addr, message)
197 except ValueError:
198 return
200 if (
201 message.message_class == stun.Class.RESPONSE
202 or message.message_class == stun.Class.ERROR
203 ) and message.transaction_id in self.transactions:
204 transaction = self.transactions[message.transaction_id]
205 transaction.response_received(message, addr)
207 async def delete(self) -> None:
208 """
209 Delete the TURN allocation.
210 """
211 if self.refresh_task:
212 self.refresh_task.cancel()
213 self.refresh_task = None
215 request = stun.Message(
216 message_method=stun.Method.REFRESH, message_class=stun.Class.REQUEST
217 )
218 request.attributes["LIFETIME"] = 0
219 try:
220 await self.request_with_retry(request)
221 except stun.TransactionError:
222 # we do not care, we need to shutdown
223 pass
225 logger.info("TURN allocation deleted %s", self.relayed_address)
226 self.transport.close()
228 async def refresh(self, time_to_expiry: int) -> None:
229 """
230 Periodically refresh the TURN allocation.
231 """
232 while True:
233 await asyncio.sleep(5 / 6 * time_to_expiry)
235 request = stun.Message(
236 message_method=stun.Method.REFRESH, message_class=stun.Class.REQUEST
237 )
238 request.attributes["LIFETIME"] = self.lifetime
239 response, _ = await self.request_with_retry(request)
241 time_to_expiry = response.attributes["LIFETIME"]
242 logger.info(
243 "TURN allocation refreshed %s (expires in %d seconds)",
244 self.relayed_address,
245 time_to_expiry,
246 )
248 async def request(
249 self, request: stun.Message
250 ) -> tuple[stun.Message, tuple[str, int]]:
251 """
252 Execute a STUN transaction and return the response.
253 """
254 assert request.transaction_id not in self.transactions
256 if self.integrity_key:
257 self.__add_authentication(request)
259 transaction = stun.Transaction(request, self.server, self)
260 self.transactions[request.transaction_id] = transaction
261 try:
262 return await transaction.run()
263 finally:
264 del self.transactions[request.transaction_id]
266 async def request_with_retry(
267 self, request: stun.Message
268 ) -> tuple[stun.Message, tuple[str, int]]:
269 """
270 Execute a STUN transaction and return the response.
272 On recoverable errors it will retry the request.
273 """
274 try:
275 response, addr = await self.request(request)
276 except stun.TransactionFailed as e:
277 error_code = e.response.attributes["ERROR-CODE"][0]
278 if (
279 "NONCE" in e.response.attributes
280 and self.username is not None
281 and self.password is not None
282 and (
283 (error_code == 401 and "REALM" in e.response.attributes) # type: ignore
284 or (error_code == 438 and self.realm is not None)
285 )
286 ):
287 # update long-term credentials
288 self.nonce = e.response.attributes["NONCE"]
289 if error_code == 401:
290 self.realm = e.response.attributes["REALM"]
291 self.integrity_key = make_integrity_key(
292 self.username, self.realm, self.password
293 )
295 # retry request with authentication
296 request.transaction_id = random_transaction_id()
297 response, addr = await self.request(request)
298 else:
299 raise
301 return response, addr
303 async def send_data(self, data: bytes, addr: tuple[str, int]) -> None:
304 """
305 Send data to a remote host via the TURN server.
306 """
307 # if a channel is being bound for the peer, wait
308 if addr in self.peer_connect_waiters:
309 loop = asyncio.get_running_loop()
310 waiter = loop.create_future()
311 self.peer_connect_waiters[addr].append(waiter)
312 await waiter
314 channel = self.peer_to_channel.get(addr)
315 now = time.time()
316 if channel is None:
317 self.peer_connect_waiters[addr] = []
318 channel = self.channel_number
319 self.channel_number += 1
321 # bind channel
322 await self.channel_bind(channel, addr)
324 # update state
325 self.channel_refresh_at[channel] = now + self.channel_refresh_time
326 self.channel_to_peer[channel] = addr
327 self.peer_to_channel[addr] = channel
329 # notify waiters
330 for waiter in self.peer_connect_waiters.pop(addr):
331 waiter.set_result(None)
332 elif now > self.channel_refresh_at[channel]:
333 # refresh channel
334 await self.channel_bind(channel, addr)
336 # update state
337 self.channel_refresh_at[channel] = now + self.channel_refresh_time
339 header = struct.pack("!HH", channel, len(data))
340 self._send(header + data)
342 def send_stun(self, message: stun.Message, addr: tuple[str, int]) -> None:
343 """
344 Send a STUN message to the TURN server.
345 """
346 logger.debug("%s > %s %s", self, addr, message)
347 self._send(bytes(message))
349 def __add_authentication(self, request: stun.Message) -> None:
350 request.attributes["USERNAME"] = self.username
351 request.attributes["NONCE"] = self.nonce
352 request.attributes["REALM"] = self.realm
353 request.add_message_integrity(self.integrity_key)
356class TurnClientTcpProtocol(TurnClientMixin, TurnStreamMixin, asyncio.Protocol):
357 """
358 Protocol for handling TURN over TCP.
359 """
361 transport: asyncio.Transport # type: ignore
363 def _send(self, data: bytes) -> None: # type: ignore
364 self.transport.write(self._padded(data))
366 def __repr__(self) -> str:
367 return "turn/tcp"
370class TurnClientUdpProtocol(TurnClientMixin, asyncio.DatagramProtocol):
371 """
372 Protocol for handling TURN over UDP.
373 """
375 transport: asyncio.DatagramTransport # type: ignore
377 def _send(self, data: bytes) -> None: # type: ignore
378 self.transport.sendto(data)
380 def __repr__(self) -> str:
381 return "turn/udp"
384TurnClientProtocol = Union[TurnClientTcpProtocol, TurnClientUdpProtocol]
387class TurnTransport:
388 """
389 Behaves like a Datagram transport, but uses a TURN allocation.
390 """
392 def __init__(self, inner_protocol: TurnClientProtocol) -> None:
393 self.__inner_protocol = inner_protocol
394 self.__relayed_address: Optional[tuple[str, int]] = None
396 def close(self) -> None:
397 """
398 Close the transport.
400 After the TURN allocation has been deleted, the protocol's
401 `connection_lost()` method will be called with None as its argument.
402 """
403 asyncio.create_task(self.__inner_protocol.delete())
405 def get_extra_info(self, name: str, default: Any = None) -> Any:
406 """
407 Return optional transport information.
409 - `'related_address'`: the related address
410 - `'sockname'`: the relayed address
411 """
412 if name == "related_address":
413 return self.__inner_protocol.transport.get_extra_info("sockname")
414 elif name == "sockname":
415 return self.__relayed_address
416 return default
418 def sendto(self, data: bytes, addr: tuple[str, int]) -> None:
419 """
420 Sends the `data` bytes to the remote peer given `addr`.
422 This will bind a TURN channel as necessary.
423 """
424 asyncio.create_task(self.__inner_protocol.send_data(data, addr))
426 async def _connect(self, protocol: asyncio.DatagramProtocol) -> None:
427 self.__relayed_address = await self.__inner_protocol.connect()
429 # Once the allocation has succeeded, notify the protocol
430 # and start relaying received data.
431 self.__inner_protocol.receiver = protocol
432 protocol.connection_made(cast(asyncio.DatagramTransport, self))
435async def create_turn_endpoint(
436 protocol_factory: Callable[[], _ProtocolT],
437 server_addr: tuple[str, int],
438 username: Optional[str],
439 password: Optional[str],
440 lifetime: int = DEFAULT_ALLOCATION_LIFETIME,
441 channel_refresh_time: int = DEFAULT_CHANNEL_REFRESH_TIME,
442 ssl: Optional[Union[bool, ssl.SSLContext]] = None,
443 transport: str = "udp",
444) -> tuple[TurnTransport, _ProtocolT]:
445 """
446 Create datagram connection relayed over TURN.
447 """
448 loop = asyncio.get_running_loop()
449 inner_protocol: TurnClientProtocol
450 inner_transport: asyncio.BaseTransport
451 if transport == "tcp":
452 inner_transport, inner_protocol = await loop.create_connection(
453 lambda: TurnClientTcpProtocol(
454 server_addr,
455 username=username,
456 password=password,
457 lifetime=lifetime,
458 channel_refresh_time=channel_refresh_time,
459 ),
460 host=server_addr[0],
461 port=server_addr[1],
462 ssl=ssl,
463 )
464 else:
465 inner_transport, inner_protocol = await loop.create_datagram_endpoint(
466 lambda: TurnClientUdpProtocol(
467 server_addr,
468 username=username,
469 password=password,
470 lifetime=lifetime,
471 channel_refresh_time=channel_refresh_time,
472 ),
473 remote_addr=server_addr,
474 )
475 sock = inner_transport.get_extra_info("socket")
476 if sock is not None:
477 size_udp_socket(sock)
479 try:
480 protocol = protocol_factory()
481 turn_transport = TurnTransport(inner_protocol)
482 await turn_transport._connect(protocol)
483 except Exception:
484 inner_transport.close()
485 raise
487 return turn_transport, protocol