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) 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 aiortc 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.
33
34import asyncio
35import logging
36import re
37from dataclasses import dataclass
38from typing import Any, Optional
39
40from ..ice import Candidate, Connection, ConnectionClosed
41from pyee.asyncio import AsyncIOEventEmitter
42
43from .exceptions import InvalidStateError
44from .rtcconfiguration import RTCIceServer
45
46# See https://datatracker.ietf.org/doc/html/rfc7064
47# transport is not defined by RFC 7064 and rejected by browsers.
48STUN_REGEX = re.compile(
49 r"(?P<scheme>stun|stuns)\:(?P<host>[^?:]+)(\:(?P<port>[0-9]+?))?"
50 r"(\?transport=(?P<transport>.*))?"
51)
52# See https://datatracker.ietf.org/doc/html/rfc7065
53TURN_REGEX = re.compile(
54 r"(?P<scheme>turn|turns)\:(?P<host>[^?:]+)(\:(?P<port>[0-9]+?))?"
55 r"(\?transport=(?P<transport>.*))?"
56)
57
58logger = logging.getLogger(__name__)
59
60
61@dataclass
62class RTCIceCandidate:
63 """
64 The :class:`RTCIceCandidate` interface represents a candidate Interactive
65 Connectivity Establishment (ICE) configuration which may be used to
66 establish an RTCPeerConnection.
67 """
68
69 component: int
70 foundation: str
71 ip: str
72 port: int
73 priority: int
74 protocol: str
75 type: str
76 relatedAddress: Optional[str] = None
77 relatedPort: Optional[int] = None
78 sdpMid: Optional[str] = None
79 sdpMLineIndex: Optional[int] = None
80 tcpType: Optional[str] = None
81
82
83@dataclass
84class RTCIceParameters:
85 """
86 The :class:`RTCIceParameters` dictionary includes the ICE username
87 fragment and password and other ICE-related parameters.
88 """
89
90 usernameFragment: Optional[str] = None
91 "ICE username fragment."
92
93 password: Optional[str] = None
94 "ICE password."
95
96 iceLite: bool = False
97
98
99def candidate_from_aioice(x: Candidate) -> RTCIceCandidate:
100 return RTCIceCandidate(
101 component=x.component,
102 foundation=x.foundation,
103 ip=x.host,
104 port=x.port,
105 priority=x.priority,
106 protocol=x.transport,
107 relatedAddress=x.related_address,
108 relatedPort=x.related_port,
109 tcpType=x.tcptype,
110 type=x.type,
111 )
112
113
114def candidate_to_aioice(x: RTCIceCandidate) -> Candidate:
115 return Candidate(
116 component=x.component,
117 foundation=x.foundation,
118 host=x.ip,
119 port=x.port,
120 priority=x.priority,
121 related_address=x.relatedAddress,
122 related_port=x.relatedPort,
123 transport=x.protocol,
124 tcptype=x.tcpType,
125 type=x.type,
126 )
127
128
129def connection_kwargs(servers: list[RTCIceServer]) -> dict[str, Any]:
130 kwargs: dict[str, Any] = {}
131
132 for server in servers:
133 if isinstance(server.urls, list):
134 uris = server.urls
135 else:
136 uris = [server.urls]
137
138 for uri in uris:
139 parsed = parse_stun_turn_uri(uri)
140
141 if parsed["scheme"] == "stun":
142 # only a single STUN server is supported
143 if "stun_server" in kwargs:
144 continue
145
146 kwargs["stun_server"] = (parsed["host"], parsed["port"])
147 elif parsed["scheme"] in ["turn", "turns"]:
148 # only a single TURN server is supported
149 if "turn_server" in kwargs:
150 continue
151
152 # only 'udp' and 'tcp' transports are supported
153 if parsed["scheme"] == "turn" and parsed["transport"] not in [
154 "udp",
155 "tcp",
156 ]:
157 continue
158 elif parsed["scheme"] == "turns" and parsed["transport"] != "tcp":
159 continue
160
161 # only 'password' credentialType is supported
162 if server.credentialType != "password":
163 continue
164
165 kwargs["turn_server"] = (parsed["host"], parsed["port"])
166 kwargs["turn_ssl"] = parsed["scheme"] == "turns"
167 kwargs["turn_transport"] = parsed["transport"]
168 kwargs["turn_username"] = server.username
169 kwargs["turn_password"] = server.credential
170
171 return kwargs
172
173
174def parse_stun_turn_uri(uri: str) -> dict[str, Any]:
175 if uri.startswith("stun"):
176 match = STUN_REGEX.fullmatch(uri)
177 elif uri.startswith("turn"):
178 match = TURN_REGEX.fullmatch(uri)
179 else:
180 raise ValueError("malformed uri: invalid scheme")
181
182 if not match:
183 raise ValueError("malformed uri")
184
185 # set port
186 parsed: dict[str, Any] = match.groupdict()
187 if parsed["port"]:
188 parsed["port"] = int(parsed["port"])
189 elif parsed["scheme"] in ["stuns", "turns"]:
190 parsed["port"] = 5349
191 else:
192 parsed["port"] = 3478
193
194 # set transport
195 if parsed["scheme"] == "turn" and not parsed["transport"]:
196 parsed["transport"] = "udp"
197 elif parsed["scheme"] == "turns" and not parsed["transport"]:
198 parsed["transport"] = "tcp"
199 elif parsed["scheme"] in ["stun", "stuns"]:
200 if parsed["transport"] is not None:
201 raise ValueError(
202 "malformed uri: " + parsed["scheme"] + " must not contain transport"
203 )
204 del parsed["transport"]
205
206 return parsed
207
208
209class RTCIceGatherer(AsyncIOEventEmitter):
210 """
211 The :class:`RTCIceGatherer` interface gathers local host, server reflexive,
212 and relay candidates, as well as enabling the retrieval of local
213 Interactive Connectivity Establishment (ICE) parameters which can be
214 exchanged in signaling.
215 """
216
217 def __init__(
218 self,
219 iceServers: Optional[list[RTCIceServer]] = None,
220 local_username: Optional[str] = None,
221 local_password: Optional[str] = None,
222 ice_host_public_ips: Optional[list[str]] = None,
223 ice_port_range: Optional[tuple[int, int]] = None,
224 ice_udp_mux: Optional[Any] = None,
225 ice_tcp_mux: Optional[Any] = None,
226 ice_lite: bool = False,
227 ) -> None:
228 super().__init__()
229
230 if iceServers is None:
231 iceServers = self.getDefaultIceServers()
232 # An ICE-lite agent gathers host candidates only, so the servers stay
233 # with the peer that still uses them.
234 ice_kwargs = {} if ice_lite else connection_kwargs(iceServers)
235
236 self._connection = Connection(
237 ice_controlling=False,
238 local_username=local_username,
239 local_password=local_password,
240 nat1to1_ips=ice_host_public_ips,
241 port_range=ice_port_range,
242 udp_mux=ice_udp_mux,
243 tcp_mux=ice_tcp_mux,
244 ice_lite=ice_lite,
245 **ice_kwargs,
246 )
247 self._remote_candidates_end = False
248 self.__state = "new"
249
250 @property
251 def state(self) -> str:
252 """
253 The current state of the ICE gatherer.
254 """
255 return self.__state
256
257 async def gather(self) -> None:
258 """
259 Gather ICE candidates.
260 """
261 if self.__state == "new":
262 self.__setState("gathering")
263 await self._connection.gather_candidates()
264 self.__setState("completed")
265
266 @classmethod
267 def getDefaultIceServers(cls) -> list[RTCIceServer]:
268 """
269 Return the list of default :class:`RTCIceServer`.
270 """
271 return [RTCIceServer("stun:stun.l.google.com:19302")]
272
273 def getLocalCandidates(self) -> list[RTCIceCandidate]:
274 """
275 Retrieve the list of valid local candidates associated with the ICE
276 gatherer.
277 """
278 return [candidate_from_aioice(x) for x in self._connection.local_candidates]
279
280 def getLocalParameters(self) -> RTCIceParameters:
281 """
282 Retrieve the ICE parameters of the ICE gatherer.
283
284 :rtype: RTCIceParameters
285 """
286 return RTCIceParameters(
287 usernameFragment=self._connection.local_username,
288 password=self._connection.local_password,
289 iceLite=self._connection.ice_lite,
290 )
291
292 def __setState(self, state: str) -> None:
293 self.__state = state
294 self.emit("statechange")
295
296
297class RTCIceTransport(AsyncIOEventEmitter):
298 """
299 The :class:`RTCIceTransport` interface allows an application access to
300 information about the Interactive Connectivity Establishment (ICE)
301 transport over which packets are sent and received.
302
303 :param gatherer: An :class:`RTCIceGatherer`.
304 """
305
306 def __init__(self, gatherer: RTCIceGatherer) -> None:
307 super().__init__()
308 self.__iceGatherer = gatherer
309 self.__monitor_task: Optional[asyncio.Future[None]] = None
310 self.__start: Optional[asyncio.Event] = None
311 self.__state = "new"
312 self._connection = gatherer._connection
313 self._role_set = False
314
315 # expose recv / send methods
316 self._recv = self._connection.recv
317 self._send = self._connection.send
318
319 @property
320 def iceGatherer(self) -> RTCIceGatherer:
321 """
322 The ICE gatherer passed in the constructor.
323 """
324 return self.__iceGatherer
325
326 @property
327 def role(self) -> str:
328 """
329 The current role of the ICE transport.
330
331 Either `'controlling'` or `'controlled'`.
332 """
333 if self._connection.ice_controlling:
334 return "controlling"
335 else:
336 return "controlled"
337
338 @property
339 def state(self) -> str:
340 """
341 The current state of the ICE transport.
342 """
343 return self.__state
344
345 async def addRemoteCandidate(self, candidate: Optional[RTCIceCandidate]) -> None:
346 """
347 Add a remote candidate.
348
349 :param candidate: The new candidate or `None` to signal end of candidates.
350 """
351 if not self.__iceGatherer._remote_candidates_end:
352 if candidate is None:
353 self.__iceGatherer._remote_candidates_end = True
354 await self._connection.add_remote_candidate(None)
355 else:
356 await self._connection.add_remote_candidate(
357 candidate_to_aioice(candidate)
358 )
359
360 def getRemoteCandidates(self) -> list[RTCIceCandidate]:
361 """
362 Retrieve the list of candidates associated with the remote
363 :class:`RTCIceTransport`.
364 """
365 return [candidate_from_aioice(x) for x in self._connection.remote_candidates]
366
367 async def start(self, remoteParameters: RTCIceParameters) -> None:
368 """
369 Initiate connectivity checks.
370
371 :param remoteParameters: The :class:`RTCIceParameters` associated with
372 the remote :class:`RTCIceTransport`.
373 """
374 if self.state == "closed":
375 raise InvalidStateError("RTCIceTransport is closed")
376
377 # handle the case where start is already in progress
378 if self.__start is not None:
379 await self.__start.wait()
380 return
381 self.__start = asyncio.Event()
382 self.__monitor_task = asyncio.ensure_future(self._monitor())
383
384 self.__setState("checking")
385 self._connection.remote_is_lite = remoteParameters.iceLite
386 self._connection.remote_username = remoteParameters.usernameFragment
387 self._connection.remote_password = remoteParameters.password
388 try:
389 await self._connection.connect()
390 except ConnectionError:
391 self.__setState("failed")
392 else:
393 self.__setState("completed")
394 self.__start.set()
395
396 async def stop(self) -> None:
397 """
398 Irreversibly stop the :class:`RTCIceTransport`.
399 """
400 if self.state != "closed":
401 self.__setState("closed")
402 await self._connection.close()
403 if self.__monitor_task is not None:
404 await self.__monitor_task
405 self.__monitor_task = None
406
407 async def _monitor(self) -> None:
408 while True:
409 event = await self._connection.get_event()
410 if isinstance(event, ConnectionClosed):
411 if self.state == "completed":
412 self.__setState("failed")
413 return
414
415 def __log_debug(self, msg: str, *args: object) -> None:
416 logger.debug(f"RTCIceTransport(%s) {msg}", self.role, *args)
417
418 def __setState(self, state: str) -> None:
419 if state != self.__state:
420 self.__log_debug("- %s -> %s", self.__state, state)
421 self.__state = state
422 self.emit("statechange")
423
424 # no more events will be emitted, so remove all event listeners
425 # to facilitate garbage collection.
426 if state == "closed":
427 self.iceGatherer.remove_all_listeners()
428 self.remove_all_listeners()