pax_global_header00006660000000000000000000000064147766701020014524gustar00rootroot0000000000000052 comment=29976f5415714df13787b933012f8a966a4859bf aioice-0.10.1/000077500000000000000000000000001477667010200130345ustar00rootroot00000000000000aioice-0.10.1/.github/000077500000000000000000000000001477667010200143745ustar00rootroot00000000000000aioice-0.10.1/.github/workflows/000077500000000000000000000000001477667010200164315ustar00rootroot00000000000000aioice-0.10.1/.github/workflows/tests.yml000066400000000000000000000034311477667010200203170ustar00rootroot00000000000000name: tests on: [push, pull_request] jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: 3.9 - name: Install packages run: pip install .[dev] - name: Run linters run: | ruff check --diff ruff format --diff mypy examples src tests test: runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] python: - '3.13' - '3.12' - '3.11' - '3.10' - '3.9' steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} - name: Disable firewall if: matrix.os == 'macos-latest' run: | sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate off - name: Run tests shell: bash run: | pip install .[dev] coverage run -m unittest discover -v coverage xml - name: Upload coverage report uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} package: runs-on: ubuntu-latest needs: [lint, test] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: 3.9 - name: Install packages run: pip install build wheel - name: Build package run: python -m build - name: Publish package if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags/') uses: pypa/gh-action-pypi-publish@release/v1 with: user: __token__ password: ${{ secrets.PYPI_TOKEN }} aioice-0.10.1/.gitignore000066400000000000000000000001311477667010200150170ustar00rootroot00000000000000*.egg-info *.pyc *.so .coverage .eggs .mypy_cache .vscode /build /dist /docs/_build /env aioice-0.10.1/.readthedocs.yaml000066400000000000000000000003061477667010200162620ustar00rootroot00000000000000version: 2 build: os: ubuntu-22.04 tools: python: "3.11" python: install: - method: pip path: . - requirements: requirements/doc.txt sphinx: configuration: docs/conf.py aioice-0.10.1/LICENSE000066400000000000000000000027501477667010200140450ustar00rootroot00000000000000Copyright (c) 2018-2019 Jeremy Lainé. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of aioice nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. aioice-0.10.1/MANIFEST.in000066400000000000000000000001761477667010200145760ustar00rootroot00000000000000include LICENSE recursive-include docs *.py *.rst Makefile recursive-include examples *.py recursive-include tests *.bin *.py aioice-0.10.1/README.rst000066400000000000000000000053641477667010200145330ustar00rootroot00000000000000aioice ====== |rtd| |pypi-v| |pypi-pyversions| |pypi-l| |pypi-wheel| |tests| |codecov| .. |rtd| image:: https://readthedocs.org/projects/aioice/badge/?version=latest :target: https://aioice.readthedocs.io/ .. |pypi-v| image:: https://img.shields.io/pypi/v/aioice.svg :target: https://pypi.python.org/pypi/aioice .. |pypi-pyversions| image:: https://img.shields.io/pypi/pyversions/aioice.svg :target: https://pypi.python.org/pypi/aioice .. |pypi-l| image:: https://img.shields.io/pypi/l/aioice.svg :target: https://pypi.python.org/pypi/aioice .. |pypi-wheel| image:: https://img.shields.io/pypi/wheel/aioice.svg :target: https://pypi.python.org/pypi/aioice .. |tests| image:: https://github.com/aiortc/aioice/workflows/tests/badge.svg :target: https://github.com/aiortc/aioice/actions .. |codecov| image:: https://img.shields.io/codecov/c/github/aiortc/aioice.svg :target: https://codecov.io/gh/aiortc/aioice What is ``aioice``? ------------------- ``aioice`` is a library for Interactive Connectivity Establishment (RFC 5245) in Python. It is built on top of ``asyncio``, Python's standard asynchronous I/O framework. Interactive Connectivity Establishment (ICE) is useful for applications that establish peer-to-peer UDP data streams, as it facilitates NAT traversal. Typical usecases include SIP and WebRTC. To learn more about ``aioice`` please `read the documentation`_. .. _read the documentation: https://aioice.readthedocs.io/en/stable/ Example ------- .. code:: python import asyncio import aioice async def connect_using_ice(): connection = aioice.Connection(ice_controlling=True) # gather local candidates await connection.gather_candidates() # send your information to the remote party using your signaling method send_local_info( connection.local_candidates, connection.local_username, connection.local_password) # receive remote information using your signaling method remote_candidates, remote_username, remote_password = get_remote_info() # perform ICE handshake for candidate in remote_candidates: await connection.add_remote_candidate(candidate) await connection.add_remote_candidate(None) connection.remote_username = remote_username connection.remote_password = remote_password await connection.connect() # send and receive data await connection.sendto(b'1234', 1) data, component = await connection.recvfrom() # close connection await connection.close() asyncio.run(connect_using_ice()) License ------- ``aioice`` is released under the `BSD license`_. .. _BSD license: https://aioice.readthedocs.io/en/stable/license.html aioice-0.10.1/docs/000077500000000000000000000000001477667010200137645ustar00rootroot00000000000000aioice-0.10.1/docs/Makefile000066400000000000000000000011331477667010200154220ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build SPHINXPROJ = aioice SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)aioice-0.10.1/docs/api.rst000066400000000000000000000011661477667010200152730ustar00rootroot00000000000000API Reference ============= .. automodule:: aioice .. autoclass:: Connection :members: local_candidates, local_username, local_password, remote_candidates, remote_username, remote_password .. automethod:: add_remote_candidate .. automethod:: gather_candidates .. automethod:: get_default_candidate .. automethod:: connect .. automethod:: close .. automethod:: recv .. automethod:: recvfrom .. automethod:: send .. automethod:: sendto .. automethod:: set_selected_pair .. autoclass:: Candidate .. automethod:: from_sdp .. automethod:: to_sdp aioice-0.10.1/docs/changelog.rst000066400000000000000000000037261477667010200164550ustar00rootroot00000000000000Changelog ========= .. currentmodule:: aioice 0.10.1 ------ * Remove a `print()` statement which was accidentally added in 0.10.0. 0.10.0 ------ * Enforce full type annotations on all the code. * Allow creating a connection with ufrag and pwd. * Fix an `IndexError` when decoding an invalid XOR-MAPPED-ADDRESS. * Connect to TURN server in parallel to STUN checks and catch errors. 0.9.0 ----- * Switch from `netifaces` to `ifaddr`. * Do not start candidate pair check if already started. 0.8.0 ----- * Allow gathering only relay (STUN/TURN) candidates. * Use padding when sending over data over TCP. * Add support for Python 3.11. 0.7.7 ----- * Close underlying transport when a TURN allocation is deleted. * Shutdown mDNS stack when it is no longer referenced. * Rewrite asynchronous tests as coroutines. 0.7.6 ----- * Ensure `dnspython` version is at least 2.0.0. * Avoid 400 error when using TURN with concurrent sends. * Avoid error if a connection is lost before the local candidate is set. 0.7.5 ----- * Emit an event when the ICE connection is closed. 0.7.4 ----- * Perform mDNS resolution using an A query, as Firefox does not respond to ANY queries. * Use full module name to name loggers. 0.7.3 ----- * Defer mDNS lock initialisation to avoid mismatched event-loops, fixes errors seen with uvloop. * Correctly gather STUN candidates when there are multiple components. 0.7.2 ----- * Add support for resolving mDNS candidates. 0.7.1 ----- TURN .... * Use the LIFETIME attribute returned by the server to determine the time-to-expiry for the allocation. * Raise stun.TransactionFailed if TURN allocation request is rejected with an error. * Handle 438 (Stale Nonce) error responses. * Ignore STUN transaction errors when deleting TURN allocation. * Periodically refresh channel bindings. 0.7.0 ----- Breaking ........ * Make :meth:`Connection.add_remote_candidate` a coroutine. * Remove the `Connection.remote_candidates` setter. aioice-0.10.1/docs/conf.py000066400000000000000000000123441477667010200152670ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # # aioice documentation build configuration file, created by # sphinx-quickstart on Thu Feb 8 17:22:14 2018. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import os import sys # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.insert(0, os.path.abspath("..")) # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. # # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ "sphinx.ext.autodoc", "sphinx_autodoc_typehints", "sphinxcontrib.asyncio", ] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] source_suffix = ".rst" # The master toctree document. master_doc = "index" # General information about the project. project = "aioice" copyright = "2018-2019, Jeremy Lainé" author = "Jeremy Lainé" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = "" # The full version, including alpha/beta/rc tags. release = "" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. language = None # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This patterns also effect to html_static_path and html_extra_path exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = "alabaster" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # html_theme_options = { "description": "A library for Interactive Connectivity Establishment in Python.", "github_button": True, "github_user": "aiortc", "github_repo": "aioice", } # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] # Custom sidebar templates, must be a dictionary that maps document names # to template names. # # This is required for the alabaster theme # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars html_sidebars = { "**": [ "about.html", "navigation.html", "relations.html", "searchbox.html", ] } # -- Options for HTMLHelp output ------------------------------------------ # Output file base name for HTML help builder. htmlhelp_basename = "aioicedoc" # -- Options for LaTeX output --------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # # 'preamble': '', # Latex figure (float) alignment # # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ (master_doc, "aioice.tex", "aioice Documentation", "Jeremy Lainé", "manual"), ] # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [(master_doc, "aioice", "aioice Documentation", [author], 1)] # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ( master_doc, "aioice", "aioice Documentation", author, "aioice", "One line description of project.", "Miscellaneous", ), ] aioice-0.10.1/docs/index.rst000066400000000000000000000023331477667010200156260ustar00rootroot00000000000000aioice ====== |pypi-v| |pypi-pyversions| |pypi-l| |pypi-wheel| |tests| |codecov| .. |pypi-v| image:: https://img.shields.io/pypi/v/aioice.svg :target: https://pypi.python.org/pypi/aioice .. |pypi-pyversions| image:: https://img.shields.io/pypi/pyversions/aioice.svg :target: https://pypi.python.org/pypi/aioice .. |pypi-l| image:: https://img.shields.io/pypi/l/aioice.svg :target: https://pypi.python.org/pypi/aioice .. |pypi-wheel| image:: https://img.shields.io/pypi/wheel/aioice.svg :target: https://pypi.python.org/pypi/aioice .. |tests| image:: https://github.com/aiortc/aioice/workflows/tests/badge.svg :target: https://github.com/aiortc/aioice/actions .. |codecov| image:: https://img.shields.io/codecov/c/github/aiortc/aioice.svg :target: https://codecov.io/gh/aiortc/aioice ``aioice`` is a library for Interactive Connectivity Establishment (RFC 5245) in Python. It is built on top of :mod:`asyncio`, Python's standard asynchronous I/O framework. Interactive Connectivity Establishment (ICE) is useful for applications that establish peer-to-peer UDP data streams, as it facilitates NAT traversal. Typical usecases include SIP and WebRTC. .. toctree:: :maxdepth: 2 api changelog license aioice-0.10.1/docs/license.rst000066400000000000000000000000601477667010200161340ustar00rootroot00000000000000License ------- .. literalinclude:: ../LICENSE aioice-0.10.1/examples/000077500000000000000000000000001477667010200146525ustar00rootroot00000000000000aioice-0.10.1/examples/ice-client.py000066400000000000000000000063261477667010200172470ustar00rootroot00000000000000#!/usr/bin/env python import argparse import asyncio import json import logging import websockets import aioice STUN_SERVER = ("stun.l.google.com", 19302) WEBSOCKET_URI = "ws://127.0.0.1:8765" async def offer(components: int) -> None: connection = aioice.Connection( ice_controlling=True, components=components, stun_server=STUN_SERVER ) await connection.gather_candidates() websocket = await websockets.connect(WEBSOCKET_URI) # send offer await websocket.send( json.dumps( { "candidates": [c.to_sdp() for c in connection.local_candidates], "password": connection.local_password, "username": connection.local_username, } ) ) # await answer message = json.loads(await websocket.recv()) print("received answer", message) for c in message["candidates"]: await connection.add_remote_candidate(aioice.Candidate.from_sdp(c)) await connection.add_remote_candidate(None) connection.remote_username = message["username"] connection.remote_password = message["password"] await websocket.close() await connection.connect() print("connected") # send data data = b"hello" component = 1 print("sending %s on component %d" % (repr(data), component)) await connection.sendto(data, component) data, component = await connection.recvfrom() print("received %s on component %d" % (repr(data), component)) await asyncio.sleep(5) await connection.close() async def answer(components: int) -> None: connection = aioice.Connection( ice_controlling=False, components=components, stun_server=STUN_SERVER ) await connection.gather_candidates() websocket = await websockets.connect(WEBSOCKET_URI) # await offer message = json.loads(await websocket.recv()) print("received offer", message) for c in message["candidates"]: await connection.add_remote_candidate(aioice.Candidate.from_sdp(c)) await connection.add_remote_candidate(None) connection.remote_username = message["username"] connection.remote_password = message["password"] # send answer await websocket.send( json.dumps( { "candidates": [c.to_sdp() for c in connection.local_candidates], "password": connection.local_password, "username": connection.local_username, } ) ) await websocket.close() await connection.connect() print("connected") # echo data back data, component = await connection.recvfrom() print("echoing %s on component %d" % (repr(data), component)) await connection.sendto(data, component) await asyncio.sleep(5) await connection.close() async def main() -> None: parser = argparse.ArgumentParser(description="ICE tester") parser.add_argument("action", choices=["offer", "answer"]) parser.add_argument("--components", type=int, default=1) options = parser.parse_args() logging.basicConfig(level=logging.DEBUG) if options.action == "offer": await offer(options.components) else: await answer(options.components) if __name__ == "__main__": asyncio.run(main()) aioice-0.10.1/examples/signaling-server.py000066400000000000000000000013511477667010200205030ustar00rootroot00000000000000#!/usr/bin/env python # # Simple websocket server to perform signaling. # import asyncio import binascii import os from websockets.asyncio.server import ServerConnection, serve clients: dict[bytes, ServerConnection] = {} async def echo(websocket: ServerConnection) -> None: client_id = binascii.hexlify(os.urandom(8)) clients[client_id] = websocket try: async for message in websocket: for c in clients.values(): if c != websocket: await c.send(message) finally: clients.pop(client_id) async def main() -> None: async with serve(echo, "0.0.0.0", 8765) as server: await server.serve_forever() if __name__ == "__main__": asyncio.run(main()) aioice-0.10.1/pyproject.toml000066400000000000000000000033421477667010200157520ustar00rootroot00000000000000[build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta" [project] name = "aioice" description = "An implementation of Interactive Connectivity Establishment (RFC 5245)" readme = "README.rst" requires-python = ">=3.9" license = "BSD-3-Clause" authors = [ { name = "Jeremy Lainé", email = "jeremy.laine@m4x.org" }, ] classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", ] dependencies = [ "dnspython>=2.0.0", "ifaddr>=0.2.0", ] dynamic = ["version"] [project.optional-dependencies] dev = [ "coverage[toml]>=7.2.2", "mypy", "pyopenssl", "ruff", "websockets", ] [project.urls] Homepage = "https://github.com/aiortc/aioice" Changelog = "https://aioice.readthedocs.io/en/stable/changelog.html" Documentation = "https://aioice.readthedocs.io/" [tool.coverage.run] source = ["aioice"] [tool.mypy] disallow_untyped_calls = true disallow_untyped_defs = true disallow_untyped_decorators = true mypy_path = "stubs" strict_optional = false warn_redundant_casts = true warn_unused_ignores = true [tool.ruff.lint] select = [ "E", # pycodestyle "F", # Pyflakes "W", # pycodestyle "I", # isort "T20", # flake8-print ] [tool.ruff.lint.per-file-ignores] "examples/*.py" = ["T201"] [tool.setuptools.dynamic] version = {attr = "aioice.__version__"} aioice-0.10.1/requirements/000077500000000000000000000000001477667010200155575ustar00rootroot00000000000000aioice-0.10.1/requirements/doc.txt000066400000000000000000000000571477667010200170670ustar00rootroot00000000000000sphinx_autodoc_typehints sphinxcontrib-asyncio aioice-0.10.1/setup.py000066400000000000000000000000461477667010200145460ustar00rootroot00000000000000import setuptools setuptools.setup() aioice-0.10.1/src/000077500000000000000000000000001477667010200136235ustar00rootroot00000000000000aioice-0.10.1/src/aioice/000077500000000000000000000000001477667010200150545ustar00rootroot00000000000000aioice-0.10.1/src/aioice/__init__.py000066400000000000000000000005301477667010200171630ustar00rootroot00000000000000import logging from .candidate import Candidate from .ice import Connection, ConnectionClosed, TransportPolicy __all__ = ["Candidate", "Connection", "ConnectionClosed", "TransportPolicy"] __version__ = "0.10.1" # Set default logging handler to avoid "No handler found" warnings. logging.getLogger(__name__).addHandler(logging.NullHandler()) aioice-0.10.1/src/aioice/candidate.py000066400000000000000000000103371477667010200173460ustar00rootroot00000000000000import hashlib import ipaddress from typing import Optional def candidate_foundation( candidate_type: str, candidate_transport: str, base_address: str ) -> str: """ See RFC 5245 - 4.1.1.3. Computing Foundations """ key = "%s|%s|%s" % (candidate_type, candidate_transport, base_address) return hashlib.md5(key.encode("ascii")).hexdigest() def candidate_priority( candidate_component: int, candidate_type: str, local_pref: int = 65535 ) -> int: """ See RFC 5245 - 4.1.2.1. Recommended Formula """ if candidate_type == "host": type_pref = 126 elif candidate_type == "prflx": type_pref = 110 elif candidate_type == "srflx": type_pref = 100 else: type_pref = 0 return (1 << 24) * type_pref + (1 << 8) * local_pref + (256 - candidate_component) class Candidate: """ An ICE candidate. """ def __init__( self, foundation: str, component: int, transport: str, priority: int, host: str, port: int, type: str, related_address: Optional[str] = None, related_port: Optional[int] = None, tcptype: Optional[str] = None, generation: Optional[int] = None, ) -> None: self.foundation = foundation self.component = component self.transport = transport self.priority = priority self.host = host self.port = port self.type = type self.related_address = related_address self.related_port = related_port self.tcptype = tcptype self.generation = generation @classmethod def from_sdp(cls, sdp: str) -> "Candidate": """ Parse a :class:`Candidate` from SDP. .. code-block:: python Candidate.from_sdp( '6815297761 1 udp 659136 1.2.3.4 31102 typ host generation 0') """ bits = sdp.split() if len(bits) < 8: raise ValueError("SDP does not have enough properties") related_address: Optional[str] = None related_port: Optional[int] = None tcptype: Optional[str] = None generation: Optional[int] = None for i in range(8, len(bits) - 1, 2): if bits[i] == "raddr": related_address = bits[i + 1] elif bits[i] == "rport": related_port = int(bits[i + 1]) elif bits[i] == "tcptype": tcptype = bits[i + 1] elif bits[i] == "generation": generation = int(bits[i + 1]) return Candidate( foundation=bits[0], component=int(bits[1]), transport=bits[2], priority=int(bits[3]), host=bits[4], port=int(bits[5]), type=bits[7], related_address=related_address, related_port=related_port, tcptype=tcptype, generation=generation, ) def to_sdp(self) -> str: """ Return a string representation suitable for SDP. """ sdp = "%s %d %s %d %s %d typ %s" % ( self.foundation, self.component, self.transport, self.priority, self.host, self.port, self.type, ) if self.related_address is not None: sdp += " raddr %s" % self.related_address if self.related_port is not None: sdp += " rport %s" % self.related_port if self.tcptype is not None: sdp += " tcptype %s" % self.tcptype if self.generation is not None: sdp += " generation %d" % self.generation return sdp def can_pair_with(self, other: "Candidate") -> bool: """ A local candidate is paired with a remote candidate if and only if the two candidates have the same component ID and have the same IP address version. """ a = ipaddress.ip_address(self.host) b = ipaddress.ip_address(other.host) return ( self.component == other.component and self.transport.lower() == other.transport.lower() and a.version == b.version ) def __repr__(self) -> str: return "Candidate(%s)" % self.to_sdp() aioice-0.10.1/src/aioice/ice.py000066400000000000000000001241211477667010200161670ustar00rootroot00000000000000import asyncio import copy import enum import ipaddress import itertools import logging import random import re import secrets import socket import threading from collections.abc import Callable from typing import Optional, Union, cast import ifaddr from . import mdns, stun, turn from .candidate import Candidate, candidate_foundation, candidate_priority from .utils import random_string logger = logging.getLogger(__name__) ICE_COMPLETED = 1 ICE_FAILED = 2 CONSENT_FAILURES = 6 CONSENT_INTERVAL = 5 connection_id = itertools.count() protocol_id = itertools.count() _mdns = threading.local() class TransportPolicy(enum.Enum): ALL = 0 """ All ICE candidates will be considered. """ RELAY = 1 """ Only ICE candidates whose IP addresses are being relayed, such as those being passed through a STUN or TURN server, will be considered. """ async def get_or_create_mdns_protocol(subscriber: object) -> mdns.MDnsProtocol: if not hasattr(_mdns, "lock"): _mdns.lock = asyncio.Lock() _mdns.protocol = None _mdns.subscribers = set() async with _mdns.lock: if _mdns.protocol is None: _mdns.protocol = await mdns.create_mdns_protocol() _mdns.subscribers.add(subscriber) return _mdns.protocol async def unref_mdns_protocol(subscriber: object) -> None: if hasattr(_mdns, "lock"): async with _mdns.lock: _mdns.subscribers.discard(subscriber) if _mdns.protocol and not _mdns.subscribers: await _mdns.protocol.close() _mdns.protocol = None def candidate_pair_priority( local: Candidate, remote: Candidate, ice_controlling: bool ) -> int: """ See RFC 5245 - 5.7.2. Computing Pair Priority and Ordering Pairs """ G = ice_controlling and local.priority or remote.priority D = ice_controlling and remote.priority or local.priority return (1 << 32) * min(G, D) + 2 * max(G, D) + (G > D and 1 or 0) def get_host_addresses(use_ipv4: bool, use_ipv6: bool) -> list[str]: """ Get local IP addresses. """ addresses = [] for adapter in ifaddr.get_adapters(): for ip in adapter.ips: if isinstance(ip.ip, str) and use_ipv4 and ip.ip != "127.0.0.1": addresses.append(ip.ip) elif use_ipv6 and ip.ip[0] != "::1" and ip.ip[2] == 0: addresses.append(ip.ip[0]) return addresses async def relayed_candidate( component: int, protocol_factory: Callable[[], "StunProtocol"], turn_server: tuple[str, int], turn_username: Optional[str], turn_password: Optional[str], turn_ssl: bool, turn_transport: str, ) -> tuple[Candidate, "StunProtocol"]: """ Connect to a TURN server to obtain a relayed candidate. """ # Connect to TURN server. _, protocol = await turn.create_turn_endpoint( protocol_factory, server_addr=turn_server, username=turn_username, password=turn_password, ssl=turn_ssl, transport=turn_transport, ) # Build relayed candidate. candidate_address = protocol.transport.get_extra_info("sockname") related_address = protocol.transport.get_extra_info("related_address") protocol.local_candidate = Candidate( foundation=candidate_foundation("relay", "udp", candidate_address[0]), component=component, transport="udp", priority=candidate_priority(component, "relay"), host=candidate_address[0], port=candidate_address[1], type="relay", related_address=related_address[0], related_port=related_address[1], ) return protocol.local_candidate, protocol async def server_reflexive_candidate( protocol: "StunProtocol", stun_server: tuple[str, int] ) -> tuple[Candidate, None]: """ Query STUN server to obtain a server-reflexive candidate. """ # lookup address loop = asyncio.get_event_loop() stun_server = ( await loop.run_in_executor(None, socket.gethostbyname, stun_server[0]), stun_server[1], ) # perform STUN query request = stun.Message( message_method=stun.Method.BINDING, message_class=stun.Class.REQUEST ) response, _ = await protocol.request(request, stun_server) local_candidate = protocol.local_candidate return Candidate( foundation=candidate_foundation("srflx", "udp", local_candidate.host), component=local_candidate.component, transport=local_candidate.transport, priority=candidate_priority(local_candidate.component, "srflx"), host=response.attributes["XOR-MAPPED-ADDRESS"][0], port=response.attributes["XOR-MAPPED-ADDRESS"][1], type="srflx", related_address=local_candidate.host, related_port=local_candidate.port, ), None def sort_candidate_pairs(pairs: list["CandidatePair"], ice_controlling: bool) -> None: """ Sort a list of candidate pairs. """ def pair_priority(pair: CandidatePair) -> int: return -candidate_pair_priority( pair.local_candidate, pair.remote_candidate, ice_controlling ) pairs.sort(key=pair_priority) def validate_password(value: str) -> None: """ Check the password is well-formed. See RFC 5245 - 15.4. "ice-ufrag" and "ice-pwd" Attributes """ if not re.match("^[a-z0-9+/]{22,256}$", value): raise ValueError("Password must satisfy 22*256ice-char") def validate_remote_candidate(candidate: Candidate) -> Candidate: """ Check the remote candidate is supported. """ if candidate.type not in ["host", "relay", "srflx"]: raise ValueError('Unexpected candidate type "%s"' % candidate.type) ipaddress.ip_address(candidate.host) return candidate def validate_username(value: str) -> None: """ Check the username is well-formed. See RFC 5245 - 15.4. "ice-ufrag" and "ice-pwd" Attributes """ if not re.match("^[a-z0-9+/]{4,256}$", value): raise ValueError("Username must satisfy 4*256ice-char") class CandidatePair: def __init__(self, protocol: "StunProtocol", remote_candidate: Candidate) -> None: self.task: Optional[asyncio.Task] = None self.nominated = False self.protocol = protocol self.remote_candidate = remote_candidate self.remote_nominated = False self.state = CandidatePair.State.FROZEN def __repr__(self) -> str: return "CandidatePair(%s -> %s)" % (self.local_addr, self.remote_addr) @property def component(self) -> int: return self.local_candidate.component @property def local_addr(self) -> tuple[str, int]: return (self.local_candidate.host, self.local_candidate.port) @property def local_candidate(self) -> Candidate: return self.protocol.local_candidate @property def remote_addr(self) -> tuple[str, int]: return (self.remote_candidate.host, self.remote_candidate.port) class State(enum.Enum): FROZEN = 0 WAITING = 1 IN_PROGRESS = 2 SUCCEEDED = 3 FAILED = 4 class StunProtocol(asyncio.DatagramProtocol): def __init__(self, receiver: "Connection") -> None: self.__closed: asyncio.Future[bool] = asyncio.Future() self.id = next(protocol_id) self.local_candidate: Optional[Candidate] = None self.receiver = receiver self.transport: Optional[asyncio.DatagramTransport] = None self.transactions: dict[bytes, stun.Transaction] = {} def connection_lost(self, exc: Exception) -> None: self.__log_debug("connection_lost(%s)", exc) if not self.__closed.done(): self.receiver.data_received(None, None) self.__closed.set_result(True) def connection_made(self, transport: asyncio.BaseTransport) -> None: self.__log_debug("connection_made(%s)", transport) self.transport = cast(asyncio.DatagramTransport, transport) def datagram_received(self, data: Union[bytes, str], addr: tuple) -> None: # force IPv6 four-tuple to a two-tuple addr = (addr[0], addr[1]) data = cast(bytes, data) try: message = stun.parse_message(data) self.__log_debug("< %s %s", addr, message) except ValueError: self.receiver.data_received(data, self.local_candidate.component) return if ( message.message_class == stun.Class.RESPONSE or message.message_class == stun.Class.ERROR ) and message.transaction_id in self.transactions: transaction = self.transactions[message.transaction_id] transaction.response_received(message, addr) elif message.message_class == stun.Class.REQUEST: self.receiver.request_received(message, addr, self, data) def error_received(self, exc: Exception) -> None: self.__log_debug("error_received(%s)", exc) # custom async def close(self) -> None: self.transport.close() await self.__closed async def request( self, request: stun.Message, addr: tuple[str, int], integrity_key: Optional[bytes] = None, retransmissions: Optional[int] = None, ) -> tuple[stun.Message, tuple[str, int]]: """ Execute a STUN transaction and return the response. """ assert request.transaction_id not in self.transactions if integrity_key is not None: request.add_message_integrity(integrity_key) transaction = stun.Transaction( request, addr, self, retransmissions=retransmissions ) self.transactions[request.transaction_id] = transaction try: return await transaction.run() finally: del self.transactions[request.transaction_id] async def send_data(self, data: bytes, addr: tuple[str, int]) -> None: self.transport.sendto(data, addr) def send_stun(self, message: stun.Message, addr: tuple[str, int]) -> None: """ Send a STUN message. """ self.__log_debug("> %s %s", addr, message) self.transport.sendto(bytes(message), addr) def __log_debug(self, msg: str, *args: object) -> None: logger.debug("%s %s " + msg, self.receiver, self, *args) def __repr__(self) -> str: return "protocol(%s)" % self.id class ConnectionEvent: pass class ConnectionClosed(ConnectionEvent): pass class Connection: """ An ICE connection for a single media stream. :param ice_controlling: Whether the local peer has the controlling role. :param components: The number of components. :param stun_server: The address of the STUN server or `None`. :param turn_server: The address of the TURN server or `None`. :param turn_username: The username for the TURN server. :param turn_password: The password for the TURN server. :param turn_ssl: Whether to use TLS for the TURN server. :param turn_transport: The transport for TURN server, `"udp"` or `"tcp"`. :param use_ipv4: Whether to use IPv4 candidates. :param use_ipv6: Whether to use IPv6 candidates. :param transport_policy: Transport policy. :param local_username: An optional local username, otherwise a random one will be generated. :param local_password: An optional local password, otherwise a random one will be generated. """ def __init__( self, ice_controlling: bool, components: int = 1, stun_server: Optional[tuple[str, int]] = None, turn_server: Optional[tuple[str, int]] = None, turn_username: Optional[str] = None, turn_password: Optional[str] = None, turn_ssl: bool = False, turn_transport: str = "udp", use_ipv4: bool = True, use_ipv6: bool = True, transport_policy: TransportPolicy = TransportPolicy.ALL, local_username: Optional[str] = None, local_password: Optional[str] = None, ) -> None: self.ice_controlling = ice_controlling if local_username is None: local_username = random_string(4) else: validate_username(local_username) if local_password is None: local_password = random_string(22) else: validate_password(local_password) #: Whether the remote party is an ICE Lite implementation. self.remote_is_lite = False #: Remote username, which you need to set. self.remote_username: Optional[str] = None #: Remote password, which you need to set. self.remote_password: Optional[str] = None self.stun_server = stun_server self.turn_server = turn_server self.turn_username = turn_username self.turn_password = turn_password self.turn_ssl = turn_ssl self.turn_transport = turn_transport # private self._closed = False self._components = set(range(1, components + 1)) self._check_list: list[CandidatePair] = [] self._check_list_done = False self._check_list_state: asyncio.Queue = asyncio.Queue() self._early_checks: list[ tuple[stun.Message, tuple[str, int], StunProtocol] ] = [] self._early_checks_done = False self._event_waiter: Optional[asyncio.Future[ConnectionEvent]] = None self._id = next(connection_id) self._local_candidates: list[Candidate] = [] self._local_candidates_end = False self._local_candidates_start = False self._local_password = local_password self._local_username = local_username self._nominated: dict[int, CandidatePair] = {} self._nominating: set[int] = set() self._protocols: list[StunProtocol] = [] self._remote_candidates: list[Candidate] = [] self._remote_candidates_end = False self._query_consent_task: Optional[asyncio.Task] = None self._queue: asyncio.Queue[tuple[Optional[bytes], Optional[int]]] = ( asyncio.Queue() ) self._tie_breaker = secrets.randbits(64) self._use_ipv4 = use_ipv4 self._use_ipv6 = use_ipv6 if ( stun_server is None and turn_server is None and transport_policy == TransportPolicy.RELAY ): raise ValueError( "Relay transport policy requires a STUN and/or TURN server." ) self._transport_policy = transport_policy @property def local_candidates(self) -> list[Candidate]: """ Local candidates, automatically set by :meth:`gather_candidates`. """ return self._local_candidates[:] @property def local_password(self) -> str: """ Local password, set at construction time. """ return self._local_password @property def local_username(self) -> str: """ Local username, set at construction time. """ return self._local_username @property def remote_candidates(self) -> list[Candidate]: """ Remote candidates, which you need to populate using :meth:`add_remote_candidate`. """ return self._remote_candidates[:] async def add_remote_candidate(self, remote_candidate: Optional[Candidate]) -> None: """ Add a remote candidate or signal end-of-candidates. To signal end-of-candidates, pass `None`. :param remote_candidate: A :class:`Candidate` instance or `None`. """ if self._remote_candidates_end: raise ValueError("Cannot add remote candidate after end-of-candidates.") # end-of-candidates if remote_candidate is None: self._prune_components() self._remote_candidates_end = True return # resolve mDNS candidate if mdns.is_mdns_hostname(remote_candidate.host): mdns_protocol = await get_or_create_mdns_protocol(self) remote_addr = await mdns_protocol.resolve(remote_candidate.host) if remote_addr is None: self.__log_info( f'Remote candidate "{remote_candidate.host}" could not be resolved' ) return self.__log_info( f'Remote candidate "{remote_candidate.host}" resolved to {remote_addr}' ) copy_candidate = copy.copy(remote_candidate) copy_candidate.host = remote_addr await self.add_remote_candidate(copy_candidate) return # validate the remote candidate try: validate_remote_candidate(remote_candidate) except ValueError as e: self.__log_info( f'Remote candidate "{remote_candidate.host}" is not valid: {e}' ) return self._remote_candidates.append(remote_candidate) # pair the remote candidate for protocol in self._protocols: if protocol.local_candidate.can_pair_with( remote_candidate ) and not self._find_pair(protocol, remote_candidate): pair = CandidatePair(protocol, remote_candidate) self._check_list.append(pair) self.sort_check_list() async def gather_candidates(self) -> None: """ Gather local candidates. You **must** call this coroutine before calling :meth:`connect`. """ if not self._local_candidates_start: self._local_candidates_start = True addresses = get_host_addresses( use_ipv4=self._use_ipv4, use_ipv6=self._use_ipv6 ) coros = [ self.get_component_candidates(component=component, addresses=addresses) for component in self._components ] for candidates in await asyncio.gather(*coros): self._local_candidates += candidates self._local_candidates_end = True def get_default_candidate(self, component: int) -> Optional[Candidate]: """ Get the default local candidate for the specified component. :param component: The component whose default candidate is requested. """ for candidate in sorted(self._local_candidates, key=lambda x: x.priority): if candidate.component == component: return candidate return None async def connect(self) -> None: """ Perform ICE handshake. This coroutine returns if a candidate pair was successfuly nominated and raises an exception otherwise. """ if not self._local_candidates_end: raise ConnectionError("Local candidates gathering was not performed") if self.remote_username is None or self.remote_password is None: raise ConnectionError("Remote username or password is missing") # 5.7.1. Forming Candidate Pairs for remote_candidate in self._remote_candidates: for protocol in self._protocols: if protocol.local_candidate.can_pair_with( remote_candidate ) and not self._find_pair(protocol, remote_candidate): pair = CandidatePair(protocol, remote_candidate) self._check_list.append(pair) self.sort_check_list() self._unfreeze_initial() # handle early checks for early_check in self._early_checks: self.check_incoming(*early_check) self._early_checks = [] self._early_checks_done = True # perform checks while True: if not self.check_periodic(): break await asyncio.sleep(0.02) # wait for completion if self._check_list: res = await self._check_list_state.get() else: res = ICE_FAILED # cancel remaining checks for check in self._check_list: if check.task: check.task.cancel() if res != ICE_COMPLETED: raise ConnectionError("ICE negotiation failed") # start consent freshness tests self._query_consent_task = asyncio.create_task(self.query_consent()) async def close(self) -> None: """ Close the connection. """ # stop consent freshness tests if self._query_consent_task and not self._query_consent_task.done(): self._query_consent_task.cancel() try: await self._query_consent_task except asyncio.CancelledError: pass # stop check list if self._check_list and not self._check_list_done: self._check_list_state.put_nowait(ICE_FAILED) # unreference mDNS await unref_mdns_protocol(self) self._nominated.clear() for protocol in self._protocols: await protocol.close() self._protocols.clear() self._local_candidates.clear() # emit event if not self._closed: self._emit_event(ConnectionClosed()) self._closed = True async def get_event(self) -> Optional[ConnectionEvent]: """ Return the next `ConnectionEvent` or `None` if the connection is already closed. This method may only be called once at a time. """ assert self._event_waiter is None, "already awaiting event" if self._closed: return None loop = asyncio.get_event_loop() self._event_waiter = loop.create_future() return await asyncio.shield(self._event_waiter) async def recv(self) -> bytes: """ Receive the next datagram. The return value is a `bytes` object representing the data received. If the connection is not established, a `ConnectionError` is raised. """ data, component = await self.recvfrom() return data async def recvfrom(self) -> tuple[bytes, int]: """ Receive the next datagram. The return value is a `(bytes, component)` tuple where `bytes` is a bytes object representing the data received and `component` is the component on which the data was received. If the connection is not established, a `ConnectionError` is raised. """ if not len(self._nominated): raise ConnectionError("Cannot receive data, not connected") result = await self._queue.get() if result[0] is None: raise ConnectionError("Connection lost while receiving data") return result async def send(self, data: bytes) -> None: """ Send a datagram on the first component. If the connection is not established, a `ConnectionError` is raised. :param data: The data to be sent. """ await self.sendto(data, 1) async def sendto(self, data: bytes, component: int) -> None: """ Send a datagram on the specified component. If the connection is not established, a `ConnectionError` is raised. :param data: The data to be sent. :param component: The component on which to send the data. """ active_pair = self._nominated.get(component) if active_pair: await active_pair.protocol.send_data(data, active_pair.remote_addr) else: raise ConnectionError("Cannot send data, not connected") def set_selected_pair( self, component: int, local_foundation: str, remote_foundation: str ) -> None: """ Force the selected candidate pair. If the remote party does not support ICE, you should using this instead of calling :meth:`connect`. """ # find local candidate protocol = None for p in self._protocols: if ( p.local_candidate.component == component and p.local_candidate.foundation == local_foundation ): protocol = p break # find remote candidate remote_candidate = None for c in self._remote_candidates: if c.component == component and c.foundation == remote_foundation: remote_candidate = c assert protocol and remote_candidate self._nominated[component] = CandidatePair(protocol, remote_candidate) # private def build_request(self, pair: CandidatePair, nominate: bool) -> stun.Message: tx_username = "%s:%s" % (self.remote_username, self.local_username) request = stun.Message( message_method=stun.Method.BINDING, message_class=stun.Class.REQUEST ) request.attributes["USERNAME"] = tx_username request.attributes["PRIORITY"] = candidate_priority(pair.component, "prflx") if self.ice_controlling: request.attributes["ICE-CONTROLLING"] = self._tie_breaker if nominate: request.attributes["USE-CANDIDATE"] = None else: request.attributes["ICE-CONTROLLED"] = self._tie_breaker return request def check_complete(self, pair: CandidatePair) -> None: pair.task = None if pair.state == CandidatePair.State.SUCCEEDED: if pair.nominated: self._nominated[pair.component] = pair # 8.1.2. Updating States # # The agent MUST remove all Waiting and Frozen pairs in the check # list and triggered check queue for the same component as the # nominated pairs for that media stream. for p in self._check_list: if p.component == pair.component and p.state in [ CandidatePair.State.WAITING, CandidatePair.State.FROZEN, ]: self.check_state(p, CandidatePair.State.FAILED) # Once there is at least one nominated pair in the valid list for # every component of at least one media stream and the state of the # check list is Running: if len(self._nominated) == len(self._components): if not self._check_list_done: self.__log_info("ICE completed") self._check_list_state.put_nowait(ICE_COMPLETED) self._check_list_done = True return # 7.1.3.2.3. Updating Pair States for p in self._check_list: if ( p.local_candidate.foundation == pair.local_candidate.foundation and p.state == CandidatePair.State.FROZEN ): self.check_state(p, CandidatePair.State.WAITING) for p in self._check_list: if p.state not in [ CandidatePair.State.SUCCEEDED, CandidatePair.State.FAILED, ]: return if not self.ice_controlling: for p in self._check_list: if p.state == CandidatePair.State.SUCCEEDED: return if not self._check_list_done: self.__log_info("ICE failed") self._check_list_state.put_nowait(ICE_FAILED) self._check_list_done = True def check_incoming( self, message: stun.Message, addr: tuple[str, int], protocol: StunProtocol ) -> None: """ Handle a succesful incoming check. """ component = protocol.local_candidate.component # find remote candidate remote_candidate = None for c in self._remote_candidates: if c.host == addr[0] and c.port == addr[1]: remote_candidate = c assert remote_candidate.component == component break if remote_candidate is None: # 7.2.1.3. Learning Peer Reflexive Candidates remote_candidate = Candidate( foundation=random_string(10), component=component, transport="udp", priority=message.attributes["PRIORITY"], host=addr[0], port=addr[1], type="prflx", ) self._remote_candidates.append(remote_candidate) self.__log_info("Discovered peer reflexive candidate %s", remote_candidate) # find pair pair = self._find_pair(protocol, remote_candidate) if pair is None: pair = CandidatePair(protocol, remote_candidate) pair.state = CandidatePair.State.WAITING self._check_list.append(pair) self.sort_check_list() # triggered check if pair.state in [CandidatePair.State.WAITING, CandidatePair.State.FAILED]: self.check_start_task(pair) # 7.2.1.5. Updating the Nominated Flag if "USE-CANDIDATE" in message.attributes and not self.ice_controlling: pair.remote_nominated = True if pair.state == CandidatePair.State.SUCCEEDED: pair.nominated = True self.check_complete(pair) def check_periodic(self) -> bool: # find the highest-priority pair that is in the waiting state for pair in self._check_list: if pair.state == CandidatePair.State.WAITING: self.check_start_task(pair) return True # find the highest-priority pair that is in the frozen state for pair in self._check_list: if pair.state == CandidatePair.State.FROZEN: self.check_start_task(pair) return True # if we expect more candidates, keep going if not self._remote_candidates_end: return not self._check_list_done return False async def check_start(self, pair: CandidatePair) -> None: """ Starts a check. """ self.check_state(pair, CandidatePair.State.IN_PROGRESS) nominate = self.ice_controlling and not self.remote_is_lite request = self.build_request(pair, nominate=nominate) try: response, addr = await pair.protocol.request( request, pair.remote_addr, integrity_key=self.remote_password.encode("utf8"), ) except stun.TransactionError as exc: # 7.1.3.1. Failure Cases if ( exc.response and exc.response.attributes.get("ERROR-CODE", (None, None))[0] == 487 ): if "ICE-CONTROLLING" in request.attributes: self.switch_role(ice_controlling=False) elif "ICE-CONTROLLED" in request.attributes: self.switch_role(ice_controlling=True) return await self.check_start(pair) else: self.check_state(pair, CandidatePair.State.FAILED) self.check_complete(pair) return # check remote address matches if addr != pair.remote_addr: self.__log_info("Check %s failed : source address mismatch", pair) self.check_state(pair, CandidatePair.State.FAILED) self.check_complete(pair) return # success if nominate or pair.remote_nominated: # nominated by agressive nomination or the remote party pair.nominated = True elif self.ice_controlling and pair.component not in self._nominating: # perform regular nomination self.__log_info("Check %s nominating pair", pair) self._nominating.add(pair.component) request = self.build_request(pair, nominate=True) try: await pair.protocol.request( request, pair.remote_addr, integrity_key=self.remote_password.encode("utf8"), ) except stun.TransactionError: self.__log_info("Check %s failed : could not nominate pair", pair) self.check_state(pair, CandidatePair.State.FAILED) self.check_complete(pair) return pair.nominated = True self.check_state(pair, CandidatePair.State.SUCCEEDED) self.check_complete(pair) def check_start_task(self, pair: CandidatePair) -> None: """ Starts a check in a task, unless already started. """ if pair.task is None: pair.task = asyncio.create_task(self.check_start(pair)) def check_state(self, pair: CandidatePair, state: CandidatePair.State) -> None: """ Updates the state of a check. """ self.__log_info("Check %s %s -> %s", pair, pair.state, state) pair.state = state def _emit_event(self, event: ConnectionEvent) -> None: if self._event_waiter is not None: waiter = self._event_waiter self._event_waiter = None waiter.set_result(event) def _find_pair( self, protocol: StunProtocol, remote_candidate: Candidate ) -> Optional[CandidatePair]: """ Find a candidate pair in the check list. """ for pair in self._check_list: if pair.protocol == protocol and pair.remote_candidate == remote_candidate: return pair return None async def get_component_candidates( self, component: int, addresses: list[str], timeout: int = 5 ) -> list[Candidate]: candidates = [] loop = asyncio.get_event_loop() # gather host candidates host_protocols = [] for address in addresses: # create transport try: transport, protocol = await loop.create_datagram_endpoint( lambda: StunProtocol(self), local_addr=(address, 0) ) sock = transport.get_extra_info("socket") if sock is not None: sock.setsockopt( socket.SOL_SOCKET, socket.SO_RCVBUF, turn.UDP_SOCKET_BUFFER_SIZE ) except OSError as exc: self.__log_info("Could not bind to %s - %s", address, exc) continue host_protocols.append(protocol) # add host candidate candidate_address = protocol.transport.get_extra_info("sockname") protocol.local_candidate = Candidate( foundation=candidate_foundation("host", "udp", candidate_address[0]), component=component, transport="udp", priority=candidate_priority(component, "host"), host=candidate_address[0], port=candidate_address[1], type="host", ) if self._transport_policy == TransportPolicy.ALL: candidates.append(protocol.local_candidate) self._protocols += host_protocols tasks: list[asyncio.Task[tuple[Candidate, Optional[StunProtocol]]]] = [] # Query STUN server for server-reflexive candidates (IPv4 only). if self.stun_server: for protocol in host_protocols: if ipaddress.ip_address(protocol.local_candidate.host).version == 4: tasks.append( asyncio.create_task( server_reflexive_candidate(protocol, self.stun_server) ) ) # Connect to TURN server. if self.turn_server: tasks.append( asyncio.create_task( relayed_candidate( component=component, protocol_factory=lambda: StunProtocol(self), turn_server=self.turn_server, turn_username=self.turn_username, turn_password=self.turn_password, turn_ssl=self.turn_ssl, turn_transport=self.turn_transport, ) ) ) # Run tasks in parallel and handle exceptions. if len(tasks): done, pending = await asyncio.wait(tasks, timeout=timeout) for task in done: if task.exception() is None: candidate, protocol = task.result() candidates.append(candidate) if protocol is not None: self._protocols.append(protocol) for task in pending: task.cancel() return candidates def _prune_components(self) -> None: """ Remove components for which the remote party did not provide any candidates. This can only be determined after end-of-candidates. """ seen_components = set(map(lambda x: x.component, self._remote_candidates)) missing_components = self._components - seen_components if missing_components: self.__log_info( "Components %s have no candidate pairs" % missing_components ) self._components = seen_components async def query_consent(self) -> None: """ Periodically check consent (RFC 7675). """ failures = 0 while True: # randomize between 0.8 and 1.2 times CONSENT_INTERVAL await asyncio.sleep(CONSENT_INTERVAL * (0.8 + 0.4 * random.random())) for pair in self._nominated.values(): request = self.build_request(pair, nominate=False) try: await pair.protocol.request( request, pair.remote_addr, integrity_key=self.remote_password.encode("utf8"), retransmissions=0, ) failures = 0 except stun.TransactionError: failures += 1 if failures >= CONSENT_FAILURES: self.__log_info("Consent to send expired") self._query_consent_task = None return await self.close() def data_received(self, data: Optional[bytes], component: Optional[int]) -> None: self._queue.put_nowait((data, component)) def request_received( self, message: stun.Message, addr: tuple[str, int], protocol: StunProtocol, raw_data: bytes, ) -> None: if message.message_method != stun.Method.BINDING: self.respond_error(message, addr, protocol, (400, "Bad Request")) return # authenticate request try: stun.parse_message( raw_data, integrity_key=self.local_password.encode("utf8") ) if self.remote_username is not None: rx_username = "%s:%s" % (self.local_username, self.remote_username) if message.attributes.get("USERNAME") != rx_username: raise ValueError("Wrong username") except ValueError: self.respond_error(message, addr, protocol, (400, "Bad Request")) return # 7.2.1.1. Detecting and Repairing Role Conflicts if self.ice_controlling and "ICE-CONTROLLING" in message.attributes: self.__log_info("Role conflict, expected to be controlling") if self._tie_breaker >= message.attributes["ICE-CONTROLLING"]: self.respond_error(message, addr, protocol, (487, "Role Conflict")) return self.switch_role(ice_controlling=False) elif not self.ice_controlling and "ICE-CONTROLLED" in message.attributes: self.__log_info("Role conflict, expected to be controlled") if self._tie_breaker < message.attributes["ICE-CONTROLLED"]: self.respond_error(message, addr, protocol, (487, "Role Conflict")) return self.switch_role(ice_controlling=True) # send binding response response = stun.Message( message_method=stun.Method.BINDING, message_class=stun.Class.RESPONSE, transaction_id=message.transaction_id, ) response.attributes["XOR-MAPPED-ADDRESS"] = addr response.add_message_integrity(self.local_password.encode("utf8")) protocol.send_stun(response, addr) if not self._check_list and not self._early_checks_done: self._early_checks.append((message, addr, protocol)) else: self.check_incoming(message, addr, protocol) def respond_error( self, request: stun.Message, addr: tuple[str, int], protocol: StunProtocol, error_code: tuple[int, str], ) -> None: response = stun.Message( message_method=request.message_method, message_class=stun.Class.ERROR, transaction_id=request.transaction_id, ) response.attributes["ERROR-CODE"] = error_code response.add_message_integrity(self.local_password.encode("utf8")) protocol.send_stun(response, addr) def sort_check_list(self) -> None: sort_candidate_pairs(self._check_list, self.ice_controlling) def switch_role(self, ice_controlling: bool) -> None: self.__log_info( "Switching to %s role", ice_controlling and "controlling" or "controlled" ) self.ice_controlling = ice_controlling self.sort_check_list() def _unfreeze_initial(self) -> None: # unfreeze first pair for the first component first_pair = None for pair in self._check_list: if pair.component == min(self._components): first_pair = pair break if first_pair is None: return if first_pair.state == CandidatePair.State.FROZEN: self.check_state(first_pair, CandidatePair.State.WAITING) # unfreeze pairs with same component but different foundations seen_foundations = set(first_pair.local_candidate.foundation) for pair in self._check_list: if ( pair.component == first_pair.component and pair.local_candidate.foundation not in seen_foundations and pair.state == CandidatePair.State.FROZEN ): self.check_state(pair, CandidatePair.State.WAITING) seen_foundations.add(pair.local_candidate.foundation) def __log_info(self, msg: str, *args: object) -> None: logger.info("%s " + msg, self, *args) def __repr__(self) -> str: return "Connection(%s)" % self._id aioice-0.10.1/src/aioice/mdns.py000066400000000000000000000146171477667010200164000ustar00rootroot00000000000000import asyncio import re import socket import sys import uuid from typing import Optional, Union, cast import dns.exception import dns.flags import dns.message import dns.name import dns.rdata import dns.rdataclass import dns.rdataset import dns.rdatatype import dns.zone MDNS_ADDRESS = "224.0.0.251" MDNS_PORT = 5353 MDNS_HOSTNAME_RE = re.compile(r"^[a-zA-Z0-9-]{1,63}\.local$") MDNS_RDCLASS = dns.rdataclass.IN | 0x8000 def create_mdns_hostname() -> str: return str(uuid.uuid4()) + ".local" def is_mdns_hostname(name: str) -> bool: return MDNS_HOSTNAME_RE.match(name) is not None class MDnsProtocol(asyncio.DatagramProtocol): def __init__(self, tx_transport: asyncio.DatagramTransport) -> None: self.__closed: asyncio.Future[bool] = asyncio.Future() self.zone = dns.zone.Zone("", relativize=False, rdclass=MDNS_RDCLASS) self.queries: dict[dns.name.Name, set[asyncio.Future[str]]] = {} self.rx_transport: Optional[asyncio.DatagramTransport] = None self.tx_transport = tx_transport def connection_lost(self, exc: Exception) -> None: # abort any outstanding queries for name, futures in list(self.queries.items()): for future in futures: future.set_exception(asyncio.TimeoutError) self.__closed.set_result(True) def connection_made(self, transport: asyncio.BaseTransport) -> None: self.rx_transport = cast(asyncio.DatagramTransport, transport) def datagram_received(self, data: Union[bytes, str], addr: tuple) -> None: # parse message try: message = dns.message.from_wire(cast(bytes, data)) except dns.exception.FormError: return if isinstance(message, dns.message.QueryMessage): # answer question for question in message.question: rdtypes: list[int] = [] if question.rdtype in ( dns.rdatatype.ANY, dns.rdatatype.A, dns.rdatatype.AAAA, ): rdtypes += [dns.rdatatype.A, dns.rdatatype.AAAA] response = dns.message.QueryMessage(id=0) response.flags |= dns.flags.QR response.flags |= dns.flags.AA for rdtype in rdtypes: try: response.answer.append( self.zone.find_rrset(name=question.name, rdtype=rdtype) ) except KeyError: continue if response.answer: self.tx_transport.sendto( response.to_wire(), (MDNS_ADDRESS, MDNS_PORT) ) # handle answer for answer in message.answer: for item in answer: item = item.to_generic() if ( isinstance(item, dns.rdata.GenericRdata) and item.rdclass == MDNS_RDCLASS and item.rdtype in (dns.rdatatype.A, dns.rdatatype.AAAA) ): if item.rdtype == dns.rdatatype.A: result = socket.inet_ntop(socket.AF_INET, item.data) else: result = socket.inet_ntop(socket.AF_INET6, item.data) for future in self.queries.pop(answer.name, []): future.set_result(result) # custom async def close(self) -> None: self.rx_transport.close() self.tx_transport.close() await self.__closed async def publish(self, hostname: str, addr: str) -> None: name = dns.name.from_text(hostname) try: data = socket.inet_pton(socket.AF_INET, addr) rdtype = dns.rdatatype.A except OSError: data = socket.inet_pton(socket.AF_INET6, addr) rdtype = dns.rdatatype.AAAA rdata = dns.rdata.GenericRdata(rdclass=MDNS_RDCLASS, rdtype=rdtype, data=data) self.zone.replace_rdataset(name, dns.rdataset.from_rdata(120, rdata)) async def resolve( self, hostname: str, timeout: Optional[float] = 1.0 ) -> Optional[str]: name = dns.name.from_text(hostname) future: asyncio.Future[str] = asyncio.Future() if name in self.queries: # a query for this name is already pending self.queries[name].add(future) else: # no query for this name is pending, send a request self.queries[name] = set([future]) message = dns.message.make_query(name, rdtype=dns.rdatatype.A) message.id = 0 message.flags = 0 self.tx_transport.sendto(message.to_wire(), (MDNS_ADDRESS, MDNS_PORT)) try: return await asyncio.wait_for(future, timeout=timeout) except asyncio.TimeoutError: return None finally: if name in self.queries: self.queries[name].discard(future) if not self.queries[name]: del self.queries[name] async def create_mdns_protocol() -> MDnsProtocol: """ Using a single socket works fine on Linux, but on OS X we need to use separate sockets for sending and receiving. """ loop = asyncio.get_event_loop() # sender tx_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) tx_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) if hasattr(socket, "SO_REUSEPORT"): tx_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) tx_sock.bind(("", MDNS_PORT)) tx_transport, _ = await loop.create_datagram_endpoint( lambda: asyncio.DatagramProtocol(), sock=tx_sock, ) # receiver rx_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) rx_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) if hasattr(socket, "SO_REUSEPORT"): rx_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) rx_sock.setsockopt( socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, socket.inet_aton(MDNS_ADDRESS) + b"\x00\x00\x00\x00", ) if sys.platform == "win32": rx_sock.bind(("", MDNS_PORT)) else: rx_sock.bind((MDNS_ADDRESS, MDNS_PORT)) _, protocol = await loop.create_datagram_endpoint( lambda: MDnsProtocol(tx_transport=tx_transport), sock=rx_sock, ) return protocol aioice-0.10.1/src/aioice/py.typed000066400000000000000000000000071477667010200165500ustar00rootroot00000000000000Marker aioice-0.10.1/src/aioice/stun.py000066400000000000000000000310261477667010200164210ustar00rootroot00000000000000import asyncio import binascii import enum import hmac import ipaddress from collections.abc import Callable from struct import pack, unpack from typing import Optional, Protocol from .utils import random_transaction_id COOKIE = 0x2112A442 FINGERPRINT_LENGTH = 8 FINGERPRINT_XOR = 0x5354554E HEADER_LENGTH = 20 INTEGRITY_LENGTH = 24 IPV4_PROTOCOL = 1 IPV6_PROTOCOL = 2 RETRY_MAX = 6 RETRY_RTO = 0.5 def set_body_length(data: bytes, length: int) -> bytes: return data[0:2] + pack("!H", length) + data[4:] def message_fingerprint(data: bytes) -> int: check_data = set_body_length(data, len(data) - HEADER_LENGTH + FINGERPRINT_LENGTH) return binascii.crc32(check_data) ^ FINGERPRINT_XOR def message_integrity(data: bytes, key: bytes) -> bytes: check_data = set_body_length(data, len(data) - HEADER_LENGTH + INTEGRITY_LENGTH) return hmac.new(key, check_data, "sha1").digest() def xor_data(data: bytes, transaction_id: bytes) -> bytes: xpad = pack("!HI", COOKIE >> 16, COOKIE) + transaction_id xdata = b"" for i in range(len(data)): xdata += int.to_bytes(data[i] ^ xpad[i], 1, "big", signed=False) return xdata def pack_address( value: tuple[str, int], xor: Callable[[bytes], bytes] = lambda x: x, ) -> bytes: ip_address = ipaddress.ip_address(value[0]) if isinstance(ip_address, ipaddress.IPv4Address): protocol = IPV4_PROTOCOL else: protocol = IPV6_PROTOCOL return pack("!BB", 0, protocol) + xor(pack("!H", value[1]) + ip_address.packed) def pack_bytes(value: bytes) -> bytes: return value def pack_error_code(value: tuple[int, str]) -> bytes: return pack("!HBB", 0, value[0] // 100, value[0] % 100) + value[1].encode("utf8") def pack_none(value: None) -> bytes: return b"" def pack_string(value: str) -> bytes: return value.encode("utf8") def pack_unsigned(value: int) -> bytes: return pack("!I", value) def pack_unsigned_short(value: int) -> bytes: return pack("!H", value) + b"\x00\x00" def pack_unsigned_64(value: int) -> bytes: return pack("!Q", value) def pack_xor_address(value: tuple[str, int], transaction_id: bytes) -> bytes: return pack_address(value, lambda x: xor_data(x, transaction_id)) def unpack_address( data: bytes, xor: Callable[[bytes], bytes] = lambda x: x, ) -> tuple[str, int]: if len(data) < 4: raise ValueError("STUN address length is less than 4 bytes") # The first two bytes are a reserved byte and the protocol family. reserved, protocol = unpack("!BB", data[0:2]) # The remaining data represents a port and an address. port_address = data[2:] if protocol == IPV4_PROTOCOL: # For IPv4 we expect 2 bytes for the port, 4 bytes for the address. if len(port_address) != 6: raise ValueError("STUN address has invalid length for IPv4") port_address = xor(port_address) return ( str(ipaddress.IPv4Address(port_address[2:])), unpack("!H", port_address[0:2])[0], ) elif protocol == IPV6_PROTOCOL: # For IPv6 we expect 2 bytes for the port, 16 bytes for the address. if len(port_address) != 18: raise ValueError("STUN address has invalid length for IPv6") port_address = xor(port_address) return ( str(ipaddress.IPv6Address(port_address[2:])), unpack("!H", port_address[0:2])[0], ) else: raise ValueError("STUN address has unknown protocol") def unpack_xor_address(data: bytes, transaction_id: bytes) -> tuple[str, int]: return unpack_address(data, lambda x: xor_data(x, transaction_id)) def unpack_bytes(data: bytes) -> bytes: return data def unpack_error_code(data: bytes) -> tuple[int, str]: if len(data) < 4: raise ValueError("STUN error code is less than 4 bytes") reserved, code_high, code_low = unpack("!HBB", data[0:4]) reason = data[4:].decode("utf8") return (code_high * 100 + code_low, reason) def unpack_none(data: bytes) -> None: return None def unpack_string(data: bytes) -> str: return data.decode("utf8") def unpack_unsigned(data: bytes) -> int: return unpack("!I", data)[0] def unpack_unsigned_short(data: bytes) -> int: return unpack("!H", data[0:2])[0] def unpack_unsigned_64(data: bytes) -> int: return unpack("!Q", data)[0] AttributeEntry = tuple[int, str, Callable, Callable] ATTRIBUTES: list[AttributeEntry] = [ (0x0001, "MAPPED-ADDRESS", pack_address, unpack_address), (0x0003, "CHANGE-REQUEST", pack_unsigned, unpack_unsigned), (0x0004, "SOURCE-ADDRESS", pack_address, unpack_address), (0x0005, "CHANGED-ADDRESS", pack_address, unpack_address), (0x0006, "USERNAME", pack_string, unpack_string), (0x0008, "MESSAGE-INTEGRITY", pack_bytes, unpack_bytes), (0x0009, "ERROR-CODE", pack_error_code, unpack_error_code), (0x000C, "CHANNEL-NUMBER", pack_unsigned_short, unpack_unsigned_short), (0x000D, "LIFETIME", pack_unsigned, unpack_unsigned), (0x0012, "XOR-PEER-ADDRESS", pack_xor_address, unpack_xor_address), (0x0014, "REALM", pack_string, unpack_string), (0x0015, "NONCE", pack_bytes, unpack_bytes), (0x0016, "XOR-RELAYED-ADDRESS", pack_xor_address, unpack_xor_address), (0x0019, "REQUESTED-TRANSPORT", pack_unsigned, unpack_unsigned), (0x0020, "XOR-MAPPED-ADDRESS", pack_xor_address, unpack_xor_address), (0x0024, "PRIORITY", pack_unsigned, unpack_unsigned), (0x0025, "USE-CANDIDATE", pack_none, unpack_none), (0x8022, "SOFTWARE", pack_string, unpack_string), (0x8028, "FINGERPRINT", pack_unsigned, unpack_unsigned), (0x8029, "ICE-CONTROLLED", pack_unsigned_64, unpack_unsigned_64), (0x802A, "ICE-CONTROLLING", pack_unsigned_64, unpack_unsigned_64), (0x802B, "RESPONSE-ORIGIN", pack_address, unpack_address), (0x802C, "OTHER-ADDRESS", pack_address, unpack_address), ] ATTRIBUTES_BY_TYPE: dict[int, AttributeEntry] = {} ATTRIBUTES_BY_NAME: dict[str, AttributeEntry] = {} for attr in ATTRIBUTES: ATTRIBUTES_BY_TYPE[attr[0]] = attr ATTRIBUTES_BY_NAME[attr[1]] = attr class Class(enum.IntEnum): REQUEST = 0x000 INDICATION = 0x010 RESPONSE = 0x100 ERROR = 0x110 class Method(enum.IntEnum): BINDING = 0x1 SHARED_SECRET = 0x2 ALLOCATE = 0x3 REFRESH = 0x4 SEND = 0x6 DATA = 0x7 CREATE_PERMISSION = 0x8 CHANNEL_BIND = 0x9 class Message: def __init__( self, message_method: Method, message_class: Class, transaction_id: Optional[bytes] = None, attributes: Optional[dict] = None, ) -> None: self.message_method = message_method self.message_class = message_class self.transaction_id = transaction_id or random_transaction_id() self.attributes = attributes or {} def add_message_integrity(self, key: bytes) -> None: """ Add MESSAGE-INTEGRITY and FINGERPRINT attributes to the message. This must be the last step before sending out the message. """ self.attributes.pop("MESSAGE-INTEGRITY", None) self.attributes.pop("FINGERPRINT", None) self.attributes["MESSAGE-INTEGRITY"] = message_integrity(bytes(self), key) self.attributes["FINGERPRINT"] = message_fingerprint(bytes(self)) def __bytes__(self) -> bytes: data = b"" for attr_name, attr_value in self.attributes.items(): attr_type, _, attr_pack, attr_unpack = ATTRIBUTES_BY_NAME[attr_name] if attr_pack == pack_xor_address: v = attr_pack(attr_value, self.transaction_id) else: v = attr_pack(attr_value) attr_len = len(v) pad_len = padding_length(attr_len) data += pack("!HH", attr_type, attr_len) + v + bytes(pad_len) return ( pack( "!HHI12s", self.message_method | self.message_class, len(data), COOKIE, self.transaction_id, ) + data ) def __repr__(self) -> str: return ( f"Message(message_method=Method.{self.message_method.name}, " f"message_class=Class.{self.message_class.name}, " f"transaction_id={repr(self.transaction_id)})" ) class TransactionError(Exception): response: Optional[Message] = None class TransactionFailed(TransactionError): def __init__(self, response: Message) -> None: self.response = response def __str__(self) -> str: out = "STUN transaction failed" if "ERROR-CODE" in self.response.attributes: out += " (%s - %s)" % self.response.attributes["ERROR-CODE"] return out class TransactionTimeout(TransactionError): def __str__(self) -> str: return "STUN transaction timed out" class TransactionSender(Protocol): def send_stun(self, message: Message, addr: tuple[str, int]) -> None: ... class Transaction: def __init__( self, request: Message, addr: tuple[str, int], protocol: TransactionSender, retransmissions: Optional[int] = None, ) -> None: self.__addr = addr self.__future: asyncio.Future[tuple[Message, tuple[str, int]]] = ( asyncio.Future() ) self.__request = request self.__timeout_delay = RETRY_RTO self.__timeout_handle: Optional[asyncio.TimerHandle] = None self.__protocol = protocol self.__tries = 0 self.__tries_max = 1 + ( retransmissions if retransmissions is not None else RETRY_MAX ) def response_received(self, message: Message, addr: tuple[str, int]) -> None: if not self.__future.done(): if message.message_class == Class.RESPONSE: self.__future.set_result((message, addr)) else: self.__future.set_exception(TransactionFailed(message)) async def run(self) -> tuple[Message, tuple[str, int]]: try: self.__retry() return await self.__future finally: if self.__timeout_handle: self.__timeout_handle.cancel() def __retry(self) -> None: if self.__tries >= self.__tries_max: self.__future.set_exception(TransactionTimeout()) return self.__protocol.send_stun(self.__request, self.__addr) loop = asyncio.get_event_loop() self.__timeout_handle = loop.call_later(self.__timeout_delay, self.__retry) self.__timeout_delay *= 2 self.__tries += 1 def padding_length(length: int) -> int: """ STUN message attributes are padded to a 4-byte boundary. """ rest = length % 4 if rest == 0: return 0 else: return 4 - rest def parse_message(data: bytes, integrity_key: Optional[bytes] = None) -> Message: """ Parses a STUN message. If the ``integrity_key`` parameter is given, the message's HMAC will be verified. """ if len(data) < HEADER_LENGTH: raise ValueError("STUN message length is less than 20 bytes") message_type, length, cookie, transaction_id = unpack( "!HHI12s", data[0:HEADER_LENGTH] ) if len(data) != HEADER_LENGTH + length: raise ValueError("STUN message length does not match") attributes = {} pos = HEADER_LENGTH while pos <= len(data) - 4: attr_type, attr_len = unpack("!HH", data[pos : pos + 4]) v = data[pos + 4 : pos + 4 + attr_len] pad_len = padding_length(attr_len) if attr_type in ATTRIBUTES_BY_TYPE: _, attr_name, attr_pack, attr_unpack = ATTRIBUTES_BY_TYPE[attr_type] if attr_unpack == unpack_xor_address: attributes[attr_name] = attr_unpack(v, transaction_id=transaction_id) else: attributes[attr_name] = attr_unpack(v) if attr_name == "FINGERPRINT": if attributes[attr_name] != message_fingerprint(data[0:pos]): raise ValueError("STUN message fingerprint does not match") elif attr_name == "MESSAGE-INTEGRITY": if integrity_key is not None and attributes[ attr_name ] != message_integrity(data[0:pos], integrity_key): raise ValueError("STUN message integrity does not match") pos += 4 + attr_len + pad_len return Message( # An unknown method raises a `ValueError`. message_method=Method(message_type & 0x3EEF), # This cast cannot fail, as all 4 possible classes are defined. message_class=Class(message_type & 0x0110), transaction_id=transaction_id, attributes=attributes, ) aioice-0.10.1/src/aioice/turn.py000066400000000000000000000356131477667010200164260ustar00rootroot00000000000000import asyncio import hashlib import logging import socket import ssl import struct import time from collections.abc import Callable from typing import Any, Optional, TypeVar, Union, cast from . import stun from .utils import random_transaction_id logger = logging.getLogger(__name__) DEFAULT_CHANNEL_REFRESH_TIME = 500 DEFAULT_ALLOCATION_LIFETIME = 600 TCP_TRANSPORT = 0x06000000 UDP_TRANSPORT = 0x11000000 UDP_SOCKET_BUFFER_SIZE = 262144 _ProtocolT = TypeVar("_ProtocolT", bound=asyncio.DatagramProtocol) def is_channel_data(data: bytes) -> bool: return (data[0] & 0xC0) == 0x40 def make_integrity_key(username: str, realm: str, password: str) -> bytes: return hashlib.md5(":".join([username, realm, password]).encode("utf8")).digest() class TurnStreamMixin: datagram_received: Callable[[bytes, Any], None] transport: asyncio.BaseTransport def data_received(self, data: bytes) -> None: if not hasattr(self, "buffer"): self.buffer = b"" self.buffer += data while len(self.buffer) >= 4: _, length = struct.unpack("!HH", self.buffer[0:4]) length += stun.padding_length(length) if is_channel_data(self.buffer): full_length = 4 + length else: full_length = 20 + length if len(self.buffer) < full_length: break addr = self.transport.get_extra_info("peername") self.datagram_received(self.buffer[0:full_length], addr) self.buffer = self.buffer[full_length:] def _padded(self, data: bytes) -> bytes: # TCP and TCP-over-TLS must pad messages to 4-byte boundaries. padding = stun.padding_length(len(data)) if padding: data += bytes(padding) return data class TurnClientMixin: _send: Callable[[bytes], None] def __init__( self, server: tuple[str, int], username: Optional[str], password: Optional[str], lifetime: int, channel_refresh_time: int, ) -> None: self.channel_refresh_at: dict[int, float] = {} self.channel_to_peer: dict[int, tuple[str, int]] = {} self.peer_connect_waiters: dict[ tuple[str, int], list[asyncio.Future[None]] ] = {} self.peer_to_channel: dict[tuple[str, int], int] = {} self.channel_number = 0x4000 self.channel_refresh_time = channel_refresh_time self.integrity_key: Optional[bytes] = None self.lifetime = lifetime self.nonce: Optional[bytes] = None self.password = password self.receiver: Optional[asyncio.DatagramProtocol] = None self.realm: Optional[str] = None self.refresh_task: Optional[asyncio.Task] = None self.relayed_address: Optional[tuple[str, int]] = None self.server = server self.transactions: dict[bytes, stun.Transaction] = {} self.username = username async def channel_bind(self, channel_number: int, addr: tuple[str, int]) -> None: request = stun.Message( message_method=stun.Method.CHANNEL_BIND, message_class=stun.Class.REQUEST ) request.attributes["CHANNEL-NUMBER"] = channel_number request.attributes["XOR-PEER-ADDRESS"] = addr await self.request_with_retry(request) logger.info("TURN channel bound %d %s", channel_number, addr) async def connect(self) -> tuple[str, int]: """ Create a TURN allocation. """ request = stun.Message( message_method=stun.Method.ALLOCATE, message_class=stun.Class.REQUEST ) request.attributes["LIFETIME"] = self.lifetime request.attributes["REQUESTED-TRANSPORT"] = UDP_TRANSPORT response, _ = await self.request_with_retry(request) time_to_expiry = response.attributes["LIFETIME"] self.relayed_address = response.attributes["XOR-RELAYED-ADDRESS"] logger.info( "TURN allocation created %s (expires in %d seconds)", self.relayed_address, time_to_expiry, ) # periodically refresh allocation self.refresh_task = asyncio.create_task(self.refresh(time_to_expiry)) return self.relayed_address def connection_lost(self, exc: Exception) -> None: logger.debug("%s connection_lost(%s)", self, exc) if self.receiver is not None: self.receiver.connection_lost(exc) def connection_made(self, transport: asyncio.BaseTransport) -> None: logger.debug("%s connection_made(%s)", self, transport) self.transport = transport def datagram_received(self, data: Union[bytes, str], addr: tuple[str, int]) -> None: data = cast(bytes, data) # demultiplex channel data if len(data) >= 4 and is_channel_data(data): channel, length = struct.unpack("!HH", data[0:4]) if len(data) >= length + 4 and self.receiver is not None: peer_address = self.channel_to_peer.get(channel) if peer_address: payload = data[4 : 4 + length] self.receiver.datagram_received(payload, peer_address) return try: message = stun.parse_message(data) logger.debug("%s < %s %s", self, addr, message) except ValueError: return if ( message.message_class == stun.Class.RESPONSE or message.message_class == stun.Class.ERROR ) and message.transaction_id in self.transactions: transaction = self.transactions[message.transaction_id] transaction.response_received(message, addr) async def delete(self) -> None: """ Delete the TURN allocation. """ if self.refresh_task: self.refresh_task.cancel() self.refresh_task = None request = stun.Message( message_method=stun.Method.REFRESH, message_class=stun.Class.REQUEST ) request.attributes["LIFETIME"] = 0 try: await self.request_with_retry(request) except stun.TransactionError: # we do not care, we need to shutdown pass logger.info("TURN allocation deleted %s", self.relayed_address) self.transport.close() async def refresh(self, time_to_expiry: int) -> None: """ Periodically refresh the TURN allocation. """ while True: await asyncio.sleep(5 / 6 * time_to_expiry) request = stun.Message( message_method=stun.Method.REFRESH, message_class=stun.Class.REQUEST ) request.attributes["LIFETIME"] = self.lifetime response, _ = await self.request_with_retry(request) time_to_expiry = response.attributes["LIFETIME"] logger.info( "TURN allocation refreshed %s (expires in %d seconds)", self.relayed_address, time_to_expiry, ) async def request( self, request: stun.Message ) -> tuple[stun.Message, tuple[str, int]]: """ Execute a STUN transaction and return the response. """ assert request.transaction_id not in self.transactions if self.integrity_key: self.__add_authentication(request) transaction = stun.Transaction(request, self.server, self) self.transactions[request.transaction_id] = transaction try: return await transaction.run() finally: del self.transactions[request.transaction_id] async def request_with_retry( self, request: stun.Message ) -> tuple[stun.Message, tuple[str, int]]: """ Execute a STUN transaction and return the response. On recoverable errors it will retry the request. """ try: response, addr = await self.request(request) except stun.TransactionFailed as e: error_code = e.response.attributes["ERROR-CODE"][0] if ( "NONCE" in e.response.attributes and self.username is not None and self.password is not None and ( (error_code == 401 and "REALM" in e.response.attributes) or (error_code == 438 and self.realm is not None) ) ): # update long-term credentials self.nonce = e.response.attributes["NONCE"] if error_code == 401: self.realm = e.response.attributes["REALM"] self.integrity_key = make_integrity_key( self.username, self.realm, self.password ) # retry request with authentication request.transaction_id = random_transaction_id() response, addr = await self.request(request) else: raise return response, addr async def send_data(self, data: bytes, addr: tuple[str, int]) -> None: """ Send data to a remote host via the TURN server. """ # if a channel is being bound for the peer, wait if addr in self.peer_connect_waiters: loop = asyncio.get_event_loop() waiter = loop.create_future() self.peer_connect_waiters[addr].append(waiter) await waiter channel = self.peer_to_channel.get(addr) now = time.time() if channel is None: self.peer_connect_waiters[addr] = [] channel = self.channel_number self.channel_number += 1 # bind channel await self.channel_bind(channel, addr) # update state self.channel_refresh_at[channel] = now + self.channel_refresh_time self.channel_to_peer[channel] = addr self.peer_to_channel[addr] = channel # notify waiters for waiter in self.peer_connect_waiters.pop(addr): waiter.set_result(None) elif now > self.channel_refresh_at[channel]: # refresh channel await self.channel_bind(channel, addr) # update state self.channel_refresh_at[channel] = now + self.channel_refresh_time header = struct.pack("!HH", channel, len(data)) self._send(header + data) def send_stun(self, message: stun.Message, addr: tuple[str, int]) -> None: """ Send a STUN message to the TURN server. """ logger.debug("%s > %s %s", self, addr, message) self._send(bytes(message)) def __add_authentication(self, request: stun.Message) -> None: request.attributes["USERNAME"] = self.username request.attributes["NONCE"] = self.nonce request.attributes["REALM"] = self.realm request.add_message_integrity(self.integrity_key) class TurnClientTcpProtocol(TurnClientMixin, TurnStreamMixin, asyncio.Protocol): """ Protocol for handling TURN over TCP. """ transport: asyncio.Transport def _send(self, data: bytes) -> None: self.transport.write(self._padded(data)) def __repr__(self) -> str: return "turn/tcp" class TurnClientUdpProtocol(TurnClientMixin, asyncio.DatagramProtocol): """ Protocol for handling TURN over UDP. """ transport: asyncio.DatagramTransport def _send(self, data: bytes) -> None: self.transport.sendto(data) def __repr__(self) -> str: return "turn/udp" TurnClientProtocol = Union[TurnClientTcpProtocol, TurnClientUdpProtocol] class TurnTransport: """ Behaves like a Datagram transport, but uses a TURN allocation. """ def __init__(self, inner_protocol: TurnClientProtocol) -> None: self.__inner_protocol = inner_protocol self.__relayed_address: Optional[tuple[str, int]] = None def close(self) -> None: """ Close the transport. After the TURN allocation has been deleted, the protocol's `connection_lost()` method will be called with None as its argument. """ asyncio.create_task(self.__inner_protocol.delete()) def get_extra_info(self, name: str, default: Any = None) -> Any: """ Return optional transport information. - `'related_address'`: the related address - `'sockname'`: the relayed address """ if name == "related_address": return self.__inner_protocol.transport.get_extra_info("sockname") elif name == "sockname": return self.__relayed_address return default def sendto(self, data: bytes, addr: tuple[str, int]) -> None: """ Sends the `data` bytes to the remote peer given `addr`. This will bind a TURN channel as necessary. """ asyncio.create_task(self.__inner_protocol.send_data(data, addr)) async def _connect(self, protocol: asyncio.DatagramProtocol) -> None: self.__relayed_address = await self.__inner_protocol.connect() # Once the allocation has succeeded, notify the protocol # and start relaying received data. self.__inner_protocol.receiver = protocol protocol.connection_made(cast(asyncio.DatagramTransport, self)) async def create_turn_endpoint( protocol_factory: Callable[[], _ProtocolT], server_addr: tuple[str, int], username: Optional[str], password: Optional[str], lifetime: int = DEFAULT_ALLOCATION_LIFETIME, channel_refresh_time: int = DEFAULT_CHANNEL_REFRESH_TIME, ssl: Optional[Union[bool, ssl.SSLContext]] = None, transport: str = "udp", ) -> tuple[TurnTransport, _ProtocolT]: """ Create datagram connection relayed over TURN. """ loop = asyncio.get_event_loop() inner_protocol: TurnClientProtocol inner_transport: asyncio.BaseTransport if transport == "tcp": inner_transport, inner_protocol = await loop.create_connection( lambda: TurnClientTcpProtocol( server_addr, username=username, password=password, lifetime=lifetime, channel_refresh_time=channel_refresh_time, ), host=server_addr[0], port=server_addr[1], ssl=ssl, ) else: inner_transport, inner_protocol = await loop.create_datagram_endpoint( lambda: TurnClientUdpProtocol( server_addr, username=username, password=password, lifetime=lifetime, channel_refresh_time=channel_refresh_time, ), remote_addr=server_addr, ) sock = inner_transport.get_extra_info("socket") if sock is not None: sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, UDP_SOCKET_BUFFER_SIZE) try: protocol = protocol_factory() turn_transport = TurnTransport(inner_protocol) await turn_transport._connect(protocol) except Exception: inner_transport.close() raise return turn_transport, protocol aioice-0.10.1/src/aioice/utils.py000066400000000000000000000004101477667010200165610ustar00rootroot00000000000000import os import secrets import string def random_string(length: int) -> str: allchar = string.ascii_letters + string.digits return "".join(secrets.choice(allchar) for x in range(length)) def random_transaction_id() -> bytes: return os.urandom(12) aioice-0.10.1/stubs/000077500000000000000000000000001477667010200141745ustar00rootroot00000000000000aioice-0.10.1/stubs/dns/000077500000000000000000000000001477667010200147605ustar00rootroot00000000000000aioice-0.10.1/stubs/dns/__init__.py000066400000000000000000000000001477667010200170570ustar00rootroot00000000000000aioice-0.10.1/stubs/dns/flags.py000066400000000000000000000001001477667010200164150ustar00rootroot00000000000000import enum class Flag(enum.IntFlag): ... AA: Flag QR: Flag aioice-0.10.1/stubs/dns/message.py000066400000000000000000000007071477667010200167620ustar00rootroot00000000000000from typing import Optional, Union from .name import Name from .rrset import RRset class Message: id: int flags: int answer: list[RRset] question: list[RRset] def __init__(self, id: Optional[int] = None) -> None: ... def to_wire(self) -> bytes: ... ... class QueryMessage(Message): ... def from_wire(wire: bytes) -> Message: ... def make_query(qname: Union[Name, str], rdtype: Union[int, str]) -> QueryMessage: ... aioice-0.10.1/stubs/dns/name.py000066400000000000000000000000701477667010200162470ustar00rootroot00000000000000class Name: ... def from_text(text: str) -> Name: ... aioice-0.10.1/stubs/dns/rdata.py000066400000000000000000000003511477667010200164240ustar00rootroot00000000000000class Rdata: rdclass: int rdtype: int ... def to_generic(self) -> "GenericRdata": ... class GenericRdata(Rdata): data: bytes ... def __init__(self, rdclass: int, rdtype: int, data: bytes) -> None: ... aioice-0.10.1/stubs/dns/rdataclass.py000066400000000000000000000001031477667010200174450ustar00rootroot00000000000000import enum class RdataClass(enum.IntEnum): ... IN: RdataClass aioice-0.10.1/stubs/dns/rdataset.py000066400000000000000000000001531477667010200171400ustar00rootroot00000000000000from .rdata import Rdata class Rdataset: ... def from_rdata(ttl: int, *rdatas: Rdata) -> Rdataset: ... aioice-0.10.1/stubs/dns/rdatatype.py000066400000000000000000000001371477667010200173300ustar00rootroot00000000000000import enum class RdataType(enum.IntEnum): ... A: RdataType AAAA: RdataType ANY: RdataType aioice-0.10.1/stubs/dns/rrset.py000066400000000000000000000002641477667010200164730ustar00rootroot00000000000000from typing import Iterator from .name import Name from .rdata import Rdata class RRset: name: Name rdtype: int def __iter__(self) -> Iterator[Rdata]: ... ... aioice-0.10.1/stubs/dns/zone.py000066400000000000000000000006761477667010200163160ustar00rootroot00000000000000from typing import Union from .name import Name from .rdataclass import IN from .rdataset import Rdataset from .rrset import RRset class Zone: def __init__( self, origin: str, rdclass: int = IN, relativize: bool = False ) -> None: ... def find_rrset(self, name: Union[Name, str], rdtype: int) -> RRset: ... def replace_rdataset( self, name: Union[Name, str], replacement: Rdataset ) -> None: ... ... aioice-0.10.1/tests/000077500000000000000000000000001477667010200141765ustar00rootroot00000000000000aioice-0.10.1/tests/__init__.py000066400000000000000000000000001477667010200162750ustar00rootroot00000000000000aioice-0.10.1/tests/data/000077500000000000000000000000001477667010200151075ustar00rootroot00000000000000aioice-0.10.1/tests/data/binding_request.bin000066400000000000000000000000241477667010200207570ustar00rootroot00000000000000!¤BNvfx3lU7FUBFaioice-0.10.1/tests/data/binding_request_ice_controlled.bin000066400000000000000000000002101477667010200240210ustar00rootroot00000000000000t!¤BwxaNbAdXjwG3AYeZ:sw7YvCSbcVex3bhi$d~ÿ€"FreeSWITCH (-37-987c9b9 64bit)€)L7AIRmaycŠOv@¦k?ê ƒßÞ6È€(ÀŒ6Âaioice-0.10.1/tests/data/binding_request_ice_controlling.bin000066400000000000000000000001601477667010200242120ustar00rootroot00000000000000\!¤BJEwwUxjLWaa2sw7YvCSbcVex3bhi:AYeZÀW €*RzÒàÙ‘%$n~ÿÈ{XìˬÛÀuÔ—­ –Z‚“zµ‡€(PI¯’aioice-0.10.1/tests/data/binding_response.bin000066400000000000000000000001501477667010200211250ustar00rootroot00000000000000T!¤BNvfx3lU7FUBF î,qÚ,Ï>PȈZ€+ –4$a€, —4$a€"Citrix-3.2.4.5 'Marshal West'aioice-0.10.1/tests/echoserver.py000066400000000000000000000020371477667010200167170ustar00rootroot00000000000000import asyncio import contextlib from typing import AsyncGenerator, cast class EchoServerProtocol(asyncio.DatagramProtocol): def connection_made(self, transport: asyncio.BaseTransport) -> None: self.transport = cast(asyncio.DatagramTransport, transport) def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: self.transport.sendto(data, addr) class EchoServer: async def close(self) -> None: self.udp_server.transport.close() async def listen(self, host: str = "127.0.0.1", port: int = 0) -> None: loop = asyncio.get_event_loop() # listen for UDP transport, self.udp_server = await loop.create_datagram_endpoint( EchoServerProtocol, local_addr=(host, port) ) self.udp_address = transport.get_extra_info("sockname") @contextlib.asynccontextmanager async def run_echo_server() -> AsyncGenerator[EchoServer, None]: server = EchoServer() await server.listen() try: yield server finally: await server.close() aioice-0.10.1/tests/test_candidate.py000066400000000000000000000137371477667010200175360ustar00rootroot00000000000000import unittest from aioice import Candidate class CandidateTest(unittest.TestCase): def test_can_pair_ipv4(self) -> None: candidate_a = Candidate.from_sdp( "6815297761 1 udp 659136 1.2.3.4 31102 typ host generation 0" ) candidate_b = Candidate.from_sdp( "6815297761 1 udp 659136 1.2.3.4 12345 typ host generation 0" ) self.assertTrue(candidate_a.can_pair_with(candidate_b)) def test_can_pair_ipv4_case_insensitive(self) -> None: candidate_a = Candidate.from_sdp( "6815297761 1 udp 659136 1.2.3.4 31102 typ host generation 0" ) candidate_b = Candidate.from_sdp( "6815297761 1 UDP 659136 1.2.3.4 12345 typ host generation 0" ) self.assertTrue(candidate_a.can_pair_with(candidate_b)) def test_can_pair_ipv6(self) -> None: candidate_a = Candidate.from_sdp( "6815297761 1 udp 659136 2a02:0db8:85a3:0000:0000:8a2e:0370:7334 31102" " typ host generation 0" ) candidate_b = Candidate.from_sdp( "6815297761 1 udp 659136 2a02:0db8:85a3:0000:0000:8a2e:0370:7334 12345" " typ host generation 0" ) self.assertTrue(candidate_a.can_pair_with(candidate_b)) def test_cannot_pair_ipv4_ipv6(self) -> None: candidate_a = Candidate.from_sdp( "6815297761 1 udp 659136 1.2.3.4 31102 typ host generation 0" ) candidate_b = Candidate.from_sdp( "6815297761 1 udp 659136 2a02:0db8:85a3:0000:0000:8a2e:0370:7334 12345" " typ host generation 0" ) self.assertFalse(candidate_a.can_pair_with(candidate_b)) def test_cannot_pair_different_components(self) -> None: candidate_a = Candidate.from_sdp( "6815297761 1 udp 659136 1.2.3.4 31102 typ host generation 0" ) candidate_b = Candidate.from_sdp( "6815297761 2 udp 659136 1.2.3.4 12345 typ host generation 0" ) self.assertFalse(candidate_a.can_pair_with(candidate_b)) def test_cannot_pair_different_transports(self) -> None: candidate_a = Candidate.from_sdp( "6815297761 1 udp 659136 1.2.3.4 31102 typ host generation 0" ) candidate_b = Candidate.from_sdp( "6815297761 1 tcp 659136 1.2.3.4 12345 typ host generation 0 tcptype active" ) self.assertFalse(candidate_a.can_pair_with(candidate_b)) def test_from_sdp_udp(self) -> None: candidate = Candidate.from_sdp( "6815297761 1 udp 659136 1.2.3.4 31102 typ host generation 0" ) self.assertEqual(candidate.foundation, "6815297761") self.assertEqual(candidate.component, 1) self.assertEqual(candidate.transport, "udp") self.assertEqual(candidate.priority, 659136) self.assertEqual(candidate.host, "1.2.3.4") self.assertEqual(candidate.port, 31102) self.assertEqual(candidate.type, "host") self.assertEqual(candidate.generation, 0) self.assertEqual( candidate.to_sdp(), "6815297761 1 udp 659136 1.2.3.4 31102 typ host generation 0", ) def test_from_sdp_udp_srflx(self) -> None: candidate = Candidate.from_sdp( "1 1 UDP 1686052863 1.2.3.4 42705 typ srflx raddr 192.168.1.101 rport 42705" ) self.assertEqual(candidate.foundation, "1") self.assertEqual(candidate.component, 1) self.assertEqual(candidate.transport, "UDP") self.assertEqual(candidate.priority, 1686052863) self.assertEqual(candidate.host, "1.2.3.4") self.assertEqual(candidate.port, 42705) self.assertEqual(candidate.type, "srflx") self.assertEqual(candidate.related_address, "192.168.1.101") self.assertEqual(candidate.related_port, 42705) self.assertEqual(candidate.generation, None) self.assertEqual( candidate.to_sdp(), "1 1 UDP 1686052863 1.2.3.4 42705 typ srflx raddr 192.168.1.101 " "rport 42705", ) def test_from_sdp_tcp(self) -> None: candidate = Candidate.from_sdp( "1936595596 1 tcp 1518214911 1.2.3.4 9 typ host " "tcptype active generation 0 network-id 1 network-cost 10" ) self.assertEqual(candidate.foundation, "1936595596") self.assertEqual(candidate.component, 1) self.assertEqual(candidate.transport, "tcp") self.assertEqual(candidate.priority, 1518214911) self.assertEqual(candidate.host, "1.2.3.4") self.assertEqual(candidate.port, 9) self.assertEqual(candidate.type, "host") self.assertEqual(candidate.tcptype, "active") self.assertEqual(candidate.generation, 0) self.assertEqual( candidate.to_sdp(), "1936595596 1 tcp 1518214911 1.2.3.4 9 typ host tcptype active " "generation 0", ) def test_from_sdp_no_generation(self) -> None: candidate = Candidate.from_sdp("6815297761 1 udp 659136 1.2.3.4 31102 typ host") self.assertEqual(candidate.foundation, "6815297761") self.assertEqual(candidate.component, 1) self.assertEqual(candidate.transport, "udp") self.assertEqual(candidate.priority, 659136) self.assertEqual(candidate.host, "1.2.3.4") self.assertEqual(candidate.port, 31102) self.assertEqual(candidate.type, "host") self.assertEqual(candidate.generation, None) self.assertEqual( candidate.to_sdp(), "6815297761 1 udp 659136 1.2.3.4 31102 typ host" ) def test_from_sdp_truncated(self) -> None: with self.assertRaises(ValueError): Candidate.from_sdp("6815297761 1 udp 659136 1.2.3.4 31102 typ") def test_repr(self) -> None: candidate = Candidate.from_sdp( "6815297761 1 udp 659136 1.2.3.4 31102 typ host generation 0" ) self.assertEqual( repr(candidate), "Candidate(6815297761 1 udp 659136 1.2.3.4 31102 typ host generation 0)", ) aioice-0.10.1/tests/test_exceptions.py000066400000000000000000000011401477667010200177640ustar00rootroot00000000000000import unittest from aioice import stun class ExceptionTest(unittest.TestCase): def test_transaction_failed(self) -> None: response = stun.Message( message_method=stun.Method.BINDING, message_class=stun.Class.RESPONSE ) response.attributes["ERROR-CODE"] = (487, "Role Conflict") exc = stun.TransactionFailed(response) self.assertEqual(str(exc), "STUN transaction failed (487 - Role Conflict)") def test_transaction_timeout(self) -> None: exc = stun.TransactionTimeout() self.assertEqual(str(exc), "STUN transaction timed out") aioice-0.10.1/tests/test_ice.py000066400000000000000000001306311477667010200163530ustar00rootroot00000000000000import asyncio import functools import os import unittest from collections.abc import Callable, Coroutine from typing import Optional from unittest import mock import ifaddr from aioice import Candidate, TransportPolicy, ice, mdns, stun from .turnserver import run_turn_server from .utils import asynctest, invite_accept RUNNING_ON_CI = os.environ.get("GITHUB_ACTIONS") == "true" async def delay(coro: Callable[[], Coroutine[None, None, None]]) -> None: await asyncio.sleep(1) await coro() class ProtocolMock(ice.StunProtocol): def __init__(self, response_addr: tuple[str, int]) -> None: super().__init__(None) self.local_candidate = Candidate( foundation="some-foundation", component=1, transport="udp", priority=1234, host="1.2.3.4", port=1234, type="host", ) self.response_addr = response_addr self.sent_message: Optional[stun.Message] = None async def request( self, message: stun.Message, addr: tuple[str, int], integrity_key: Optional[bytes] = None, retransmissions: Optional[int] = None, ) -> tuple[stun.Message, tuple[str, int]]: response_message = stun.Message( message_method=message.message_method, message_class=stun.Class.RESPONSE, transaction_id=message.transaction_id, ) return (response_message, self.response_addr) def send_stun(self, message: stun.Message, addr: tuple[str, int]) -> None: self.sent_message = message class IceComponentTest(unittest.TestCase): @asynctest async def test_peer_reflexive(self) -> None: connection = ice.Connection(ice_controlling=True) connection.remote_password = "remote-password" connection.remote_username = "remote-username" protocol = ProtocolMock(response_addr=("2.3.4.5", 2345)) request = stun.Message( message_method=stun.Method.BINDING, message_class=stun.Class.REQUEST ) request.attributes["PRIORITY"] = 456789 connection.check_incoming(request, ("2.3.4.5", 2345), protocol) self.assertIsNone(protocol.sent_message) # check we have discovered a peer-reflexive candidate self.assertEqual(len(connection.remote_candidates), 1) candidate = connection.remote_candidates[0] self.assertEqual(candidate.component, 1) self.assertEqual(candidate.transport, "udp") self.assertEqual(candidate.priority, 456789) self.assertEqual(candidate.host, "2.3.4.5") self.assertEqual(candidate.port, 2345) self.assertEqual(candidate.type, "prflx") self.assertEqual(candidate.generation, None) # check a new pair was formed self.assertEqual(len(connection._check_list), 1) pair = connection._check_list[0] self.assertEqual(pair.protocol, protocol) self.assertEqual(pair.remote_candidate, candidate) # check a triggered check was scheduled self.assertIsNotNone(pair.task) await pair.task @asynctest async def test_request_with_invalid_method(self) -> None: connection = ice.Connection(ice_controlling=True) protocol = ProtocolMock(response_addr=("2.3.4.5", 2345)) request = stun.Message( message_method=stun.Method.ALLOCATE, message_class=stun.Class.REQUEST ) connection.request_received( request, ("2.3.4.5", 2345), protocol, bytes(request) ) self.assertIsNotNone(protocol.sent_message) self.assertEqual(protocol.sent_message.message_method, stun.Method.ALLOCATE) self.assertEqual(protocol.sent_message.message_class, stun.Class.ERROR) self.assertEqual( protocol.sent_message.attributes["ERROR-CODE"], (400, "Bad Request") ) @asynctest async def test_response_with_invalid_address(self) -> None: connection = ice.Connection(ice_controlling=True) connection.remote_password = "remote-password" connection.remote_username = "remote-username" protocol = ProtocolMock(response_addr=("3.4.5.6", 3456)) pair = ice.CandidatePair( protocol, Candidate( foundation="some-foundation", component=1, transport="udp", priority=2345, host="2.3.4.5", port=2345, type="host", ), ) self.assertEqual( repr(pair), "CandidatePair(('1.2.3.4', 1234) -> ('2.3.4.5', 2345))" ) await connection.check_start(pair) self.assertEqual(pair.state, ice.CandidatePair.State.FAILED) class IceConnectionTest(unittest.TestCase): def assertCandidateTypes(self, conn: ice.Connection, expected: set[str]) -> None: types = set([c.type for c in conn.local_candidates]) self.assertEqual(types, expected) def tearDown(self) -> None: ice.CONSENT_FAILURES = 6 ice.CONSENT_INTERVAL = 5 stun.RETRY_MAX = 6 async def connect_and_exchange_data( self, conn_a: ice.Connection, conn_b: ice.Connection ) -> None: try: # connect await asyncio.gather(conn_a.connect(), conn_b.connect()) # send data a -> b await conn_a.send(b"howdee") data = await conn_b.recv() self.assertEqual(data, b"howdee") # send data b -> a await conn_b.send(b"gotcha") data = await conn_a.recv() self.assertEqual(data, b"gotcha") finally: # close await conn_a.close() await conn_b.close() @asynctest async def test_local_username_and_password(self) -> None: # No username or password. connection = ice.Connection(ice_controlling=True) self.assertEqual(len(connection.local_username), 4) self.assertEqual(len(connection.local_password), 22) # Valid username and password. connection = ice.Connection( ice_controlling=True, local_username="test+user", local_password="some+password/that+is/long+enough", ) self.assertEqual(connection.local_username, "test+user") self.assertEqual(connection.local_password, "some+password/that+is/long+enough") # Invalid username. with self.assertRaises(ValueError) as cm: ice.Connection(ice_controlling=True, local_username="a") self.assertEqual(str(cm.exception), "Username must satisfy 4*256ice-char") # Invalid password. with self.assertRaises(ValueError) as cm: ice.Connection(ice_controlling=True, local_password="aaaaaa") self.assertEqual(str(cm.exception), "Password must satisfy 22*256ice-char") @mock.patch("ifaddr.get_adapters") def test_get_host_addresses(self, mock_get_adapters: mock.MagicMock) -> None: mock_get_adapters.return_value = [ ifaddr.Adapter( ips=[ ifaddr.IP(ip="127.0.0.1", network_prefix=8, nice_name="lo"), ifaddr.IP(ip=("::1", 0, 0), network_prefix=128, nice_name="lo"), ], name="lo", nice_name="lo", ), ifaddr.Adapter( ips=[ ifaddr.IP( ip="1.2.3.4", network_prefix=24, nice_name="eth0", ), ifaddr.IP( ip=("2a02:0db8:85a3:0000:0000:8a2e:0370:7334", 0, 0), network_prefix=64, nice_name="eth0", ), ifaddr.IP( ip=("fe80::1234:5678:9abc:def0", 0, 2), network_prefix=64, nice_name="eth0", ), ], name="eth0", nice_name="eth0", ), ] # IPv4 only addresses = ice.get_host_addresses(use_ipv4=True, use_ipv6=False) self.assertEqual(addresses, ["1.2.3.4"]) # IPv6 only addresses = ice.get_host_addresses(use_ipv4=False, use_ipv6=True) self.assertEqual(addresses, ["2a02:0db8:85a3:0000:0000:8a2e:0370:7334"]) # both addresses = ice.get_host_addresses(use_ipv4=True, use_ipv6=True) self.assertEqual( addresses, ["1.2.3.4", "2a02:0db8:85a3:0000:0000:8a2e:0370:7334"] ) @asynctest async def test_close(self) -> None: conn_a = ice.Connection(ice_controlling=True) # close event, _ = await asyncio.gather(conn_a.get_event(), conn_a.close()) self.assertTrue(isinstance(event, ice.ConnectionClosed)) # no more events event = await conn_a.get_event() self.assertIsNone(event) # close again await conn_a.close() @asynctest async def test_connect(self) -> None: conn_a = ice.Connection(ice_controlling=True) conn_b = ice.Connection(ice_controlling=False) # invite / accept await invite_accept(conn_a, conn_b) # we should only have host candidates self.assertCandidateTypes(conn_a, set(["host"])) self.assertCandidateTypes(conn_b, set(["host"])) # there should be a default candidate for component 1 candidate = conn_a.get_default_candidate(1) self.assertIsNotNone(candidate) self.assertEqual(candidate.type, "host") # there should not be a default candidate for component 2 candidate = conn_a.get_default_candidate(2) self.assertIsNone(candidate) await self.connect_and_exchange_data(conn_a, conn_b) @asynctest async def test_connect_close(self) -> None: conn_a = ice.Connection(ice_controlling=True) conn_b = ice.Connection(ice_controlling=False) # invite / accept await invite_accept(conn_a, conn_b) # close while connecting await conn_b.close() done, pending = await asyncio.wait( [ asyncio.create_task(conn_a.connect()), asyncio.create_task(delay(conn_a.close)), ] ) for task in pending: task.cancel() self.assertEqual(len(done), 2) exceptions = [x.exception() for x in done if x.exception()] self.assertEqual(len(exceptions), 1) self.assertTrue(isinstance(exceptions[0], ConnectionError)) @asynctest async def test_connect_early_checks(self) -> None: conn_a = ice.Connection(ice_controlling=True) conn_b = ice.Connection(ice_controlling=False) # invite / accept await invite_accept(conn_a, conn_b) # connect await conn_a.connect() await asyncio.sleep(1) await conn_b.connect() # send data a -> b await conn_a.send(b"howdee") data = await conn_b.recv() self.assertEqual(data, b"howdee") # send data b -> a await conn_b.send(b"gotcha") data = await conn_a.recv() self.assertEqual(data, b"gotcha") # close await conn_a.close() await conn_b.close() @asynctest async def test_connect_early_checks_2(self) -> None: conn_a = ice.Connection(ice_controlling=True) conn_b = ice.Connection(ice_controlling=False) # both sides gather local candidates and exchange credentials await conn_a.gather_candidates() await conn_b.gather_candidates() conn_a.remote_username = conn_b.local_username conn_a.remote_password = conn_b.local_password conn_b.remote_username = conn_a.local_username conn_b.remote_password = conn_a.local_password async def connect_b() -> None: # side B receives offer and connects for candidate in conn_a.local_candidates: await conn_b.add_remote_candidate(candidate) await conn_b.add_remote_candidate(None) await conn_b.connect() # side A receives candidates for candidate in conn_b.local_candidates: await conn_a.add_remote_candidate(candidate) await conn_a.add_remote_candidate(None) # The sequence is: # - side A starts connecting immediately, but has no candidates # - side B receives candidates and connects # - side A receives candidates, and connection completes await asyncio.gather(conn_a.connect(), connect_b()) # send data a -> b await conn_a.send(b"howdee") data = await conn_b.recv() self.assertEqual(data, b"howdee") # send data b -> a await conn_b.send(b"gotcha") data = await conn_a.recv() self.assertEqual(data, b"gotcha") # close await conn_a.close() await conn_b.close() @asynctest async def test_connect_two_components(self) -> None: conn_a = ice.Connection(ice_controlling=True, components=2) conn_b = ice.Connection(ice_controlling=False, components=2) # invite / accept await invite_accept(conn_a, conn_b) # we should only have host candidates self.assertCandidateTypes(conn_a, set(["host"])) self.assertCandidateTypes(conn_b, set(["host"])) # there should be a default candidate for component 1 candidate = conn_a.get_default_candidate(1) self.assertIsNotNone(candidate) self.assertEqual(candidate.type, "host") # there should be a default candidate for component 2 candidate = conn_a.get_default_candidate(2) self.assertIsNotNone(candidate) self.assertEqual(candidate.type, "host") # connect await asyncio.gather(conn_a.connect(), conn_b.connect()) self.assertEqual(conn_a._components, set([1, 2])) self.assertEqual(conn_b._components, set([1, 2])) # send data a -> b (component 1) await conn_a.sendto(b"howdee", 1) data, component = await conn_b.recvfrom() self.assertEqual(data, b"howdee") self.assertEqual(component, 1) # send data b -> a (component 1) await conn_b.sendto(b"gotcha", 1) data, component = await conn_a.recvfrom() self.assertEqual(data, b"gotcha") self.assertEqual(component, 1) # send data a -> b (component 2) await conn_a.sendto(b"howdee 2", 2) data, component = await conn_b.recvfrom() self.assertEqual(data, b"howdee 2") self.assertEqual(component, 2) # send data b -> a (component 2) await conn_b.sendto(b"gotcha 2", 2) data, component = await conn_a.recvfrom() self.assertEqual(data, b"gotcha 2") self.assertEqual(component, 2) # close await conn_a.close() await conn_b.close() @asynctest async def test_connect_two_components_vs_one_component(self) -> None: """ It is possible that some of the local candidates won't get paired with remote candidates, and some of the remote candidates won't get paired with local candidates. This can happen if one agent doesn't include candidates for the all of the components for a media stream. If this happens, the number of components for that media stream is effectively reduced, and considered to be equal to the minimum across both agents of the maximum component ID provided by each agent across all components for the media stream. """ conn_a = ice.Connection(ice_controlling=True, components=2) conn_b = ice.Connection(ice_controlling=False, components=1) # invite / accept await invite_accept(conn_a, conn_b) self.assertTrue(len(conn_a.local_candidates) > 0) for candidate in conn_a.local_candidates: self.assertEqual(candidate.type, "host") # connect await asyncio.gather(conn_a.connect(), conn_b.connect()) self.assertEqual(conn_a._components, set([1])) self.assertEqual(conn_b._components, set([1])) # send data a -> b (component 1) await conn_a.sendto(b"howdee", 1) data, component = await conn_b.recvfrom() self.assertEqual(data, b"howdee") self.assertEqual(component, 1) # send data b -> a (component 1) await conn_b.sendto(b"gotcha", 1) data, component = await conn_a.recvfrom() self.assertEqual(data, b"gotcha") self.assertEqual(component, 1) # close await conn_a.close() await conn_b.close() @asynctest async def test_connect_to_ice_lite(self) -> None: conn_a = ice.Connection(ice_controlling=True) conn_a.remote_is_lite = True conn_b = ice.Connection(ice_controlling=False) # invite / accept await invite_accept(conn_a, conn_b) # we should only have host candidates self.assertCandidateTypes(conn_a, set(["host"])) self.assertCandidateTypes(conn_b, set(["host"])) # there should be a default candidate for component 1 candidate = conn_a.get_default_candidate(1) self.assertIsNotNone(candidate) self.assertEqual(candidate.type, "host") # there should not be a default candidate for component 2 candidate = conn_a.get_default_candidate(2) self.assertIsNone(candidate) await self.connect_and_exchange_data(conn_a, conn_b) @asynctest async def test_connect_to_ice_lite_nomination_fails(self) -> None: def mock_request_received( self: ice.Connection, message: stun.Message, addr: tuple[str, int], protocol: ice.StunProtocol, raw_data: bytes, ) -> None: if "USE-CANDIDATE" in message.attributes: self.respond_error(message, addr, protocol, (500, "Internal Error")) else: self.real_request_received(message, addr, protocol, raw_data) # type: ignore conn_a = ice.Connection(ice_controlling=True) conn_a.remote_is_lite = True conn_b = ice.Connection(ice_controlling=False) conn_b.real_request_received = conn_b.request_received # type: ignore conn_b.request_received = functools.partial(mock_request_received, conn_b) # type: ignore # invite / accept await invite_accept(conn_a, conn_b) # connect with self.assertRaises(ConnectionError) as cm: await asyncio.gather(conn_a.connect(), conn_b.connect()) self.assertEqual(str(cm.exception), "ICE negotiation failed") # close await conn_a.close() await conn_b.close() @unittest.skipIf(RUNNING_ON_CI, "CI lacks ipv6") @asynctest async def test_connect_ipv6(self) -> None: conn_a = ice.Connection(ice_controlling=True, use_ipv4=False, use_ipv6=True) conn_b = ice.Connection(ice_controlling=False, use_ipv4=False, use_ipv6=True) # invite / accept await invite_accept(conn_a, conn_b) self.assertTrue(len(conn_a.local_candidates) > 0) for candidate in conn_a.local_candidates: self.assertEqual(candidate.type, "host") await self.connect_and_exchange_data(conn_a, conn_b) @asynctest async def test_connect_reverse_order(self) -> None: conn_a = ice.Connection(ice_controlling=True) conn_b = ice.Connection(ice_controlling=False) # invite / accept await invite_accept(conn_a, conn_b) # introduce a delay so that B's checks complete before A's await asyncio.gather(delay(conn_a.connect), conn_b.connect()) # send data a -> b await conn_a.send(b"howdee") data = await conn_b.recv() self.assertEqual(data, b"howdee") # send data b -> a await conn_b.send(b"gotcha") data = await conn_a.recv() self.assertEqual(data, b"gotcha") # close await conn_a.close() await conn_b.close() @asynctest async def test_connect_invalid_password(self) -> None: conn_a = ice.Connection(ice_controlling=True) conn_b = ice.Connection(ice_controlling=False) # invite await conn_a.gather_candidates() for candidate in conn_a.local_candidates: await conn_b.add_remote_candidate(candidate) await conn_b.add_remote_candidate(None) conn_b.remote_username = conn_a.local_username conn_b.remote_password = conn_a.local_password # accept await conn_b.gather_candidates() for candidate in conn_b.local_candidates: await conn_a.add_remote_candidate(candidate) await conn_a.add_remote_candidate(None) conn_a.remote_username = conn_b.local_username conn_a.remote_password = "wrong-password" # connect done, pending = await asyncio.wait( [ asyncio.create_task(conn_a.connect()), asyncio.create_task(conn_b.connect()), ], return_when=asyncio.FIRST_EXCEPTION, ) for task in pending: task.cancel() self.assertEqual(len(done), 1) self.assertTrue(isinstance(done.pop().exception(), ConnectionError)) # close await conn_a.close() await conn_b.close() @asynctest async def test_connect_invalid_username(self) -> None: conn_a = ice.Connection(ice_controlling=True) conn_b = ice.Connection(ice_controlling=False) # invite await conn_a.gather_candidates() for candidate in conn_a.local_candidates: await conn_b.add_remote_candidate(candidate) await conn_b.add_remote_candidate(None) conn_b.remote_username = conn_a.local_username conn_b.remote_password = conn_a.local_password # accept await conn_b.gather_candidates() for candidate in conn_b.local_candidates: await conn_a.add_remote_candidate(candidate) await conn_a.add_remote_candidate(None) conn_a.remote_username = "wrong-username" conn_a.remote_password = conn_b.local_password # connect done, pending = await asyncio.wait( [ asyncio.create_task(conn_a.connect()), asyncio.create_task(conn_b.connect()), ] ) for task in pending: task.cancel() self.assertEqual(len(done), 2) self.assertTrue(isinstance(done.pop().exception(), ConnectionError)) self.assertTrue(isinstance(done.pop().exception(), ConnectionError)) # close await conn_a.close() await conn_b.close() @asynctest async def test_connect_no_gather(self) -> None: """ If local candidates gathering was not performed, connect fails. """ conn = ice.Connection(ice_controlling=True) await conn.add_remote_candidate( Candidate.from_sdp( "6815297761 1 udp 659136 1.2.3.4 31102 typ host generation 0" ) ) await conn.add_remote_candidate(None) conn.remote_username = "foo" conn.remote_password = "bar" with self.assertRaises(ConnectionError) as cm: await conn.connect() self.assertEqual( str(cm.exception), "Local candidates gathering was not performed" ) await conn.close() @asynctest async def test_connect_no_local_candidates(self) -> None: """ If local candidates gathering yielded no candidates, connect fails. """ conn = ice.Connection(ice_controlling=True) conn._local_candidates_end = True await conn.add_remote_candidate( Candidate.from_sdp( "6815297761 1 udp 659136 1.2.3.4 31102 typ host generation 0" ) ) await conn.add_remote_candidate(None) conn.remote_username = "foo" conn.remote_password = "bar" with self.assertRaises(ConnectionError) as cm: await conn.connect() self.assertEqual(str(cm.exception), "ICE negotiation failed") await conn.close() @asynctest async def test_connect_no_remote_candidates(self) -> None: """ If no remote candidates were provided, connect fails. """ conn = ice.Connection(ice_controlling=True) await conn.gather_candidates() await conn.add_remote_candidate(None) conn.remote_username = "foo" conn.remote_password = "bar" with self.assertRaises(ConnectionError) as cm: await conn.connect() self.assertEqual(str(cm.exception), "ICE negotiation failed") await conn.close() @asynctest async def test_connect_no_remote_credentials(self) -> None: """ If remote credentials have not been provided, connect fails. """ conn = ice.Connection(ice_controlling=True) await conn.gather_candidates() await conn.add_remote_candidate( Candidate.from_sdp( "6815297761 1 udp 659136 1.2.3.4 31102 typ host generation 0" ) ) await conn.add_remote_candidate(None) with self.assertRaises(ConnectionError) as cm: await conn.connect() self.assertEqual(str(cm.exception), "Remote username or password is missing") await conn.close() @asynctest async def test_connect_role_conflict_both_controlling(self) -> None: conn_a = ice.Connection(ice_controlling=True) conn_b = ice.Connection(ice_controlling=True) # set tie breaker for a deterministic outcome conn_a._tie_breaker = 1 conn_b._tie_breaker = 2 # invite / accept await invite_accept(conn_a, conn_b) # connect await asyncio.gather(conn_a.connect(), conn_b.connect()) self.assertFalse(conn_a.ice_controlling) self.assertTrue(conn_b.ice_controlling) # close await conn_a.close() await conn_b.close() @asynctest async def test_connect_role_conflict_both_controlled(self) -> None: conn_a = ice.Connection(ice_controlling=False) conn_b = ice.Connection(ice_controlling=False) # set tie breaker for a deterministic outcome conn_a._tie_breaker = 1 conn_b._tie_breaker = 2 # invite / accept await invite_accept(conn_a, conn_b) # connect await asyncio.gather(conn_a.connect(), conn_b.connect()) self.assertFalse(conn_a.ice_controlling) self.assertTrue(conn_b.ice_controlling) # close await conn_a.close() await conn_b.close() @asynctest async def test_connect_timeout(self) -> None: # lower STUN retries stun.RETRY_MAX = 1 conn = ice.Connection(ice_controlling=True) await conn.gather_candidates() await conn.add_remote_candidate( Candidate.from_sdp( "6815297761 1 udp 659136 1.2.3.4 31102 typ host generation 0" ) ) await conn.add_remote_candidate(None) conn.remote_username = "foo" conn.remote_password = "bar" with self.assertRaises(ConnectionError) as cm: await conn.connect() self.assertEqual(str(cm.exception), "ICE negotiation failed") await conn.close() @asynctest async def test_connect_with_stun_server(self) -> None: async with run_turn_server() as stun_server: conn_a = ice.Connection( ice_controlling=True, stun_server=stun_server.udp_address ) conn_b = ice.Connection(ice_controlling=False) # invite / accept await invite_accept(conn_a, conn_b) # we whould have both host and server-reflexive candidates self.assertCandidateTypes(conn_a, set(["host", "srflx"])) self.assertCandidateTypes(conn_b, set(["host"])) # the default candidate should be server-reflexive candidate = conn_a.get_default_candidate(1) self.assertIsNotNone(candidate) self.assertEqual(candidate.type, "srflx") self.assertIsNotNone(candidate.related_address) self.assertIsNotNone(candidate.related_port) await self.connect_and_exchange_data(conn_a, conn_b) @asynctest async def test_connect_with_stun_server_dns_lookup_error(self) -> None: conn_a = ice.Connection(ice_controlling=True, stun_server=("invalid.", 1234)) conn_b = ice.Connection(ice_controlling=False) # invite / accept await invite_accept(conn_a, conn_b) # we whould have only host candidates self.assertCandidateTypes(conn_a, set(["host"])) self.assertCandidateTypes(conn_b, set(["host"])) await self.connect_and_exchange_data(conn_a, conn_b) @asynctest async def test_connect_with_stun_server_timeout(self) -> None: async with run_turn_server() as stun_server: # immediately stop turn server await stun_server.close() conn_a = ice.Connection( ice_controlling=True, stun_server=stun_server.udp_address ) conn_b = ice.Connection(ice_controlling=False) # invite / accept await invite_accept(conn_a, conn_b) # we whould have only host candidates self.assertCandidateTypes(conn_a, set(["host"])) self.assertCandidateTypes(conn_b, set(["host"])) await self.connect_and_exchange_data(conn_a, conn_b) @unittest.skipIf(RUNNING_ON_CI, "CI lacks ipv6") @asynctest async def test_connect_with_stun_server_ipv6(self) -> None: async with run_turn_server() as stun_server: conn_a = ice.Connection( ice_controlling=True, stun_server=stun_server.udp_address, use_ipv4=False, use_ipv6=True, ) conn_b = ice.Connection( ice_controlling=False, use_ipv4=False, use_ipv6=True ) # invite / accept await invite_accept(conn_a, conn_b) # we only want host candidates : no STUN for IPv6 self.assertTrue(len(conn_a.local_candidates) > 0) for candidate in conn_a.local_candidates: self.assertEqual(candidate.type, "host") await self.connect_and_exchange_data(conn_a, conn_b) @asynctest async def test_connect_with_turn_server_tcp(self) -> None: async with run_turn_server(users={"foo": "bar"}) as turn_server: # create connections conn_a = ice.Connection( ice_controlling=True, turn_server=turn_server.tcp_address, turn_username="foo", turn_password="bar", turn_transport="tcp", ) conn_b = ice.Connection(ice_controlling=False) # invite / accept await invite_accept(conn_a, conn_b) # we should have both host and relayed candidates self.assertCandidateTypes(conn_a, set(["host", "relay"])) self.assertCandidateTypes(conn_b, set(["host"])) # the default candidate should be relayed candidate = conn_a.get_default_candidate(1) self.assertIsNotNone(candidate) self.assertEqual(candidate.type, "relay") self.assertIsNotNone(candidate.related_address) self.assertIsNotNone(candidate.related_port) await self.connect_and_exchange_data(conn_a, conn_b) @asynctest async def test_connect_with_turn_server_udp(self) -> None: async with run_turn_server(users={"foo": "bar"}) as turn_server: # create connections conn_a = ice.Connection( ice_controlling=True, turn_server=turn_server.udp_address, turn_username="foo", turn_password="bar", ) conn_b = ice.Connection(ice_controlling=False) # invite / accept await invite_accept(conn_a, conn_b) # we should have both host and relayed candidates self.assertCandidateTypes(conn_a, set(["host", "relay"])) self.assertCandidateTypes(conn_b, set(["host"])) # the default candidate should be relayed candidate = conn_a.get_default_candidate(1) self.assertIsNotNone(candidate) self.assertEqual(candidate.type, "relay") self.assertIsNotNone(candidate.related_address) self.assertIsNotNone(candidate.related_port) await self.connect_and_exchange_data(conn_a, conn_b) @asynctest async def test_connect_with_turn_server_udp_auth_failed(self) -> None: async with run_turn_server(users={"foo": "bar"}) as turn_server: # create connections conn_a = ice.Connection( ice_controlling=True, turn_server=turn_server.udp_address, turn_username="foo", turn_password="incorrect", ) conn_b = ice.Connection(ice_controlling=False) # invite / accept await invite_accept(conn_a, conn_b) # we should only have host candidates self.assertCandidateTypes(conn_a, set(["host"])) self.assertCandidateTypes(conn_b, set(["host"])) # the default candidate should be host candidate = conn_a.get_default_candidate(1) self.assertIsNotNone(candidate) self.assertEqual(candidate.type, "host") self.assertIsNone(candidate.related_address) self.assertIsNone(candidate.related_port) await self.connect_and_exchange_data(conn_a, conn_b) @asynctest async def test_connect_with_turn_server_udp_timeout(self) -> None: async with run_turn_server(users={"foo": "bar"}) as turn_server: # immediately stop turn server await turn_server.close() # create connections conn_a = ice.Connection( ice_controlling=True, turn_server=turn_server.udp_address, turn_username="foo", turn_password="bar", ) conn_b = ice.Connection(ice_controlling=False) # invite / accept await invite_accept(conn_a, conn_b) # we should only have host candidates self.assertCandidateTypes(conn_a, set(["host"])) self.assertCandidateTypes(conn_b, set(["host"])) # the default candidate should be host candidate = conn_a.get_default_candidate(1) self.assertIsNotNone(candidate) self.assertEqual(candidate.type, "host") self.assertIsNone(candidate.related_address) self.assertIsNone(candidate.related_port) await self.connect_and_exchange_data(conn_a, conn_b) @asynctest async def test_consent_expired(self) -> None: # lower consent timer ice.CONSENT_FAILURES = 1 ice.CONSENT_INTERVAL = 1 conn_a = ice.Connection(ice_controlling=True) conn_b = ice.Connection(ice_controlling=False) # invite / accept await invite_accept(conn_a, conn_b) # connect await asyncio.gather(conn_a.connect(), conn_b.connect()) self.assertEqual(len(conn_a._nominated), 1) # let consent expire await conn_b.close() await asyncio.sleep(2) self.assertEqual(len(conn_a._nominated), 0) # close await conn_a.close() @asynctest async def test_consent_valid(self) -> None: # lower consent timer ice.CONSENT_FAILURES = 1 ice.CONSENT_INTERVAL = 1 conn_a = ice.Connection(ice_controlling=True) conn_b = ice.Connection(ice_controlling=False) # invite / accept await invite_accept(conn_a, conn_b) # connect await asyncio.gather(conn_a.connect(), conn_b.connect()) self.assertEqual(len(conn_a._nominated), 1) # check consent await asyncio.sleep(2) self.assertEqual(len(conn_a._nominated), 1) # close await conn_a.close() await conn_b.close() @asynctest async def test_set_selected_pair(self) -> None: conn_a = ice.Connection(ice_controlling=True) conn_b = ice.Connection(ice_controlling=False) # invite / accept await invite_accept(conn_a, conn_b) # we should only have host candidates self.assertCandidateTypes(conn_a, set(["host"])) self.assertCandidateTypes(conn_b, set(["host"])) # force selected pair default_a = conn_a.get_default_candidate(1) default_b = conn_a.get_default_candidate(1) conn_a.set_selected_pair(1, default_a.foundation, default_b.foundation) conn_b.set_selected_pair(1, default_b.foundation, default_a.foundation) # send data a -> b await conn_a.send(b"howdee") data = await conn_b.recv() self.assertEqual(data, b"howdee") # send data b -> a await conn_b.send(b"gotcha") data = await conn_a.recv() self.assertEqual(data, b"gotcha") # close await conn_a.close() await conn_b.close() @asynctest async def test_recv_not_connected(self) -> None: conn_a = ice.Connection(ice_controlling=True) with self.assertRaises(ConnectionError) as cm: await conn_a.recv() self.assertEqual(str(cm.exception), "Cannot receive data, not connected") @asynctest async def test_recv_connection_lost(self) -> None: conn_a = ice.Connection(ice_controlling=True) conn_b = ice.Connection(ice_controlling=False) # invite / accept await invite_accept(conn_a, conn_b) # connect await asyncio.gather(conn_a.connect(), conn_b.connect()) # disconnect while receiving with self.assertRaises(ConnectionError) as cm: await asyncio.gather(conn_a.recv(), delay(conn_a.close)) self.assertEqual(str(cm.exception), "Connection lost while receiving data") # close await conn_b.close() @asynctest async def test_send_not_connected(self) -> None: conn_a = ice.Connection(ice_controlling=True) with self.assertRaises(ConnectionError) as cm: await conn_a.send(b"howdee") self.assertEqual(str(cm.exception), "Cannot send data, not connected") @asynctest async def test_add_remote_candidate(self) -> None: conn_a = ice.Connection(ice_controlling=True) remote_candidate = Candidate( foundation="some-foundation", component=1, transport="udp", priority=1234, host="1.2.3.4", port=1234, type="host", ) # add candidate await conn_a.add_remote_candidate(remote_candidate) self.assertEqual(len(conn_a.remote_candidates), 1) self.assertEqual(conn_a.remote_candidates[0].host, "1.2.3.4") self.assertEqual(conn_a._remote_candidates_end, False) # end-of-candidates await conn_a.add_remote_candidate(None) self.assertEqual(len(conn_a.remote_candidates), 1) self.assertEqual(conn_a._remote_candidates_end, True) # try adding another candidate with self.assertRaises(ValueError) as cm: await conn_a.add_remote_candidate(remote_candidate) self.assertEqual( str(cm.exception), "Cannot add remote candidate after end-of-candidates." ) self.assertEqual(len(conn_a.remote_candidates), 1) self.assertEqual(conn_a._remote_candidates_end, True) @asynctest async def test_add_remote_candidate_mdns_bad(self) -> None: """ Add an mDNS candidate which cannot be resolved. """ conn_a = ice.Connection(ice_controlling=True) await conn_a.add_remote_candidate( Candidate( foundation="some-foundation", component=1, transport="udp", priority=1234, host=mdns.create_mdns_hostname(), port=1234, type="host", ) ) self.assertEqual(len(conn_a.remote_candidates), 0) self.assertEqual(conn_a._remote_candidates_end, False) # close await conn_a.close() @asynctest async def test_add_remote_candidate_mdns_good(self) -> None: """ Add an mDNS candidate which can be resolved. """ hostname = mdns.create_mdns_hostname() publisher = await mdns.create_mdns_protocol() await publisher.publish(hostname, "1.2.3.4") conn_a = ice.Connection(ice_controlling=True) await conn_a.add_remote_candidate( Candidate( foundation="some-foundation", component=1, transport="udp", priority=1234, host=hostname, port=1234, type="host", ) ) self.assertEqual(len(conn_a.remote_candidates), 1) self.assertEqual(conn_a.remote_candidates[0].host, "1.2.3.4") self.assertEqual(conn_a._remote_candidates_end, False) # close await conn_a.close() await publisher.close() @asynctest async def test_add_remote_candidate_unknown_type(self) -> None: conn_a = ice.Connection(ice_controlling=True) await conn_a.add_remote_candidate( Candidate( foundation="some-foundation", component=1, transport="udp", priority=1234, host="1.2.3.4", port=1234, type="bogus", ) ) self.assertEqual(len(conn_a.remote_candidates), 0) self.assertEqual(conn_a._remote_candidates_end, False) @mock.patch("asyncio.base_events.BaseEventLoop.create_datagram_endpoint") @asynctest async def test_gather_candidates_oserror(self, mock_create: mock.MagicMock) -> None: exc = OSError() exc.errno = 99 exc.strerror = "Cannot assign requested address" mock_create.side_effect = exc conn = ice.Connection(ice_controlling=True) await conn.gather_candidates() self.assertEqual(conn.local_candidates, []) @asynctest async def test_gather_candidates_relay_only_no_servers(self) -> None: with self.assertRaises(ValueError) as cm: ice.Connection(ice_controlling=True, transport_policy=TransportPolicy.RELAY) self.assertEqual( str(cm.exception), "Relay transport policy requires a STUN and/or TURN server.", ) @asynctest async def test_gather_candidates_relay_only_with_stun_server(self) -> None: async with run_turn_server() as stun_server: conn_a = ice.Connection( ice_controlling=True, stun_server=stun_server.udp_address, transport_policy=TransportPolicy.RELAY, ) conn_b = ice.Connection(ice_controlling=False) # invite / accept await invite_accept(conn_a, conn_b) # we whould only have a server-reflexive candidate in connection a self.assertCandidateTypes(conn_a, set(["srflx"])) # close await conn_a.close() await conn_b.close() @asynctest async def test_gather_candidates_relay_only_with_turn_server(self) -> None: async with run_turn_server(users={"foo": "bar"}) as turn_server: conn_a = ice.Connection( ice_controlling=True, turn_server=turn_server.udp_address, turn_username="foo", turn_password="bar", transport_policy=TransportPolicy.RELAY, ) conn_b = ice.Connection(ice_controlling=False) # invite / accept await invite_accept(conn_a, conn_b) # we whould only have a server-reflexive candidate in connection a self.assertCandidateTypes(conn_a, set(["relay"])) # close await conn_a.close() await conn_b.close() @asynctest async def test_repr(self) -> None: conn = ice.Connection(ice_controlling=True) conn._id = 1 self.assertEqual(repr(conn), "Connection(1)") class StunProtocolTest(unittest.TestCase): @asynctest async def test_error_received(self) -> None: protocol = ice.StunProtocol(None) protocol.error_received(OSError("foo")) @asynctest async def test_repr(self) -> None: protocol = ice.StunProtocol(None) protocol.id = 1 self.assertEqual(repr(protocol), "protocol(1)") aioice-0.10.1/tests/test_ice_trickle.py000066400000000000000000000042761477667010200200750ustar00rootroot00000000000000import asyncio import unittest from aioice import ice from .utils import asynctest class IceTrickleTest(unittest.TestCase): def assertCandidateTypes(self, conn: ice.Connection, expected: set[str]) -> None: types = set([c.type for c in conn.local_candidates]) self.assertEqual(types, expected) @asynctest async def test_connect(self) -> None: conn_a = ice.Connection(ice_controlling=True) conn_b = ice.Connection(ice_controlling=False) # invite await conn_a.gather_candidates() conn_b.remote_username = conn_a.local_username conn_b.remote_password = conn_a.local_password # accept await conn_b.gather_candidates() conn_a.remote_username = conn_b.local_username conn_a.remote_password = conn_b.local_password # we should only have host candidates self.assertCandidateTypes(conn_a, set(["host"])) self.assertCandidateTypes(conn_b, set(["host"])) # there should be a default candidate for component 1 candidate = conn_a.get_default_candidate(1) self.assertIsNotNone(candidate) self.assertEqual(candidate.type, "host") # there should not be a default candidate for component 2 candidate = conn_a.get_default_candidate(2) self.assertIsNone(candidate) async def add_candidates_later(a: ice.Connection, b: ice.Connection) -> None: await asyncio.sleep(0.1) for candidate in b.local_candidates: await a.add_remote_candidate(candidate) await asyncio.sleep(0.1) await a.add_remote_candidate(None) # connect await asyncio.gather( conn_a.connect(), conn_b.connect(), add_candidates_later(conn_a, conn_b), add_candidates_later(conn_b, conn_a), ) # send data a -> b await conn_a.send(b"howdee") data = await conn_b.recv() self.assertEqual(data, b"howdee") # send data b -> a await conn_b.send(b"gotcha") data = await conn_a.recv() self.assertEqual(data, b"gotcha") # close await conn_a.close() await conn_b.close() aioice-0.10.1/tests/test_mdns.py000066400000000000000000000056161477667010200165600ustar00rootroot00000000000000import asyncio import contextlib import unittest from typing import AsyncGenerator from aioice import mdns from .utils import asynctest @contextlib.asynccontextmanager async def querier_and_responder() -> AsyncGenerator[ tuple[mdns.MDnsProtocol, mdns.MDnsProtocol], None ]: querier = await mdns.create_mdns_protocol() responder = await mdns.create_mdns_protocol() try: yield querier, responder finally: await querier.close() await responder.close() class MdnsTest(unittest.TestCase): @asynctest async def test_receive_junk(self) -> None: async with querier_and_responder() as (querier, _): querier.datagram_received(b"junk", None) @asynctest async def test_resolve_bad(self) -> None: hostname = mdns.create_mdns_hostname() async with querier_and_responder() as (querier, _): result = await querier.resolve(hostname) self.assertEqual(result, None) @asynctest async def test_resolve_close(self) -> None: hostname = mdns.create_mdns_hostname() # close the querier while the query is ongoing async with querier_and_responder() as (querier, _): result = await asyncio.gather( querier.resolve(hostname, timeout=None), querier.close() ) self.assertEqual(result, [None, None]) @asynctest async def test_resolve_good_ipv4(self) -> None: hostaddr = "1.2.3.4" hostname = mdns.create_mdns_hostname() async with querier_and_responder() as (querier, responder): await responder.publish(hostname, hostaddr) result = await querier.resolve(hostname) self.assertEqual(result, hostaddr) @asynctest async def test_resolve_good_ipv6(self) -> None: hostaddr = "::ffff:1.2.3.4" hostname = mdns.create_mdns_hostname() async with querier_and_responder() as (querier, responder): await responder.publish(hostname, hostaddr) result = await querier.resolve(hostname) self.assertEqual(result, hostaddr) @asynctest async def test_resolve_simultaneous_bad(self) -> None: hostname = mdns.create_mdns_hostname() async with querier_and_responder() as (querier, _): results = await asyncio.gather( querier.resolve(hostname), querier.resolve(hostname) ) self.assertEqual(results, [None, None]) @asynctest async def test_resolve_simultaneous_good(self) -> None: hostaddr = "1.2.3.4" hostname = mdns.create_mdns_hostname() async with querier_and_responder() as (querier, responder): await responder.publish(hostname, hostaddr) results = await asyncio.gather( querier.resolve(hostname), querier.resolve(hostname) ) self.assertEqual(results, [hostaddr, hostaddr]) aioice-0.10.1/tests/test_stun.py000066400000000000000000000253521477667010200166070ustar00rootroot00000000000000import unittest from binascii import unhexlify from collections import OrderedDict from aioice import stun from .utils import asynctest, read_message class AttributeTest(unittest.TestCase): def test_unpack_error_code(self) -> None: data = unhexlify("00000457526f6c6520436f6e666c696374") code, reason = stun.unpack_error_code(data) self.assertEqual(code, 487) self.assertEqual(reason, "Role Conflict") def test_unpack_error_code_too_short(self) -> None: data = unhexlify("000004") with self.assertRaises(ValueError) as cm: stun.unpack_error_code(data) self.assertEqual(str(cm.exception), "STUN error code is less than 4 bytes") def test_unpack_xor_address_ipv4(self) -> None: transaction_id = unhexlify("b7e7a701bc34d686fa87dfae") address, port = stun.unpack_xor_address( unhexlify("0001a147e112a643"), transaction_id ) self.assertEqual(address, "192.0.2.1") self.assertEqual(port, 32853) def test_unpack_xor_address_ipv4_too_long(self) -> None: transaction_id = unhexlify("b7e7a701bc34d686fa87dfae") with self.assertRaises(ValueError) as cm: stun.unpack_xor_address( # Use 21 bytes for port + address, to check we don't try # XOR'ing this data against the 20 bytes key. unhexlify("0001a147e112a643000000000000000000000000000000"), transaction_id, ) self.assertEqual(str(cm.exception), "STUN address has invalid length for IPv4") def test_unpack_xor_address_ipv4_too_short(self) -> None: transaction_id = unhexlify("b7e7a701bc34d686fa87dfae") with self.assertRaises(ValueError) as cm: stun.unpack_xor_address(unhexlify("0001a147e112a6"), transaction_id) self.assertEqual(str(cm.exception), "STUN address has invalid length for IPv4") def test_unpack_xor_address_ipv6(self) -> None: transaction_id = unhexlify("b7e7a701bc34d686fa87dfae") address, port = stun.unpack_xor_address( unhexlify("0002a1470113a9faa5d3f179bc25f4b5bed2b9d9"), transaction_id ) self.assertEqual(address, "2001:db8:1234:5678:11:2233:4455:6677") self.assertEqual(port, 32853) def test_unpack_xor_address_ipv6_too_long(self) -> None: transaction_id = unhexlify("b7e7a701bc34d686fa87dfae") with self.assertRaises(ValueError) as cm: stun.unpack_xor_address( # Use 21 bytes for port + address, to check we don't try # XOR'ing this data against the 20 bytes key. unhexlify("0002a1470113a9faa5d3f179bc25f4b5bed2b900000000"), transaction_id, ) self.assertEqual(str(cm.exception), "STUN address has invalid length for IPv6") def test_unpack_xor_address_ipv6_too_short(self) -> None: transaction_id = unhexlify("b7e7a701bc34d686fa87dfae") with self.assertRaises(ValueError) as cm: stun.unpack_xor_address( unhexlify("0002a1470113a9faa5d3f179bc25f4b5bed2b9"), transaction_id ) self.assertEqual(str(cm.exception), "STUN address has invalid length for IPv6") def test_unpack_xor_address_too_short(self) -> None: transaction_id = unhexlify("b7e7a701bc34d686fa87dfae") with self.assertRaises(ValueError) as cm: stun.unpack_xor_address(unhexlify("0001"), transaction_id) self.assertEqual(str(cm.exception), "STUN address length is less than 4 bytes") def test_unpack_xor_address_unknown_protocol(self) -> None: transaction_id = unhexlify("b7e7a701bc34d686fa87dfae") with self.assertRaises(ValueError) as cm: stun.unpack_xor_address(unhexlify("0003a147e112a643"), transaction_id) self.assertEqual(str(cm.exception), "STUN address has unknown protocol") def test_pack_error_code(self) -> None: data = stun.pack_error_code((487, "Role Conflict")) self.assertEqual(data, unhexlify("00000457526f6c6520436f6e666c696374")) def test_pack_xor_address_ipv4(self) -> None: transaction_id = unhexlify("b7e7a701bc34d686fa87dfae") data = stun.pack_xor_address(("192.0.2.1", 32853), transaction_id) self.assertEqual(data, unhexlify("0001a147e112a643")) def test_pack_xor_address_ipv6(self) -> None: transaction_id = unhexlify("b7e7a701bc34d686fa87dfae") data = stun.pack_xor_address( ("2001:db8:1234:5678:11:2233:4455:6677", 32853), transaction_id ) self.assertEqual(data, unhexlify("0002a1470113a9faa5d3f179bc25f4b5bed2b9d9")) def test_pack_xor_address_unknown_protocol(self) -> None: transaction_id = unhexlify("b7e7a701bc34d686fa87dfae") with self.assertRaises(ValueError) as cm: stun.pack_xor_address(("foo", 32853), transaction_id) self.assertEqual( str(cm.exception), "'foo' does not appear to be an IPv4 or IPv6 address" ) class MessageTest(unittest.TestCase): def test_binding_request(self) -> None: data = read_message("binding_request.bin") message = stun.parse_message(data) self.assertEqual(message.message_method, stun.Method.BINDING) self.assertEqual(message.message_class, stun.Class.REQUEST) self.assertEqual(message.transaction_id, b"Nvfx3lU7FUBF") self.assertEqual(message.attributes, OrderedDict()) self.assertEqual(bytes(message), data) self.assertEqual( repr(message), "Message(message_method=Method.BINDING, message_class=Class.REQUEST, " "transaction_id=b'Nvfx3lU7FUBF')", ) def test_binding_request_ice_controlled(self) -> None: data = read_message("binding_request_ice_controlled.bin") message = stun.parse_message(data) self.assertEqual(message.message_method, stun.Method.BINDING) self.assertEqual(message.message_class, stun.Class.REQUEST) self.assertEqual(message.transaction_id, b"wxaNbAdXjwG3") self.assertEqual( message.attributes, OrderedDict( [ ("USERNAME", "AYeZ:sw7YvCSbcVex3bhi"), ("PRIORITY", 1685987071), ("SOFTWARE", "FreeSWITCH (-37-987c9b9 64bit)"), ("ICE-CONTROLLED", 5491930053772927353), ( "MESSAGE-INTEGRITY", unhexlify("1963108a4f764015a66b3fea0b1883dfde1436c8"), ), ("FINGERPRINT", 3230414530), ] ), ) self.assertEqual(bytes(message), data) def test_binding_request_ice_controlled_bad_fingerprint(self) -> None: data = read_message("binding_request_ice_controlled.bin")[0:-1] + b"z" with self.assertRaises(ValueError) as cm: stun.parse_message(data) self.assertEqual(str(cm.exception), "STUN message fingerprint does not match") def test_binding_request_ice_controlled_bad_integrity(self) -> None: data = read_message("binding_request_ice_controlled.bin") with self.assertRaises(ValueError) as cm: stun.parse_message(data, integrity_key=b"bogus-key") self.assertEqual(str(cm.exception), "STUN message integrity does not match") def test_binding_request_ice_controlling(self) -> None: data = read_message("binding_request_ice_controlling.bin") message = stun.parse_message(data) self.assertEqual(message.message_method, stun.Method.BINDING) self.assertEqual(message.message_class, stun.Class.REQUEST) self.assertEqual(message.transaction_id, b"JEwwUxjLWaa2") self.assertEqual( message.attributes, OrderedDict( [ ("USERNAME", "sw7YvCSbcVex3bhi:AYeZ"), ("ICE-CONTROLLING", 5943294521425135761), ("USE-CANDIDATE", None), ("PRIORITY", 1853759231), ( "MESSAGE-INTEGRITY", unhexlify("c87b58eccbacdbc075d497ad0c965a82937ab587"), ), ("FINGERPRINT", 1347006354), ] ), ) def test_binding_response(self) -> None: data = read_message("binding_response.bin") message = stun.parse_message(data) self.assertEqual(message.message_method, stun.Method.BINDING) self.assertEqual(message.message_class, stun.Class.RESPONSE) self.assertEqual(message.transaction_id, b"Nvfx3lU7FUBF") self.assertEqual( message.attributes, OrderedDict( [ ("XOR-MAPPED-ADDRESS", ("80.200.136.90", 53054)), ("MAPPED-ADDRESS", ("80.200.136.90", 53054)), ("RESPONSE-ORIGIN", ("52.17.36.97", 3478)), ("OTHER-ADDRESS", ("52.17.36.97", 3479)), ("SOFTWARE", "Citrix-3.2.4.5 'Marshal West'"), ] ), ) self.assertEqual(bytes(message), data) def test_message_body_length_mismatch(self) -> None: data = read_message("binding_response.bin") + b"123" with self.assertRaises(ValueError) as cm: stun.parse_message(data) self.assertEqual(str(cm.exception), "STUN message length does not match") def test_message_shorter_than_header(self) -> None: with self.assertRaises(ValueError) as cm: stun.parse_message(b"123") self.assertEqual(str(cm.exception), "STUN message length is less than 20 bytes") def test_message_with_unknown_method(self) -> None: with self.assertRaises(ValueError) as cm: stun.parse_message(bytes(20)) self.assertEqual(str(cm.exception), "0 is not a valid Method") class TransactionTest(unittest.TestCase): def setUp(self) -> None: stun.RETRY_MAX = 0 stun.RETRY_RTO = 0 def tearDown(self) -> None: stun.RETRY_MAX = 6 stun.RETRY_RTO = 0.5 @asynctest async def test_timeout(self) -> None: class DummyProtocol: def send_stun( self, message: stun.Message, address: tuple[str, int] ) -> None: pass request = stun.Message( message_method=stun.Method.BINDING, message_class=stun.Class.REQUEST ) transaction = stun.Transaction(request, ("127.0.0.1", 1234), DummyProtocol()) # timeout with self.assertRaises(stun.TransactionTimeout): await transaction.run() # receive response after timeout response = stun.Message( message_method=stun.Method.BINDING, message_class=stun.Class.RESPONSE ) transaction.response_received(response, ("127.0.0.1", 1234)) aioice-0.10.1/tests/test_turn.py000066400000000000000000000230771477667010200166100ustar00rootroot00000000000000import asyncio import ssl import unittest from typing import Any, Optional from aioice import stun, turn from .echoserver import run_echo_server from .turnserver import run_turn_server from .utils import asynctest, read_message class DummyClientProtocol(asyncio.DatagramProtocol): def __init__(self) -> None: self.received: list[tuple[bytes, tuple[str, int]]] = [] def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: self.received.append((data, addr)) class TurnClientTcpProtocolTest(unittest.TestCase): def setUp(self) -> None: class MockTransport(asyncio.BaseTransport): def get_extra_info(self, name: str, default: Any = None) -> Any: return ("1.2.3.4", 1234) self.protocol = turn.TurnClientTcpProtocol( ("1.2.3.4", 1234), username="foo", password="bar", lifetime=turn.DEFAULT_ALLOCATION_LIFETIME, channel_refresh_time=turn.DEFAULT_CHANNEL_REFRESH_TIME, ) self.protocol.connection_made(MockTransport()) def test_receive_stun_fragmented(self) -> None: data = read_message("binding_request.bin") self.protocol.data_received(data[0:10]) self.protocol.data_received(data[10:]) def test_receive_junk(self) -> None: self.protocol.data_received(b"\x00" * 20) def test_repr(self) -> None: self.assertEqual(repr(self.protocol), "turn/tcp") class TurnClientUdpProtocolTest(unittest.TestCase): def setUp(self) -> None: self.protocol = turn.TurnClientUdpProtocol( ("1.2.3.4", 1234), username="foo", password="bar", lifetime=turn.DEFAULT_ALLOCATION_LIFETIME, channel_refresh_time=turn.DEFAULT_CHANNEL_REFRESH_TIME, ) def test_receive_junk(self) -> None: self.protocol.datagram_received(b"\x00" * 20, ("1.2.3.4", 1234)) def test_repr(self) -> None: self.assertEqual(repr(self.protocol), "turn/udp") class TurnTest(unittest.TestCase): @asynctest async def test_tcp_transport(self) -> None: await self._test_transport("tcp", "tcp_address") @asynctest async def test_tls_transport(self) -> None: ssl_context = ssl.SSLContext(protocol=ssl.PROTOCOL_TLS_CLIENT) ssl_context.check_hostname = False ssl_context.verify_mode = ssl.CERT_NONE await self._test_transport("tcp", "tls_address", ssl=ssl_context) @asynctest async def test_udp_transport(self) -> None: await self._test_transport("udp", "udp_address") async def _test_transport( self, transport: str, server_addr_attr: str, ssl: Optional[ssl.SSLContext] = None, ) -> None: await self._test_transport_ok( transport=transport, server_addr_attr=server_addr_attr, ssl=ssl ) await self._test_transport_ok_multi( transport=transport, server_addr_attr=server_addr_attr, ssl=ssl ) await self._test_transport_allocate_failure( transport=transport, server_addr_attr=server_addr_attr, ssl=ssl ) await self._test_transport_delete_failure( transport=transport, server_addr_attr=server_addr_attr, ssl=ssl ) async def _test_transport_ok( self, *, transport: str, server_addr_attr: str, ssl: Optional[ssl.SSLContext] ) -> None: async with run_turn_server(users={"foo": "bar"}) as turn_server: turn_transport, protocol = await turn.create_turn_endpoint( DummyClientProtocol, server_addr=getattr(turn_server, server_addr_attr), username="foo", password="bar", channel_refresh_time=5, lifetime=6, ssl=ssl, transport=transport, ) self.assertIsNone(turn_transport.get_extra_info("peername")) self.assertIsNotNone(turn_transport.get_extra_info("sockname")) async with run_echo_server() as echo_server: # bind channel, send ping, expect pong turn_transport.sendto(b"ping", echo_server.udp_address) await asyncio.sleep(1) self.assertEqual( protocol.received, [(b"ping", echo_server.udp_address)] ) # wait some more to allow allocation refresh protocol.received.clear() await asyncio.sleep(5) # refresh channel, send ping, expect pong turn_transport.sendto(b"ping", echo_server.udp_address) await asyncio.sleep(1) self.assertEqual( protocol.received, [(b"ping", echo_server.udp_address)] ) # close turn_transport.close() await asyncio.sleep(0) async def _test_transport_ok_multi( self, *, transport: str, server_addr_attr: str, ssl: Optional[ssl.SSLContext] ) -> None: async with run_turn_server(users={"foo": "bar"}) as turn_server: turn_transport, protocol = await turn.create_turn_endpoint( DummyClientProtocol, server_addr=getattr(turn_server, server_addr_attr), username="foo", password="bar", channel_refresh_time=5, lifetime=6, ssl=ssl, transport=transport, ) self.assertIsNone(turn_transport.get_extra_info("peername")) self.assertIsNotNone(turn_transport.get_extra_info("sockname")) # Bind channel, send ping, expect pong. # # We use different lengths to trigger both padded an unpadded # ChannelData messages over TCP. async with run_echo_server() as echo_server1: async with run_echo_server() as echo_server2: turn_transport.sendto( b"ping", echo_server1.udp_address ) # never padded turn_transport.sendto(b"ping11", echo_server1.udp_address) turn_transport.sendto(b"ping20", echo_server2.udp_address) turn_transport.sendto(b"ping21", echo_server2.udp_address) await asyncio.sleep(1) self.assertEqual( sorted(protocol.received), [ (b"ping", echo_server1.udp_address), (b"ping11", echo_server1.udp_address), (b"ping20", echo_server2.udp_address), (b"ping21", echo_server2.udp_address), ], ) # close turn_transport.close() await asyncio.sleep(0) async def _test_transport_allocate_failure( self, *, transport: str, server_addr_attr: str, ssl: Optional[ssl.SSLContext] ) -> None: async with run_turn_server(users={"foo": "bar"}) as turn_server: # Invalid username. with self.assertRaises(stun.TransactionFailed) as cm: await turn.create_turn_endpoint( DummyClientProtocol, server_addr=getattr(turn_server, server_addr_attr), username="unknown", password="bar", ssl=ssl, transport=transport, ) self.assertEqual( str(cm.exception), "STUN transaction failed (401 - Unauthorized)" ) # Invalid password. with self.assertRaises(stun.TransactionFailed) as cm: await turn.create_turn_endpoint( DummyClientProtocol, server_addr=getattr(turn_server, server_addr_attr), username="foo", password="wrong", ssl=ssl, transport=transport, ) self.assertEqual( str(cm.exception), "STUN transaction failed (401 - Unauthorized)" ) # make the server reject the ALLOCATE request turn_server.simulated_failure = (403, "Forbidden") with self.assertRaises(stun.TransactionFailed) as cm: await turn.create_turn_endpoint( DummyClientProtocol, server_addr=getattr(turn_server, server_addr_attr), username="foo", password="bar", ssl=ssl, transport=transport, ) self.assertEqual( str(cm.exception), "STUN transaction failed (403 - Forbidden)" ) async def _test_transport_delete_failure( self, *, transport: str, server_addr_attr: str, ssl: Optional[ssl.SSLContext] ) -> None: async with run_turn_server(users={"foo": "bar"}) as turn_server: turn_transport, protocol = await turn.create_turn_endpoint( DummyClientProtocol, server_addr=getattr(turn_server, server_addr_attr), username="foo", password="bar", ssl=ssl, transport=transport, ) self.assertIsNone(turn_transport.get_extra_info("peername")) self.assertIsNotNone(turn_transport.get_extra_info("sockname")) # make the server reject the final REFRESH request turn_server.simulated_failure = (403, "Forbidden") # close client turn_transport.close() await asyncio.sleep(0) aioice-0.10.1/tests/turnserver.crt000066400000000000000000000017111477667010200171270ustar00rootroot00000000000000-----BEGIN CERTIFICATE----- MIICnTCCAYUCAgPoMA0GCSqGSIb3DQEBBQUAMBQxEjAQBgNVBAMMCWxvY2FsaG9z dDAeFw0xOTAxMDgxMzEwNDZaFw0yOTAxMDUxMzEwNDZaMBQxEjAQBgNVBAMMCWxv Y2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAM25BZIhOj+m RyQ/phgypejd+08Movgs5quH/ipnzPIBTRjIDVtT4Mf8RIeNkf7Ss7RTyF76Ey+u 1OR1xZ5D8Q4NF96CY45/y7zfzReSaLfdRPN1iLhi9NAno02Wdwd75U+KsANqY2oT IycMeihYCuK2B62v7Pt0L7PVbr5tFH40iNSacOrV2hiOrUV3oPN4fPES2g05h9CN XAL2e1IGTWZy94XimDoeGZCbkb5BBUZtkUcWm7MjAzrB9I/XRHm+gL1Cdwa2Vy1X u4A+Auw0SnSOMP4Wxga2asEIaXDTRGMm1cYj139PqcXip9mLDyIqOA/Lq1gjNYNa VX38FuYcsyUCAwEAATANBgkqhkiG9w0BAQUFAAOCAQEAvAMw/nwYbsWgQ2p+WcZU 54WtnfN3zibC7ficNTqYH/0UEZuuVxDpPMjrmg9r5e6v5Tyzzwl5bPxjLsGvVmVU jqjhWu5JdlYFUN8AHS0YghREFs8PLm4lBU17W0H1ii+5QBv7nyK/odVhvvwVXI6Z g4Es6HYM9BKAukrCB0IdrMiWXkucSYLRW+hELKiLbvwvnwJ65wlttXQAmoQcZ5Lw DhjSMCOa++D+jGez8THjOdAgUOuhtgHuLW39DFd/LdEwwwRs5XlWBA0Buuf/0j/6 w1Ew8HUKCY+4EDmqVFZza5LG+WtdNwGqErOxeVlTxpobJvihxW9xVjs8ksabG1Q9 Rw== -----END CERTIFICATE----- aioice-0.10.1/tests/turnserver.key000066400000000000000000000032501477667010200171270ustar00rootroot00000000000000-----BEGIN PRIVATE KEY----- MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDNuQWSITo/pkck P6YYMqXo3ftPDKL4LOarh/4qZ8zyAU0YyA1bU+DH/ESHjZH+0rO0U8he+hMvrtTk dcWeQ/EODRfegmOOf8u8380Xkmi33UTzdYi4YvTQJ6NNlncHe+VPirADamNqEyMn DHooWAritgetr+z7dC+z1W6+bRR+NIjUmnDq1doYjq1Fd6DzeHzxEtoNOYfQjVwC 9ntSBk1mcveF4pg6HhmQm5G+QQVGbZFHFpuzIwM6wfSP10R5voC9QncGtlctV7uA PgLsNEp0jjD+FsYGtmrBCGlw00RjJtXGI9d/T6nF4qfZiw8iKjgPy6tYIzWDWlV9 /BbmHLMlAgMBAAECggEAUpNL2yYfPWE++RvbTac21UwVDdvipn9Pb9a8fMUBjLpc +e+C/P+kIGHwGAEJcyGcJGvk58q1XNRue+2SDz7ySVOUGyp3T8GYRA4JQsbv5a2Z eafZ4zlFTzA56nDVAloG53Chyh0wHmnkGE530i3U4L90QZF2LFCsvSCUvTcHnMg9 HoD+Y2iGM3jVwx8e1/nD5+kKbSw79xmdSKgYkq8V2IULr162LNxADok307ExXSCS jxNtDJW6uRj2ATqJ0mqeOfeKfTN6pzLLsZWb4Bcuf59snwedLSjM19NyXB2qCOEe tDdL0StuL/1Lr3s4vZFapYe4DtswGhjxqTS2LoCGAQKBgQDwUeYR3iIwMDgBz4xD /sBV6ufs997bZT05HDYeF2jT5rBMxKmRdvo2vzJc/oyJs9byhPCObCrDjne+Afjk MH7HYA6TvvVF+Qo92OyifrSB/OFR51Lk/ULK4y/gbKLeuDbt2Zfu0qf5oNPM/v4F ZZoKmKgYhoMh00QWoGMjSHO5ZQKBgQDbJT2x+oOm1RlKR/VFLj8riu/LIy+mD6or Z3fGFt1PErL/QpdPjxdhte+YoBY3gazomvgjCXpPD3VLQt8bAdWBxXgzCI6tpKta ddlfIGoYZSqe/fd5XkDTTUUjZy3JoCmoTh2hPWQZNoWGgw4JyYtURDg1Y8M81w/E Q+SDjd5WwQKBgQCkFoeM86tMU+Ap/Fi9pJgXEgnB140nKH0hHY4mBb3h0cXW5QES /bXi47GzpWq4Kz884GCQHnMki4ZfCmGzDRnDcGcDooM+f8jqac9JNFJz3wLKNbR3 /iU4+t6Z0hNzFz0KMmR3AQcIfzOe6QzxCmqfiZRdCptG4UXAXUrTsIizsQKBgGfj zsy6Q4Fq0vN5C5jBZOcila2Kv8MM+BJdmdWJ717WMY97pTntTxteYfjMI9wqmKsp FGufyaEDZgrI5/Xot6wuzl37N5CwWR+ocOV8+28XPs5i/dhGy5qgrh8rgfRs/nKw nbFb5kFhrIlpRdVz+552PONqqRsFpY7Y1NNdBUPBAoGBAIAIN1g+ePSoeTyZbUqL +xDysvaTz2PdHqIBU34WvB7glzhMyFt9NtJ2RHw2RpczR1QSWV5hYr4EcopAW+Nh FFffl37ZkVDTiEpglmbh3hd8y53vkGqrpMfcNlhwne5xAwmUPIWqxRJmfGULirIB Bv5b54OGgeMM/f9ddXEH2vU7 -----END PRIVATE KEY----- aioice-0.10.1/tests/turnserver.py000066400000000000000000000375041477667010200170000ustar00rootroot00000000000000import argparse import asyncio import contextlib import logging import os import ssl import struct import time from collections.abc import Callable from typing import AsyncGenerator, Optional, cast from aioice import stun from aioice.ice import get_host_addresses from aioice.turn import ( DEFAULT_ALLOCATION_LIFETIME, UDP_TRANSPORT, TurnStreamMixin, is_channel_data, make_integrity_key, ) from aioice.utils import random_string logger = logging.getLogger("turn") CHANNEL_RANGE = range(0x4000, 0x7FFF) ROOT = os.path.dirname(__file__) CERT_FILE = os.path.join(ROOT, "turnserver.crt") KEY_FILE = os.path.join(ROOT, "turnserver.key") Address = tuple[str, int] def create_self_signed_cert(name: str = "localhost") -> None: from OpenSSL import crypto # create key pair key = crypto.PKey() key.generate_key(crypto.TYPE_RSA, 2048) # create self-signed certificate cert = crypto.X509() cert.get_subject().CN = name cert.set_serial_number(1000) cert.gmtime_adj_notBefore(0) cert.gmtime_adj_notAfter(10 * 365 * 86400) cert.set_issuer(cert.get_subject()) cert.set_pubkey(key) cert.sign(key, "sha1") with open(CERT_FILE, "wb") as fp: fp.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert)) with open(KEY_FILE, "wb") as fp: fp.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, key)) class Allocation(asyncio.DatagramProtocol): def __init__( self, client_address: Address, client_protocol: "TurnServerMixin", expiry: float, username: str, ) -> None: self.channel_to_peer: dict[int, Address] = {} self.peer_to_channel: dict[Address, int] = {} self.client_address = client_address self.client_protocol = client_protocol self.expiry = expiry self.username = username def connection_made(self, transport: asyncio.BaseTransport) -> None: self.relayed_address = transport.get_extra_info("sockname") self.transport = cast(asyncio.DatagramTransport, transport) def datagram_received(self, data: bytes, addr: Address) -> None: """ Relay data from peer to client. """ channel = self.peer_to_channel.get(addr) if channel: self.client_protocol._send( struct.pack("!HH", channel, len(data)) + data, self.client_address ) AllocationKey = tuple["TurnServerMixin", Address] class TurnServerMixin: _send: Callable[[bytes, Address], None] def __init__(self, server: "TurnServer") -> None: self.server = server def connection_made(self, transport: asyncio.BaseTransport) -> None: self.transport = transport def datagram_received(self, data: bytes, addr: Address) -> None: # demultiplex channel data if len(data) >= 4 and is_channel_data(data): channel, length = struct.unpack("!HH", data[0:4]) allocation = self.server.allocations.get((self, addr)) if len(data) >= length + 4 and allocation: peer_address = allocation.channel_to_peer.get(channel) if peer_address: payload = data[4 : 4 + length] allocation.transport.sendto(payload, peer_address) return try: message = stun.parse_message(data) except ValueError: return logger.debug("< %s %s", addr, message) assert message.message_class == stun.Class.REQUEST response: Optional[stun.Message] = None if message.message_method == stun.Method.BINDING: response = self.handle_binding(message, addr) self.send_stun(response, addr) return # Generate failure for test purposes. if self.server.simulated_failure: response = self.error_response(message, *self.server.simulated_failure) self.server.simulated_failure = None self.send_stun(response, addr) return # Check authentication. # See RFC 5389 - 10.2.2. Receiving a Request integrity_key = b"" if "MESSAGE-INTEGRITY" not in message.attributes: # Message is missing MESSAGE-INTEGRITY. response = self.error_response(message, 401, "Unauthorized") elif ( "USERNAME" not in message.attributes or "REALM" not in message.attributes or "NONCE" not in message.attributes ): # Message is missing USERNAME, REALM or NONCE. response = self.error_response( message, 400, "Missing USERNAME, REALM or NONCE attribute" ) elif message.attributes["USERNAME"] not in self.server.users: # The USERNAME is unknown. response = self.error_response(message, 401, "Unauthorized") else: username = message.attributes["USERNAME"] password = self.server.users[username] integrity_key = make_integrity_key(username, self.server.realm, password) try: stun.parse_message(data, integrity_key=integrity_key) except ValueError: # The password does not match. response = self.error_response(message, 401, "Unauthorized") if response is not None: self.send_stun(response, addr) return if message.message_method == stun.Method.ALLOCATE: asyncio.create_task(self.handle_allocate(message, addr, integrity_key)) return elif message.message_method == stun.Method.REFRESH: response = self.handle_refresh(message, addr) elif message.message_method == stun.Method.CHANNEL_BIND: response = self.handle_channel_bind(message, addr) else: response = self.error_response( message, 400, "Unsupported STUN request method" ) response.add_message_integrity(integrity_key) self.send_stun(response, addr) async def handle_allocate( self, message: stun.Message, addr: Address, integrity_key: bytes ) -> None: key = (self, addr) if key in self.server.allocations: response = self.error_response(message, 437, "Allocation already exists") elif "REQUESTED-TRANSPORT" not in message.attributes: response = self.error_response( message, 400, "Missing REQUESTED-TRANSPORT attribute" ) elif message.attributes["REQUESTED-TRANSPORT"] != UDP_TRANSPORT: response = self.error_response( message, 442, "Unsupported transport protocol" ) else: lifetime = message.attributes.get("LIFETIME", DEFAULT_ALLOCATION_LIFETIME) lifetime = min(lifetime, self.server.maximum_lifetime) # create allocation loop = asyncio.get_event_loop() _, allocation = await loop.create_datagram_endpoint( lambda: Allocation( client_address=addr, client_protocol=self, expiry=time.time() + lifetime, username=message.attributes["USERNAME"], ), local_addr=("127.0.0.1", 0), ) self.server.allocations[key] = allocation logger.info("Allocation created %s", allocation.relayed_address) # build response response = stun.Message( message_method=message.message_method, message_class=stun.Class.RESPONSE, transaction_id=message.transaction_id, ) response.attributes["LIFETIME"] = lifetime response.attributes["XOR-MAPPED-ADDRESS"] = addr response.attributes["XOR-RELAYED-ADDRESS"] = allocation.relayed_address # send response response.add_message_integrity(integrity_key) self.send_stun(response, addr) def handle_binding(self, message: stun.Message, addr: Address) -> stun.Message: response = stun.Message( message_method=message.message_method, message_class=stun.Class.RESPONSE, transaction_id=message.transaction_id, ) response.attributes["XOR-MAPPED-ADDRESS"] = addr return response def handle_channel_bind(self, message: stun.Message, addr: Address) -> stun.Message: try: key = (self, addr) allocation = self.server.allocations[key] except KeyError: return self.error_response(message, 437, "Allocation does not exist") if message.attributes["USERNAME"] != allocation.username: return self.error_response(message, 441, "Wrong credentials") for attr in ["CHANNEL-NUMBER", "XOR-PEER-ADDRESS"]: if attr not in message.attributes: return self.error_response(message, 400, "Missing %s attribute" % attr) channel: int = message.attributes["CHANNEL-NUMBER"] peer_address: Address = message.attributes["XOR-PEER-ADDRESS"] if channel not in CHANNEL_RANGE: return self.error_response( message, 400, "Channel number is outside valid range" ) if allocation.channel_to_peer.get(channel) not in [None, peer_address]: return self.error_response( message, 400, "Channel is already bound to another peer" ) if allocation.peer_to_channel.get(peer_address) not in [None, channel]: return self.error_response( message, 400, "Peer is already bound to another channel" ) # register channel allocation.channel_to_peer[channel] = peer_address allocation.peer_to_channel[peer_address] = channel # build response response = stun.Message( message_method=message.message_method, message_class=stun.Class.RESPONSE, transaction_id=message.transaction_id, ) return response def handle_refresh( self, message: stun.Message, addr: tuple[str, int] ) -> stun.Message: try: key = (self, addr) allocation = self.server.allocations[key] except KeyError: return self.error_response(message, 437, "Allocation does not exist") if message.attributes["USERNAME"] != allocation.username: return self.error_response(message, 441, "Wrong credentials") if "LIFETIME" not in message.attributes: return self.error_response(message, 400, "Missing LIFETIME attribute") # refresh allocation lifetime = min(message.attributes["LIFETIME"], self.server.maximum_lifetime) if lifetime: logger.info("Allocation refreshed %s", allocation.relayed_address) allocation.expiry = time.time() + lifetime else: logger.info("Allocation deleted %s", allocation.relayed_address) self.server._remove_allocation(key) # build response response = stun.Message( message_method=message.message_method, message_class=stun.Class.RESPONSE, transaction_id=message.transaction_id, ) response.attributes["LIFETIME"] = lifetime return response def error_response( self, request: stun.Message, code: int, message: str ) -> stun.Message: """ Build an error response for the given request. """ response = stun.Message( message_method=request.message_method, message_class=stun.Class.ERROR, transaction_id=request.transaction_id, ) response.attributes["ERROR-CODE"] = (code, message) if code == 401: response.attributes["NONCE"] = random_string(16).encode("ascii") response.attributes["REALM"] = self.server.realm return response def send_stun(self, message: stun.Message, addr: Address) -> None: logger.debug("> %s %s", addr, message) self._send(bytes(message), addr) class TurnServerTcpProtocol(TurnServerMixin, TurnStreamMixin, asyncio.Protocol): transport: asyncio.Transport def _send(self, data: bytes, addr: Address) -> None: self.transport.write(self._padded(data)) class TurnServerUdpProtocol(TurnServerMixin, asyncio.DatagramProtocol): transport: asyncio.DatagramTransport def _send(self, data: bytes, addr: Address) -> None: self.transport.sendto(data, addr) class TurnServer: """ STUN / TURN server. """ def __init__(self, realm: str = "test", users: dict[str, str] = {}) -> None: self.allocations: dict[AllocationKey, Allocation] = {} self.maximum_lifetime = 3600 self.realm = realm self.simulated_failure: Optional[tuple[int, str]] = None self.users = users self._expire_task: Optional[asyncio.Task[None]] = None async def close(self) -> None: # stop expiry loop if self._expire_task is not None: self._expire_task.cancel() # close allocations for key in list(self.allocations.keys()): self._remove_allocation(key) # shutdown servers self.tcp_server.close() self.tls_server.close() self.udp_server.transport.close() await asyncio.gather( self.tcp_server.wait_closed(), self.tls_server.wait_closed() ) async def listen(self, port: int = 0, tls_port: int = 0) -> None: loop = asyncio.get_event_loop() hostaddr = get_host_addresses(use_ipv4=True, use_ipv6=False)[0] # listen for TCP self.tcp_server = await loop.create_server( lambda: TurnServerTcpProtocol(server=self), host=hostaddr, port=port ) self.tcp_address = self.tcp_server.sockets[0].getsockname() logger.info("Listening for TCP on %s", self.tcp_address) # listen for UDP transport, self.udp_server = await loop.create_datagram_endpoint( lambda: TurnServerUdpProtocol(server=self), local_addr=(hostaddr, port) ) self.udp_address = transport.get_extra_info("sockname") logger.info("Listening for UDP on %s", self.udp_address) # listen for TLS ssl_context = ssl.SSLContext(protocol=ssl.PROTOCOL_TLS_SERVER) ssl_context.load_cert_chain(CERT_FILE, KEY_FILE) self.tls_server = await loop.create_server( lambda: TurnServerTcpProtocol(server=self), host=hostaddr, port=tls_port, ssl=ssl_context, ) self.tls_address = self.tls_server.sockets[0].getsockname() logger.info("Listening for TLS on %s", self.tls_address) # start expiry loop self._expire_task = asyncio.create_task(self._expire_allocations()) async def _expire_allocations(self) -> None: while True: now = time.time() for key, allocation in list(self.allocations.items()): if allocation.expiry < now: logger.info("Allocation expired %s", allocation.relayed_address) self._remove_allocation(key) await asyncio.sleep(1) def _remove_allocation(self, key: AllocationKey) -> None: allocation = self.allocations.pop(key) allocation.transport.close() @contextlib.asynccontextmanager async def run_turn_server( users: dict[str, str] = {}, ) -> AsyncGenerator[TurnServer, None]: server = TurnServer(users=users) await server.listen() try: yield server finally: await server.close() if __name__ == "__main__": parser = argparse.ArgumentParser(description="STUN / TURN server") parser.add_argument("--verbose", "-v", action="count") args = parser.parse_args() if args.verbose: logging.basicConfig(level=logging.DEBUG) srv = TurnServer(users={"foo": "bar"}) loop = asyncio.get_event_loop() loop.run_until_complete(srv.listen(port=3478, tls_port=5349)) loop.run_forever() aioice-0.10.1/tests/utils.py000066400000000000000000000026301477667010200157110ustar00rootroot00000000000000import asyncio import functools import logging import os import sys from collections.abc import Callable, Coroutine if sys.version_info >= (3, 10): from typing import ParamSpec else: from typing_extensions import ParamSpec from aioice import ice P = ParamSpec("P") def asynctest( coro: Callable[P, Coroutine[None, None, None]], ) -> Callable[P, None]: @functools.wraps(coro) def wrap(*args: P.args, **kwargs: P.kwargs) -> None: asyncio.run(coro(*args, **kwargs)) return wrap async def invite_accept(conn_a: ice.Connection, conn_b: ice.Connection) -> None: # invite await conn_a.gather_candidates() for candidate in conn_a.local_candidates: await conn_b.add_remote_candidate(candidate) await conn_b.add_remote_candidate(None) conn_b.remote_username = conn_a.local_username conn_b.remote_password = conn_a.local_password # accept await conn_b.gather_candidates() for candidate in conn_b.local_candidates: await conn_a.add_remote_candidate(candidate) await conn_a.add_remote_candidate(None) conn_a.remote_username = conn_b.local_username conn_a.remote_password = conn_b.local_password def read_message(name: str) -> bytes: path = os.path.join(os.path.dirname(__file__), "data", name) with open(path, "rb") as fp: return fp.read() if os.environ.get("AIOICE_DEBUG"): logging.basicConfig(level=logging.DEBUG)