├── .github └── workflows │ └── python-package.yml ├── .gitignore ├── EchoWarp.ico ├── README.md ├── echowarp ├── __init__.py ├── auth_and_heartbeat │ ├── __init__.py │ ├── transport_base.py │ ├── transport_client.py │ └── transport_server.py ├── logging_config.py ├── main.py ├── models │ ├── __init__.py │ ├── audio_device.py │ ├── ban_list.py │ ├── default_values_and_options.py │ ├── json_message.py │ ├── net_interfaces_info.py │ └── options_data_creater.py ├── services │ ├── __init__.py │ └── crypto_manager.py ├── settings.py ├── start_modes │ ├── __init__.py │ ├── args_mode.py │ ├── config_parser.py │ └── interactive_mode.py └── streamer │ ├── __init__.py │ ├── audio_client.py │ └── audio_server.py ├── requirements.txt ├── setup.py └── version.py /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | release: 8 | types: 9 | - created 10 | 11 | jobs: 12 | get_version: 13 | name: Get version of package 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v2 17 | 18 | - name: Extract version from Python script 19 | id: version 20 | run: | 21 | VERSION=$(python -c 'from version import __version__; print(__version__)') 22 | echo "VERSION=$VERSION" >> $GITHUB_ENV 23 | 24 | - name: Set output 25 | id: set_output 26 | run: | 27 | echo "::set-output name=version::$VERSION" 28 | 29 | - name: Check if version exists on PyPI 30 | run: | 31 | RESPONSE=$(curl -s https://pypi.org/pypi/echowarp/json) 32 | EXISTS=$(echo $RESPONSE | jq --arg VERSION "$VERSION" '.releases | has($VERSION)') 33 | if [[ "$EXISTS" == "true" ]]; then 34 | echo "Version $VERSION already exists on PyPI, stopping job." 35 | exit 1 36 | fi 37 | 38 | outputs: 39 | version: ${{ steps.set_output.outputs.version }} 40 | 41 | build: 42 | needs: get_version 43 | name: Build and package 44 | runs-on: ${{ matrix.os }} 45 | strategy: 46 | matrix: 47 | os: [ ubuntu-latest, macos-latest, windows-latest ] 48 | steps: 49 | - uses: actions/checkout@v2 50 | 51 | - name: Set up Python 52 | uses: actions/setup-python@v2 53 | with: 54 | python-version: '3.x' 55 | 56 | - name: Install system dependencies on Ubuntu 57 | if: matrix.os == 'ubuntu-latest' 58 | run: sudo apt-get install -y portaudio19-dev 59 | 60 | - name: Install system dependencies on macOS 61 | if: matrix.os == 'macos-latest' 62 | run: brew install portaudio 63 | 64 | - name: Install dependencies 65 | run: | 66 | python -m pip install --upgrade pip 67 | pip install setuptools wheel twine pyinstaller 68 | pip install -r requirements.txt 69 | 70 | - name: Build dist on Ubuntu 71 | if: matrix.os == 'ubuntu-latest' 72 | run: | 73 | python setup.py sdist bdist_wheel 74 | 75 | - name: Build binary with PyInstaller 76 | run: > 77 | pyinstaller 78 | --noconfirm 79 | --paths=./echowarp/ 80 | --onefile 81 | --name=EchoWarp-${{ needs.get_version.outputs.version }}-${{ matrix.os }} 82 | --icon EchoWarp.ico 83 | ./echowarp/main.py 84 | 85 | - name: Upload WHL for Ubuntu 86 | if: matrix.os == 'ubuntu-latest' 87 | uses: actions/upload-artifact@v2 88 | with: 89 | name: echowarp-${{ needs.get_version.outputs.version }}-py3-none-any.whl 90 | path: dist/echowarp-${{ needs.get_version.outputs.version }}-py3-none-any.whl 91 | 92 | - name: Upload Binaries for each OS 93 | uses: actions/upload-artifact@v2 94 | with: 95 | name: EchoWarp-${{ needs.get_version.outputs.version }}-${{ matrix.os }} 96 | path: dist/EchoWarp-${{ needs.get_version.outputs.version }}-${{ matrix.os }}${{ matrix.os == 'windows-latest' && '.exe' || '' }} 97 | 98 | release: 99 | needs: [ build, get_version ] 100 | name: Create Release and Publish to PyPI 101 | runs-on: ubuntu-latest 102 | steps: 103 | - uses: actions/checkout@v2 104 | 105 | - name: Download all artifacts 106 | uses: actions/download-artifact@v2 107 | with: 108 | path: dist 109 | 110 | - name: Create and Push Tag 111 | run: | 112 | git config --local user.email "action@github.com" 113 | git config --local user.name "GitHub Action" 114 | git tag -a ${{ needs.get_version.outputs.version }} -m "Release ${{ needs.get_version.outputs.version }}" 115 | git push origin ${{ needs.get_version.outputs.version }} 116 | 117 | - name: Create Release 118 | uses: softprops/action-gh-release@v1 119 | with: 120 | tag_name: ${{ needs.get_version.outputs.version }} 121 | name: Release ${{ needs.get_version.outputs.version }} 122 | files: dist/**/* 123 | env: 124 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 125 | 126 | publish-to-pypi: 127 | needs: [ release, get_version ] 128 | runs-on: ubuntu-latest 129 | steps: 130 | - uses: actions/checkout@v2 131 | 132 | - name: Download all artifacts 133 | uses: actions/download-artifact@v2 134 | with: 135 | path: dist 136 | 137 | - name: Set up Python 138 | uses: actions/setup-python@v2 139 | with: 140 | python-version: '3.x' 141 | 142 | - name: Publish to PyPI 143 | run: | 144 | pip install twine 145 | twine upload dist/**/*.whl 146 | env: 147 | TWINE_USERNAME: __token__ 148 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | .idea/ 3 | .venv/ 4 | build 5 | dist 6 | main.spec 7 | *.conf 8 | *.log 9 | *.txt -------------------------------------------------------------------------------- /EchoWarp.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lHumaNl/EchoWarp/62dbb95aea201e9e874fc83720ff93d61f6c74f7/EchoWarp.ico -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EchoWarp 2 | 3 | ![EchoWarp_logo](EchoWarp.ico) 4 | 5 | EchoWarp is a versatile network audio streaming tool designed to transmit audio streams between a server and a client 6 | over a network. It supports both audio input and output devices, adapting to various audio streaming scenarios. EchoWarp 7 | can operate in either server or client mode, with robust configuration options available via command-line arguments or 8 | an interactive setup. 9 | 10 | ## Key Features 11 | 12 | - **Cross-Platform Compatibility**: Works seamlessly on Windows, macOS, and Linux. 13 | - **Flexible Audio Device Selection**: Choose specific input or output devices for audio streaming. 14 | - **Real-Time Audio Streaming**: Utilizes UDP for transmitting audio data and TCP for control signals (on one port). 15 | - **Robust Encryption and Integrity Checks**: Supports AES encryption and SHA-256 hashing to secure data streams. 16 | - **Automatic Reconnection**: Implements heartbeat and authentication mechanisms to handle reconnections and ensure 17 | continuous streaming. 18 | - **Configurable through CLI and Interactive Modes**: Offers easy setup through an interactive mode or scriptable CLI 19 | options. 20 | - **Ban List Management**: Server maintains a ban list to block clients after a specified number of failed connection 21 | attempts. 22 | 23 | ## Important Notes 24 | 25 | ### Correct Usage 26 | 27 | #### Running the Utility: 28 | 29 | - The utility must be run both on the client and the server. 30 | - **Start the Server First**: The server should be started first. If the client cannot connect to the server, it will 31 | throw an error. 32 | 33 | #### Server and Client Roles: 34 | 35 | - **Server**: The server is the host where the audio stream will be captured (e.g., the microphone on the host with 36 | Moonlight). 37 | - **Client**: The client is the host where the captured audio stream from the server will be transmitted over the 38 | network (e.g., the host with Sunshine/Nvidia GameStream). 39 | 40 | ### Interactive Mode 41 | 42 | - In interactive mode (where values are offered for selection), you do not need to manually enter default values. Simply 43 | leave the input field empty and press Enter, and the default value will be automatically accepted. 44 | 45 | ### Firewall and Ports 46 | 47 | - Ensure that your firewall settings allow traffic through the necessary ports: 48 | - **Port**: Used for both audio streaming (UDP) and authentication/configuration exchange and connection status 49 | checking (TCP). Default is 4415. 50 | 51 | ### VPN Considerations 52 | 53 | - If you are using a VPN, make sure it is configured to allow traffic through the necessary ports. 54 | - Verify that the server IP address specified on the client is correct and accessible through the VPN. 55 | - If you encounter connection issues, try disabling the VPN to see if the problem persists. 56 | 57 | ### Common Issues and Solutions 58 | 59 | #### `[WinError 10049]` 60 | 61 | - This error indicates that the specified address is not available on the interface. Possible solutions: 62 | - Check if the IP address specified is correct and reachable. 63 | - Ensure that the VPN is configured correctly and does not block the necessary port. 64 | - Verify that the firewall allows traffic on the specified port. 65 | 66 | #### `timed out` 67 | 68 | - This error indicates that the connection attempt to the specified address timed out. Possible solutions: 69 | - Ensure the server is running and accessible from the client. 70 | - Verify that the server IP address and port specified on the client are correct. 71 | - Check if the server's firewall allows incoming connections on the specified port. 72 | - Ensure that the VPN, if used, is properly configured to allow traffic through the necessary port. 73 | - Verify network stability and connectivity between the client and the server. 74 | - If the server has a dynamic IP address, ensure it hasn't changed since the last connection attempt. 75 | 76 | #### `403 Forbidden (Client is banned)` 77 | 78 | - This error indicates that the client has been banned by the server. Possible solutions: 79 | - Verify that the client is not making repeated failed connection attempts, which could lead to a ban. 80 | - Check the server's ban list to see if the client's IP address is listed and remove it if necessary. 81 | - Ensure that the client's configuration matches the server's requirements to avoid being banned. 82 | 83 | #### `401 Unauthorized (Invalid password)` 84 | 85 | - This error indicates that the password provided by the client is invalid. Possible solutions: 86 | - Ensure that the correct password is being used. 87 | - Verify that the password matches the one configured on the server. 88 | - Check for any typos or encoding issues in the password. 89 | 90 | #### `409 Conflict (Client version mismatch)` 91 | 92 | - This error indicates a version mismatch between the client and server. Possible solutions: 93 | - Ensure that both the client and server are running compatible versions of EchoWarp. 94 | - Update the client or server to the latest version to ensure compatibility. 95 | - Verify that the version specified in the client configuration matches the server's version. 96 | 97 | ## Prerequisites 98 | 99 | - **Operating System**: Windows, Linux, or macOS. 100 | - **Python Version**: Python 3.6 or later. 101 | - **Dependencies**: Includes libraries like PyAudio, and Cryptography. 102 | 103 | ## PyPi Repository 104 | 105 | EchoWarp is also available as a package on the Python Package Index (PyPi), which simplifies the installation process 106 | and manages dependencies automatically. This is the recommended method if you wish to include EchoWarp in your Python 107 | project. 108 | 109 | ### Features of Installing via PyPi: 110 | 111 | - **Automatic Dependency Management**: All required libraries, such as PyAudio and Cryptography, are automatically 112 | installed. 113 | - **Easy Updates**: Simplifies the process of obtaining the latest version of EchoWarp with a simple pip command. 114 | - **Isolation from System Python**: Installing via a virtual environment prevents any conflicts with system-wide Python 115 | packages. 116 | 117 | ### Installation with PyPi (pip) 118 | 119 | To install EchoWarp using pip, follow these steps: 120 | 121 | 1. Set up a Python virtual environment (optional but recommended): 122 | 123 | ```bash 124 | python -m venv .venv 125 | source .venv/bin/activate # On Windows, use `.\.venv\Scripts\activate` 126 | ``` 127 | 128 | 2. Install EchoWarp using pip: 129 | 130 | ```bash 131 | pip install echowarp 132 | ``` 133 | 134 | ### Updating EchoWarp 135 | 136 | To update to the latest version of EchoWarp, simply run: 137 | 138 | ```bash 139 | pip install --upgrade echowarp 140 | ``` 141 | 142 | This ensures that you have the latest features and improvements. 143 | 144 | For more information and assistance, you can visit the EchoWarp PyPi page: 145 | https://pypi.org/project/echowarp/ 146 | 147 | ## Installation from source 148 | 149 | Clone the repository and set up a Python virtual environment: 150 | 151 | ```bash 152 | git clone https://github.com/lHumaNl/EchoWarp.git 153 | cd EchoWarp 154 | python -m venv venv 155 | source venv/bin/activate # On Windows, use `.\venv\Scripts\activate` 156 | pip install -r requirements.txt 157 | ``` 158 | 159 | ## Usage 160 | 161 | EchoWarp can be launched in either server or client mode, with settings configured interactively or via command-line 162 | arguments. 163 | 164 | ## Interactive Mode 165 | 166 | Simply run the main.py script without arguments to enter interactive mode: 167 | 168 | ```bash 169 | python -m echowarp.main 170 | ``` 171 | 172 | Follow the on-screen prompts to configure the utility. 173 | 174 | ## Command-Line Arguments 175 | 176 | EchoWarp supports configuration via command-line arguments for easy integration into scripts and automation workflows. 177 | 178 | #### Arguments Table 179 | 180 | | Argument | Description | Default | 181 | |----------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------| 182 | | `-c`, `--client` | Start utility in client mode (Mode in which the device receives an audio stream from the server over the network and plays it on the specified audio device). | Server mode (Mode in which the audio stream from the specified device is captured and sent to the client over the network) | 183 | | `-o`, `--output` | Use the output audio device for streaming (like speakers and headphones). | Input device (like microphones) | 184 | | `-u`, `--udp_port` | Specify the UDP\TCP port for audio data transmission\authorization. | 4415 | 185 | | `-b`, `--socket_buffer_size` | Size of socket buffer. | 6144 | 186 | | `-d`, `--device_id` | Specify the device ID directly to avoid interactive selection. | None | 187 | | `-p`, `--password` | Password for authentication. | None | 188 | | `-f`, `--config_file` | Path to config file (ignoring other args, if they added). | None | 189 | | `-e`, `--device_encoding_names` | Charset encoding for audio device. | System default encoding | 190 | | `--ignore_device_encoding_names` | Ignoring device names encoding. | False | 191 | | `-l`, `--is_error_log` | Write error log lines to file. | False | 192 | | `-r`, `--reconnect` | Number of failed connections (before closing the application in client mode\before ban client in server mode). (0=infinite) | 5 | 193 | | `-s`, `--save_config` | Save config file from selected args (ignoring default values). | None | 194 | | `-a`, `--server_address` | Specify the server address (only valid in client mode). | None | 195 | | `--ssl` | Enable SSL mode for encrypted communication (server mode only). | False | 196 | | `-i`, `--integrity_control` | Enable integrity control using hash (server mode only). | False | 197 | | `-w`, `--workers` | Set the maximum number of worker threads (server mode only). | 1 | 198 | 199 | Use these arguments to configure the utility directly from the command line for both automation and manual setups. 200 | 201 | ## Launching with Config File 202 | 203 | You can launch EchoWarp using a configuration file to avoid specifying all the arguments manually. Create a 204 | configuration file with the desired settings and use the --config_file argument to specify the path to this file. 205 | 206 | Example of a configuration file (config.conf): 207 | 208 | ```ini 209 | [echowarp_conf] 210 | is_server=False 211 | udp_port=6532 212 | socket_buffer_size=10240 213 | device_id=1 214 | password=mysecretpassword 215 | device_encoding_names=cp1251 216 | is_error_log=True 217 | server_address=192.168.1.5 218 | reconnect_attempt=15 219 | is_ssl=True 220 | is_integrity_control=True 221 | ``` 222 | 223 | To launch EchoWarp with the configuration file with CLI args: 224 | 225 | ```bash 226 | python -m echowarp.main --config_file path/to/config.conf 227 | ``` 228 | 229 | ## Ban List Management 230 | 231 | EchoWarp server maintains a ban list to block clients after a specified number of failed connection attempts. This 232 | feature helps to secure the server from unauthorized access attempts and repeated failed authentications. 233 | 234 | ### How Ban List Works 235 | 236 | 1. **Failed Connection Attempts**: Each client connection attempt is monitored. If a client fails to authenticate 237 | multiple times (as specified by the `--reconnect` argument), the client is added to the ban list. 238 | Setting `--reconnect` to `0` in server mode disables the ban list feature. 239 | 2. **Banning Clients**: Once a client is banned, it cannot reconnect to the server until the ban list is manually 240 | cleared or the server is restarted. 241 | 3. **Persistent Ban List**: The ban list is saved to a file (`echowarp_ban_list.txt`) to maintain the list of banned 242 | clients across server restarts. 243 | 244 | ### Example of Ban List File 245 | 246 | The ban list file (`echowarp_ban_list.txt`) contains the IP addresses of banned clients, one per line: 247 | 248 | ```txt 249 | 192.168.1.100 250 | 192.168.1.101 251 | ``` 252 | 253 | ## Additional Features 254 | 255 | - **Heartbeat Mechanism**: Ensures the continuous connection between client and server by periodically sending heartbeat 256 | messages. 257 | - **Thread Pooling**: Utilizes thread pooling for handling concurrent tasks efficiently. 258 | - **Logging**: Comprehensive logging for both normal operations and errors, with an option to enable error logging to a 259 | file. 260 | 261 | ## Examples 262 | 263 | ### Consult the help output for detailed command-line options:: 264 | 265 | ```bash 266 | python -m echowarp.main --help 267 | ``` 268 | 269 | ### Client Mode with Custom Server Address and Port: 270 | 271 | ```bash 272 | python -m echowarp.main --client --server_address 192.168.1.5 --udp_port 6555 273 | ``` -------------------------------------------------------------------------------- /echowarp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lHumaNl/EchoWarp/62dbb95aea201e9e874fc83720ff93d61f6c74f7/echowarp/__init__.py -------------------------------------------------------------------------------- /echowarp/auth_and_heartbeat/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lHumaNl/EchoWarp/62dbb95aea201e9e874fc83720ff93d61f6c74f7/echowarp/auth_and_heartbeat/__init__.py -------------------------------------------------------------------------------- /echowarp/auth_and_heartbeat/transport_base.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import logging 3 | import os 4 | import socket 5 | import threading 6 | import time 7 | from abc import abstractmethod 8 | from typing import Optional 9 | 10 | from echowarp.models.default_values_and_options import DefaultValuesAndOptions 11 | from echowarp.services.crypto_manager import CryptoManager 12 | from echowarp.models.json_message import JSONMessage 13 | from echowarp.settings import Settings 14 | 15 | 16 | class TransportBase: 17 | """ 18 | Provides base TCP functionality for both client and server sides of the EchoWarp application. 19 | Manages TCP connections, including sending heartbeats to maintain the connection and handling reconnections. 20 | 21 | Attributes: 22 | _is_server (bool): Indicates if the instance is running as a server. 23 | _client_tcp_socket (Optional[socket.socket]): Socket for TCP communication. 24 | _udp_socket (socket.socket): Socket for UDP communication. 25 | _udp_port (int): UDP/TCP port used for audio streaming. 26 | _stop_util_event (threading.Event): Event to signal when to terminate the application. 27 | _stop_stream_event (threading.Event): Event to signal when to stop streaming. 28 | _crypto_manager (CryptoManager): Manages cryptographic operations. 29 | _reconnect_attempt (int, optional): Allowed missed heartbeats before connection is considered lost. 30 | _password_base64 (Optional[str]): Base64 encoded password for authentication. 31 | _socket_buffer_size (int): Buffer size for the socket. 32 | """ 33 | _is_server: bool 34 | _client_tcp_socket: Optional[socket.socket] 35 | _udp_socket: socket.socket 36 | _udp_port: int 37 | _stop_util_event: threading.Event 38 | _stop_stream_event: threading.Event 39 | _crypto_manager: CryptoManager 40 | _reconnect_attempt: int 41 | _password_base64: Optional[str] 42 | _socket_buffer_size: int 43 | 44 | def __init__(self, settings: Settings, stop_util_event: threading.Event(), stop_stream_event: threading.Event()): 45 | """ 46 | Initializes the TransportBase with provided configuration. 47 | 48 | Args: 49 | settings (Settings): Settings object. 50 | stop_util_event (threading.Event): Event to signal when to terminate the application. 51 | stop_stream_event (threading.Event): Event to signal when to stop streaming. 52 | """ 53 | self._is_server = settings.is_server 54 | self._udp_port = settings.udp_port 55 | self._stop_util_event = stop_util_event 56 | self._stop_stream_event = stop_stream_event 57 | self._reconnect_attempt = settings.reconnect_attempt 58 | self._crypto_manager = settings.crypto_manager 59 | 60 | self._client_tcp_socket = None 61 | self.__initialize_udp_socket() 62 | 63 | self._password_base64 = self.__get_base64_password(settings.password) 64 | 65 | self._socket_buffer_size = settings.socket_buffer_size 66 | 67 | self.__stop_message_send = False 68 | 69 | def start_streaming(self, audio_thread: threading.Thread): 70 | """ 71 | Starts the streaming process by continuously sending heartbeat messages to maintain the connection alive. 72 | If the heartbeat fails consecutively beyond a specified limit, the connection is considered lost and the thread signals the application to stop. 73 | 74 | Args: 75 | audio_thread (threading.Thread): Thread for handling audio streaming. 76 | 77 | Raises: 78 | RuntimeError: Raised if too many consecutive heartbeat messages are missed, indicating a connection issue. 79 | """ 80 | audio_thread.start() 81 | heartbeat_delay = DefaultValuesAndOptions.get_heartbeat_delay() 82 | 83 | while not self._stop_util_event.is_set(): 84 | try: 85 | self.__send_and_get_heartbeat_message() 86 | 87 | time.sleep(heartbeat_delay) 88 | except ValueError as e: 89 | logging.error(e) 90 | self.__reconnect() 91 | except RuntimeError as e: 92 | logging.info(e) 93 | 94 | if self._is_server: 95 | self.__reconnect(True) 96 | else: 97 | break 98 | 99 | except (socket.error, socket.timeout) as e: 100 | logging.error(f"Heartbeat failed due to network error: {e}") 101 | self.__reconnect() 102 | except Exception as e: 103 | logging.error(f"Unexpected error during heartbeat: {e}") 104 | self.__reconnect() 105 | 106 | self._shutdown() 107 | 108 | def __send_and_get_heartbeat_message(self): 109 | """ 110 | Sends and receives heartbeat messages for connection validation. 111 | """ 112 | if self._is_server: 113 | self.__receive_heartbeat_and_validate() 114 | self.__format_and_send_heartbeat() 115 | else: 116 | self.__format_and_send_heartbeat() 117 | self.__receive_heartbeat_and_validate() 118 | 119 | @staticmethod 120 | def __get_base64_password(password: Optional[str]) -> Optional[str]: 121 | """ 122 | Encodes the password to Base64 format. 123 | 124 | Args: 125 | password (Optional[str]): The password to encode. 126 | 127 | Returns: 128 | Optional[str]: The Base64 encoded password. 129 | """ 130 | if password is None: 131 | return password 132 | else: 133 | return base64.b64encode(password.encode("utf-8")).decode("utf-8") 134 | 135 | def __receive_heartbeat_and_validate(self): 136 | """ 137 | Receives and validates the heartbeat message. 138 | """ 139 | encrypted_response = self._client_tcp_socket.recv(self._socket_buffer_size) 140 | if encrypted_response == b'': 141 | raise ValueError('Heartbeat message from client is NULL') 142 | 143 | decrypt_response = self._crypto_manager.decrypt_aes_and_verify_data(encrypted_response) 144 | response_message = JSONMessage(decrypt_response) 145 | 146 | if (response_message.message == JSONMessage.LOCKED_MESSAGE.response_message and 147 | response_message.response_code == JSONMessage.LOCKED_MESSAGE.response_code): 148 | raise RuntimeError("Received shutdown event. Close connection.") 149 | 150 | elif (response_message.message != JSONMessage.ACCEPTED_MESSAGE.response_message or 151 | response_message.response_code != JSONMessage.ACCEPTED_MESSAGE.response_code): 152 | raise ValueError( 153 | f"Response heartbeat message: {response_message.response_code} {response_message.message}") 154 | 155 | def __format_and_send_heartbeat(self): 156 | """ 157 | Formats and sends a heartbeat message. 158 | """ 159 | if self._stop_util_event.is_set(): 160 | message = JSONMessage.encode_message_to_json_bytes( 161 | JSONMessage.LOCKED_MESSAGE.response_message, 162 | JSONMessage.LOCKED_MESSAGE.response_code, 163 | None, 164 | None 165 | ) 166 | self.__stop_message_send = True 167 | else: 168 | message = JSONMessage.encode_message_to_json_bytes( 169 | JSONMessage.ACCEPTED_MESSAGE.response_message, 170 | JSONMessage.ACCEPTED_MESSAGE.response_code, 171 | None, 172 | None 173 | ) 174 | 175 | self._client_tcp_socket.sendall(self._crypto_manager.encrypt_aes_and_sign_data(message)) 176 | 177 | def __reconnect(self, is_client_exit=False): 178 | """ 179 | Handles the reconnection logic for the transport layer. 180 | 181 | Args: 182 | is_client_exit (bool): Indicates if the client should exit. 183 | """ 184 | self._print_pause_udp_listener_and_pause_stream() 185 | 186 | try: 187 | if not is_client_exit: 188 | logging.info("Attempting to stabilize the connection...") 189 | if self.__retry_send_heartbeat(): 190 | return 191 | 192 | self._cleanup_tcp_socket() 193 | 194 | if self._is_server: 195 | self._established_connection() 196 | else: 197 | self._initialize_socket() 198 | self.__perform_reconnect_attempts() 199 | 200 | if not self._stop_util_event.is_set(): 201 | self._print_udp_listener_and_start_stream() 202 | else: 203 | self._shutdown() 204 | except RuntimeError as e: 205 | logging.error(e) 206 | self._shutdown() 207 | except Exception as e: 208 | raise Exception(f"Critical error during reconnect: {e}") 209 | 210 | def __retry_send_heartbeat(self) -> bool: 211 | """ 212 | Retries sending the heartbeat message. 213 | 214 | Returns: 215 | bool: True if the heartbeat was successfully sent, False otherwise. 216 | """ 217 | try: 218 | self.__send_and_get_heartbeat_message() 219 | logging.info("Connection stabilized with heartbeat.") 220 | 221 | return True 222 | except ValueError as e: 223 | logging.error(e) 224 | 225 | return False 226 | except (socket.error, socket.timeout) as e: 227 | logging.error(f"Failed to retry send heartbeat due socket exception: {e}") 228 | 229 | return False 230 | except Exception as e: 231 | logging.error(f"Unexpected exception due retry sending heartbeat: {e}") 232 | 233 | return False 234 | 235 | def __perform_reconnect_attempts(self): 236 | """ 237 | Performs reconnection attempts until the maximum allowed attempts are reached. 238 | """ 239 | attempt = 0 240 | reconnect_delay = DefaultValuesAndOptions.get_heartbeat_delay() 241 | 242 | while not self._stop_util_event.is_set() and attempt < self._reconnect_attempt: 243 | try: 244 | self._established_connection() 245 | logging.info(f"Reconnection successful on attempt {attempt + 1}") 246 | 247 | break 248 | except (socket.error, socket.timeout) as e: 249 | attempt += 1 250 | logging.error(f"Reconnect failed due to socket error:{os.linesep}{e}" 251 | f"{self.__get_str_reconnect_attempt(attempt)}") 252 | time.sleep(reconnect_delay) 253 | except ValueError as e: 254 | attempt += 1 255 | logging.error(f"Reconnect failed due:{os.linesep}{e}" 256 | f"{self.__get_str_reconnect_attempt(attempt)}") 257 | time.sleep(reconnect_delay) 258 | 259 | if attempt >= self._reconnect_attempt: 260 | self._shutdown() 261 | raise RuntimeError("Maximum reconnect attempts reached, terminating connection.") 262 | 263 | def __get_str_reconnect_attempt(self, attempt: int) -> str: 264 | """ 265 | Returns a formatted string representing the reconnect attempt. 266 | 267 | Args: 268 | attempt (int): The current attempt number. 269 | 270 | Returns: 271 | str: Formatted string representing the reconnect attempt. 272 | """ 273 | if self._reconnect_attempt == 0: 274 | return f"{os.linesep}Retrying connection... (Reconnect attempt: {attempt})" 275 | else: 276 | return f"{os.linesep}Retrying connection... (Reconnect attempt: {attempt}/{self._reconnect_attempt})" 277 | 278 | def _cleanup_tcp_socket(self): 279 | """ 280 | Cleans up the TCP socket. 281 | """ 282 | try: 283 | if self._client_tcp_socket: 284 | self._client_tcp_socket.shutdown(socket.SHUT_RDWR) 285 | self._client_tcp_socket.close() 286 | except socket.error as e: 287 | logging.error(f"Error closing client TCP socket: {e}") 288 | except Exception as e: 289 | logging.error(f"Unhandled exception during TCP socket cleanup: {e}") 290 | 291 | def _cleanup_udp_socket(self): 292 | """ 293 | Cleans up the UDP socket. 294 | """ 295 | try: 296 | if self._udp_socket: 297 | self._udp_socket.close() 298 | except socket.error as e: 299 | logging.error(f"Error closing client UDP socket: {e}") 300 | except Exception as e: 301 | logging.error(f"Unhandled exception during UDP socket cleanup: {e}") 302 | 303 | def _shutdown(self): 304 | """ 305 | Shuts down the transport, signaling stop events and cleaning up sockets. 306 | """ 307 | if not self.__stop_message_send: 308 | shutdown_message = JSONMessage.encode_message_to_json_bytes( 309 | JSONMessage.LOCKED_MESSAGE.response_message, 310 | JSONMessage.LOCKED_MESSAGE.response_code, 311 | None, 312 | None 313 | ) 314 | self._client_tcp_socket.sendall(self._crypto_manager.encrypt_aes_and_sign_data(shutdown_message)) 315 | 316 | self._stop_util_event.set() 317 | self._stop_stream_event.set() 318 | 319 | time.sleep(5) 320 | 321 | self._cleanup_tcp_socket() 322 | self._cleanup_udp_socket() 323 | 324 | def __initialize_udp_socket(self): 325 | """ 326 | Initializes the UDP socket. 327 | """ 328 | self._udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 329 | self._udp_socket.settimeout(DefaultValuesAndOptions.get_timeout()) 330 | 331 | def _init_tcp_connection(self): 332 | """ 333 | Initializes the TCP connection. 334 | """ 335 | self._initialize_socket() 336 | try: 337 | self._established_connection() 338 | except (socket.error, socket.timeout, socket.gaierror, RuntimeError, ValueError, OSError) as e: 339 | self._shutdown() 340 | raise RuntimeError(e) 341 | except Exception as e: 342 | self._shutdown() 343 | raise Exception(f"TCP connection failed: {e}") 344 | 345 | @abstractmethod 346 | def _print_udp_listener_and_start_stream(self): 347 | pass 348 | 349 | @abstractmethod 350 | def _print_pause_udp_listener_and_pause_stream(self): 351 | pass 352 | 353 | @abstractmethod 354 | def _initialize_socket(self): 355 | pass 356 | 357 | @abstractmethod 358 | def _established_connection(self): 359 | pass 360 | -------------------------------------------------------------------------------- /echowarp/auth_and_heartbeat/transport_client.py: -------------------------------------------------------------------------------- 1 | import os 2 | import socket 3 | import logging 4 | import threading 5 | from abc import ABC 6 | 7 | from echowarp.auth_and_heartbeat.transport_base import TransportBase 8 | from echowarp.models.json_message import JSONMessage, JSONMessageServer 9 | from echowarp.models.default_values_and_options import DefaultValuesAndOptions 10 | from echowarp.settings import Settings 11 | 12 | 13 | class TransportClient(TransportBase, ABC): 14 | """ 15 | Manages TCP client operations for EchoWarp, including connection establishment, 16 | server authentication, and secure data exchange. 17 | 18 | Attributes: 19 | _server_address (str): The IP address or hostname of the server. 20 | """ 21 | _server_address: str 22 | 23 | def __init__(self, settings: Settings, stop_util_event: threading.Event(), stop_stream_event: threading.Event()): 24 | """ 25 | Initializes the TCP client with the necessary configuration to establish a connection. 26 | 27 | Args: 28 | settings (Settings): Settings object. 29 | """ 30 | super().__init__(settings, stop_util_event, stop_stream_event) 31 | self._server_address = settings.server_address 32 | 33 | self._udp_socket.bind(('', self._udp_port)) 34 | self._init_tcp_connection() 35 | 36 | def __authenticate_on_server(self): 37 | """ 38 | Performs authentication with the server using RSA encryption for the exchange of credentials. 39 | 40 | Raises: 41 | ValueError: If the authentication with the server fails or if versions mismatch. 42 | """ 43 | 44 | self._client_tcp_socket.sendall(self._crypto_manager.get_serialized_public_key()) 45 | server_public_key_pem = self._client_tcp_socket.recv(self._socket_buffer_size) 46 | self._crypto_manager.load_peer_public_key(server_public_key_pem) 47 | 48 | client_auth_message_bytes = JSONMessage.encode_message_to_json_bytes( 49 | self._password_base64, 50 | JSONMessage.OK_MESSAGE.response_code, 51 | None, 52 | None 53 | ) 54 | 55 | self._client_tcp_socket.sendall(self._crypto_manager.encrypt_rsa_message(client_auth_message_bytes)) 56 | 57 | encrypted_message_from_server = self._client_tcp_socket.recv(self._socket_buffer_size) 58 | message_from_server = self._crypto_manager.decrypt_rsa_message(encrypted_message_from_server) 59 | server_message = JSONMessage(message_from_server) 60 | config_server_message = None 61 | 62 | client_failed_connects = server_message.failed_connections 63 | reconnect_attempts = server_message.reconnect_attempts 64 | if reconnect_attempts is not None: 65 | client_failed_connects = f'{client_failed_connects}/{reconnect_attempts}' 66 | 67 | client_failed_connect_str = f'Client failed connect attempts on server: {client_failed_connects}' 68 | 69 | if (server_message.message == JSONMessage.OK_MESSAGE.response_message 70 | and server_message.response_code == JSONMessage.OK_MESSAGE.response_code): 71 | config_server_message = JSONMessageServer(message_from_server) 72 | 73 | elif (server_message.message == JSONMessage.FORBIDDEN_MESSAGE.response_message 74 | or server_message.response_code == JSONMessage.FORBIDDEN_MESSAGE.response_code): 75 | raise RuntimeError(f"Client is banned! Message from server: " 76 | f"{server_message.response_code} {server_message.message}" 77 | f"{os.linesep}{client_failed_connect_str}") 78 | 79 | elif (server_message.message == JSONMessage.UNAUTHORIZED_MESSAGE.response_message 80 | or server_message.response_code == JSONMessage.UNAUTHORIZED_MESSAGE.response_code): 81 | raise ValueError(f"Invalid password! Message from server: " 82 | f"{server_message.response_code} {server_message.message}" 83 | f"{os.linesep}{client_failed_connect_str}") 84 | 85 | if server_message.version != DefaultValuesAndOptions.get_util_comparability_version(): 86 | raise ValueError(f"Client comparability version not equal server version: " 87 | f"{DefaultValuesAndOptions.get_util_comparability_version()} - Client, " 88 | f"{server_message.version} - Server" 89 | f"{os.linesep}{client_failed_connect_str}") 90 | 91 | self._crypto_manager.load_aes_key_and_iv(config_server_message.aes_key_base64, 92 | config_server_message.aes_iv_base64) 93 | self._crypto_manager.load_encryption_config_for_client(config_server_message.is_encrypt, 94 | config_server_message.is_integrity_control) 95 | 96 | logging.info(f"Authentication on server completed.") 97 | 98 | def _initialize_socket(self): 99 | self._client_tcp_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 100 | self._client_tcp_socket.settimeout(DefaultValuesAndOptions.get_timeout()) 101 | 102 | def _established_connection(self): 103 | self._client_tcp_socket.connect((self._server_address, self._udp_port)) 104 | 105 | logging.info(f"TCP connection to {self._server_address} established.") 106 | 107 | try: 108 | self.__authenticate_on_server() 109 | except socket.timeout as e: 110 | raise socket.timeout(f"Timeout from server socket while authenticating: {e}") 111 | except socket.error as e: 112 | raise socket.error(f"Failed to send/receive data: {e}") 113 | except ValueError as e: 114 | raise ValueError(e) 115 | 116 | def _print_udp_listener_and_start_stream(self): 117 | logging.info(f'UDP listening started from {self._server_address}:{self._udp_port}') 118 | self._stop_stream_event.set() 119 | 120 | def _print_pause_udp_listener_and_pause_stream(self): 121 | logging.warning(f'Audio listening paused!') 122 | self._stop_stream_event.clear() 123 | -------------------------------------------------------------------------------- /echowarp/auth_and_heartbeat/transport_server.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import socket 4 | import threading 5 | from abc import ABC 6 | from typing import Optional 7 | 8 | from echowarp.auth_and_heartbeat.transport_base import TransportBase 9 | from echowarp.models.net_interfaces_info import NetInterfacesInfo 10 | from echowarp.models.ban_list import BanList 11 | from echowarp.models.json_message import JSONMessageServer, JSONMessage 12 | from echowarp.models.default_values_and_options import DefaultValuesAndOptions 13 | from echowarp.settings import Settings 14 | 15 | 16 | class TransportServer(TransportBase, ABC): 17 | """ 18 | Manages TCP server operations for EchoWarp, including handling client connections, 19 | authentication, and setting up a secure communication channel. 20 | 21 | Attributes: 22 | _client_address (Optional[str]): IP address of the connected client. 23 | _stop_util_event (threading.Event): Event to signal util to stop. 24 | _stop_stream_event (threading.Event): Event to signal stream to stop. 25 | """ 26 | _server_tcp_socket: socket.socket 27 | _client_address: Optional[str] 28 | __ban_list: BanList 29 | __net_interfaces_info: NetInterfacesInfo 30 | 31 | def __init__(self, settings: Settings, stop_util_event: threading.Event(), stop_stream_event: threading.Event()): 32 | """ 33 | Initializes the TCPServer with specified configuration parameters. 34 | 35 | Args: 36 | settings (Settings): Settings object. 37 | """ 38 | super().__init__(settings, stop_util_event, stop_stream_event) 39 | 40 | self._client_address = None 41 | self.__ban_list = BanList(settings.reconnect_attempt) 42 | self.__net_interfaces_info = NetInterfacesInfo(self._udp_port) 43 | 44 | self._init_tcp_connection() 45 | 46 | def __authenticate_client(self): 47 | """ 48 | Handles the authentication sequence with the client by exchanging encrypted messages and 49 | establishing encryption settings. 50 | 51 | Raises: 52 | ValueError: If client authentication fails or there is a version mismatch. 53 | """ 54 | 55 | self._client_tcp_socket.sendall(self._crypto_manager.get_serialized_public_key()) 56 | 57 | client_public_key_pem = self._client_tcp_socket.recv(self._socket_buffer_size) 58 | self._crypto_manager.load_peer_public_key(client_public_key_pem) 59 | 60 | encrypted_message_from_client = self._client_tcp_socket.recv(self._socket_buffer_size) 61 | message_from_client = self._crypto_manager.decrypt_rsa_message(encrypted_message_from_client) 62 | 63 | client_auth_message = JSONMessage(message_from_client) 64 | 65 | if self.__ban_list.is_banned(self._client_address): 66 | self.__form_error_message( 67 | JSONMessage.FORBIDDEN_MESSAGE.response_message, 68 | JSONMessage.FORBIDDEN_MESSAGE.response_code, 69 | f"Failed to authenticate client: {self._client_address}. " 70 | f"Client is {JSONMessage.FORBIDDEN_MESSAGE.response_message}" 71 | ) 72 | 73 | if client_auth_message.message != self._password_base64: 74 | self.__form_error_message( 75 | JSONMessage.UNAUTHORIZED_MESSAGE.response_message, 76 | JSONMessage.UNAUTHORIZED_MESSAGE.response_code, 77 | f"Failed to authenticate client: {self._client_address}. " 78 | f"Client is {JSONMessage.UNAUTHORIZED_MESSAGE.response_message}" 79 | ) 80 | 81 | if client_auth_message.version != DefaultValuesAndOptions.get_util_comparability_version(): 82 | self.__form_error_message( 83 | JSONMessage.CONFLICT_MESSAGE.response_message, 84 | JSONMessage.CONFLICT_MESSAGE.response_code, 85 | f"Client version not equal server version: " 86 | f"{client_auth_message.version} - Client, " 87 | f"{DefaultValuesAndOptions.get_util_comparability_version()} - Server" 88 | ) 89 | 90 | logging.info(f"Client {self._client_address} authenticated.") 91 | self.__ban_list.success_connect_attempt(self._client_address) 92 | self.__print_client_info() 93 | 94 | self.__send_configuration() 95 | 96 | def __form_error_message(self, response_message: str, response_code: int, exception_message: str): 97 | self.__ban_list.fail_connect_attempt(self._client_address) 98 | 99 | error_message_bytes = JSONMessage.encode_message_to_json_bytes( 100 | response_message, 101 | response_code, 102 | self.__ban_list.get_failed_connect_attempts(self._client_address), 103 | self._reconnect_attempt 104 | ) 105 | 106 | self._client_tcp_socket.sendall(self._crypto_manager.encrypt_rsa_message(error_message_bytes)) 107 | raise ValueError(exception_message) 108 | 109 | def __send_configuration(self): 110 | """ 111 | Sends the configuration settings to the connected client, including security settings and AES keys. 112 | 113 | The configuration is sent as an encrypted JSON string. 114 | """ 115 | config_json = JSONMessageServer.encode_server_config_to_json_bytes( 116 | self._crypto_manager.is_ssl, self._crypto_manager.is_integrity_control, 117 | self._crypto_manager.get_aes_key_base64(), self._crypto_manager.get_aes_iv_base64(), 118 | self.__ban_list.get_failed_connect_attempts(self._client_address), self._reconnect_attempt 119 | ) 120 | 121 | self._client_tcp_socket.sendall(self._crypto_manager.encrypt_rsa_message(config_json)) 122 | logging.info("Configuration sent to client.") 123 | 124 | def _initialize_socket(self): 125 | self._server_tcp_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 126 | self._server_tcp_socket.bind(('', self._udp_port)) 127 | self._server_tcp_socket.settimeout(DefaultValuesAndOptions.get_timeout()) 128 | self._server_tcp_socket.listen() 129 | 130 | def _established_connection(self): 131 | is_log = True 132 | while not self._stop_util_event.is_set(): 133 | if is_log: 134 | logging.info(f'Authenticate server started and awaiting client connection on next INET interfaces:' 135 | f'{os.linesep}{self.__net_interfaces_info.get_formatted_info_str()}') 136 | 137 | try: 138 | self._client_tcp_socket, client_address = self._server_tcp_socket.accept() 139 | except (socket.error, socket.timeout): 140 | is_log = False 141 | continue 142 | 143 | is_log = True 144 | self._client_address = client_address[0] 145 | self.__ban_list.add_client_to_ban_list(self._client_address) 146 | 147 | if self.__ban_list.is_banned(self._client_address) and not self.__ban_list.is_first_time_message( 148 | self._client_address): 149 | logging.error(f'Client {self._client_address} banned!') 150 | self.__ban_list.fail_connect_attempt(self._client_address) 151 | continue 152 | 153 | logging.info(f"Client connected from {self._client_address}") 154 | 155 | try: 156 | self.__authenticate_client() 157 | except ValueError as e: 158 | logging.error(e) 159 | self.__print_client_info() 160 | continue 161 | except (socket.error, socket.timeout) as e: 162 | logging.error(f"Failed to send/receive data: {e}") 163 | self.__print_client_info() 164 | continue 165 | except Exception as e: 166 | logging.error(f"Error during authentication: {e}") 167 | self.__print_client_info() 168 | continue 169 | finally: 170 | self.__ban_list.update_ban_list_file() 171 | 172 | break 173 | 174 | def __print_client_info(self): 175 | success_connections = self.__ban_list.get_success_connect_attempts(self._client_address) 176 | failed_connections = self.__ban_list.get_failed_connect_attempts(self._client_address) 177 | all_failed_connections = self.__ban_list.get_all_failed_connect_attempts(self._client_address) 178 | 179 | failed_connections_attempts = f'{failed_connections}/{self._reconnect_attempt}' if self._reconnect_attempt > 0 \ 180 | else failed_connections 181 | 182 | logging.info( 183 | f"Client {self._client_address} info:{os.linesep}" 184 | f"Success client connections: {success_connections}{os.linesep}" 185 | f"Failed client connection attempts after last success: {failed_connections_attempts}{os.linesep}" 186 | f"Count of failed client connections: {all_failed_connections}" 187 | ) 188 | 189 | def _shutdown(self): 190 | super()._shutdown() 191 | try: 192 | if self._server_tcp_socket: 193 | self._server_tcp_socket.close() 194 | except socket.error as e: 195 | logging.error(f"Error closing server TCP socket: {e}") 196 | except Exception as e: 197 | logging.error(f"Unhandled exception during TCP socket cleanup: {e}") 198 | 199 | def _print_udp_listener_and_start_stream(self): 200 | logging.info(f"Start UDP audio streaming to client {self._client_address}:{self._udp_port}") 201 | self._stop_stream_event.set() 202 | 203 | def _print_pause_udp_listener_and_pause_stream(self): 204 | logging.warning(f'Audio streaming paused!') 205 | self._stop_stream_event.clear() 206 | -------------------------------------------------------------------------------- /echowarp/logging_config.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import locale 3 | import logging 4 | 5 | 6 | class Logger: 7 | IS_CORE_LOGGER_ENABLED: bool = False 8 | IS_WARNING_LOGGER_ENABLED: bool = False 9 | 10 | @staticmethod 11 | def init_core_logger(): 12 | """ 13 | Configures the logging settings for the application. 14 | Sets the logging level to INFO and specifies the format for log messages, 15 | including timestamp, log level, module, function name, and message. 16 | """ 17 | if not Logger.IS_CORE_LOGGER_ENABLED: 18 | logging.basicConfig( 19 | level=logging.INFO, 20 | format='[%(asctime)s] %(levelname)s | ' 21 | 'module: %(module)s | ' 22 | 'funcName: %(funcName)s | ' 23 | '%(message)s', 24 | datefmt='%Y-%m-%d %H:%M:%S', 25 | encoding=locale.getpreferredencoding() 26 | ) 27 | 28 | Logger.IS_CORE_LOGGER_ENABLED = True 29 | 30 | @staticmethod 31 | def init_warning_logger(): 32 | """ 33 | Configures an additional file handler for logging warnings. 34 | Log messages with level WARNING or higher will be written to the specified file. 35 | """ 36 | if not Logger.IS_WARNING_LOGGER_ENABLED: 37 | current_date = datetime.datetime.now().strftime('%Y-%m-%d') 38 | file_handler = logging.FileHandler( 39 | f"echowarp_errors_{current_date}.log", 40 | encoding=locale.getpreferredencoding() 41 | ) 42 | file_handler.setLevel(logging.WARNING) 43 | 44 | formatter = logging.Formatter('[%(asctime)s] %(levelname)s | ' 45 | 'module: %(module)s | ' 46 | 'funcName: %(funcName)s | ' 47 | '%(message)s', 48 | datefmt='%Y-%m-%d %H:%M:%S') 49 | file_handler.setFormatter(formatter) 50 | 51 | logger = logging.getLogger() 52 | logger.addHandler(file_handler) 53 | 54 | Logger.IS_WARNING_LOGGER_ENABLED = True 55 | -------------------------------------------------------------------------------- /echowarp/main.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import sys 3 | import threading 4 | import logging 5 | import signal 6 | import time 7 | from functools import partial 8 | 9 | from echowarp.logging_config import Logger 10 | from echowarp.start_modes.args_mode import ArgsParser 11 | from echowarp.start_modes.interactive_mode import InteractiveSettings 12 | 13 | from echowarp.streamer.audio_client import ClientStreamReceiver 14 | from echowarp.streamer.audio_server import ServerStreamer 15 | 16 | 17 | def graceful_shutdown(signum, frame, stop_util_event: threading.Event): 18 | """ 19 | Handles graceful shutdown of the application when a SIGINT signal is received. 20 | 21 | Args: 22 | signum (int): Signal number. 23 | frame (frame): Current stack frame. 24 | stop_util_event (threading.Event): Event to signal the utility to stop. 25 | """ 26 | logging.info("Gracefully shutting down...") 27 | stop_util_event.set() 28 | 29 | 30 | def run_app(stop_util_event: threading.Event(), stop_stream_event: threading.Event()): 31 | """ 32 | Runs the EchoWarp application in either server or client mode based on provided settings. 33 | 34 | Args: 35 | stop_util_event (threading.Event): Event to signal the utility to stop. 36 | stop_stream_event (threading.Event): Event to signal the stream to stop. 37 | """ 38 | if len(sys.argv) <= 1: 39 | settings = InteractiveSettings.get_settings_in_interactive_mode() 40 | else: 41 | settings = ArgsParser.get_settings_from_cli_args() 42 | 43 | if settings.is_server: 44 | logging.info("Starting EchoWarp in server mode") 45 | streamer = ServerStreamer(settings, stop_util_event, stop_stream_event) 46 | 47 | streamer_thread = threading.Thread(target=streamer.encode_audio_and_send_to_client, daemon=True) 48 | streamer.start_streaming(streamer_thread) 49 | else: 50 | logging.info("Starting EchoWarp in client mode") 51 | receiver = ClientStreamReceiver(settings, stop_util_event, stop_stream_event) 52 | 53 | receiver_thread = threading.Thread(target=receiver.receive_audio_and_decode, daemon=True) 54 | receiver.start_streaming(receiver_thread) 55 | 56 | 57 | def main(): 58 | """ 59 | Entry point of the EchoWarp application. Initializes logging, handles signals, 60 | and runs the application. 61 | """ 62 | Logger.init_core_logger() 63 | 64 | stop_util_event = threading.Event() 65 | stop_stream_event = threading.Event() 66 | 67 | signal.signal(signal.SIGINT, 68 | partial(graceful_shutdown, stop_util_event=stop_util_event)) 69 | 70 | try: 71 | run_app(stop_util_event, stop_stream_event) 72 | except (socket.gaierror, OSError) as e: 73 | logging.error(e) 74 | except RuntimeError as e: 75 | logging.error(e) 76 | except Exception as e: 77 | logging.critical(f"An error occurred: {e}", exc_info=True) 78 | 79 | stop_stream_event.set() 80 | stop_util_event.set() 81 | finally: 82 | time.sleep(1) 83 | input("Press Enter to exit...") 84 | 85 | 86 | if __name__ == "__main__": 87 | main() 88 | -------------------------------------------------------------------------------- /echowarp/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lHumaNl/EchoWarp/62dbb95aea201e9e874fc83720ff93d61f6c74f7/echowarp/models/__init__.py -------------------------------------------------------------------------------- /echowarp/models/audio_device.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import platform 4 | import subprocess 5 | from dataclasses import dataclass 6 | from typing import List, Optional, Mapping 7 | 8 | import chardet 9 | import pyaudio 10 | 11 | 12 | @dataclass 13 | class Device: 14 | id: int 15 | dev: Mapping 16 | device_name: str 17 | audio_devices_str: str 18 | 19 | 20 | class AudioDevice: 21 | """ 22 | Represents an audio device for recording or playback, handling device selection and configuration. 23 | 24 | Attributes: 25 | py_audio (pyaudio.PyAudio): Instance of PyAudio used to interface with audio hardware. 26 | device_name (str): The name of the device. 27 | device_index (int): The index of the device as used by PyAudio. 28 | channels (int): Number of audio channels supported by the device. 29 | sample_rate (int): The sample rate (in Hz) of the device. 30 | """ 31 | py_audio: pyaudio.PyAudio 32 | device_name: str 33 | device_id: Optional[int] 34 | device_index: int 35 | channels: int 36 | sample_rate: int 37 | ignore_device_encoding_names: bool 38 | device_encoding_names: str 39 | 40 | def __init__(self, is_input_device: bool, device_id: Optional[int], ignore_device_encoding_names: bool, 41 | device_encoding_names: str): 42 | """ 43 | Initializes an audio device, either for input or output, based on the provided parameters. 44 | 45 | Args: 46 | is_input_device (bool): Set to True to initialize as an input device, False for output. 47 | device_id (Optional[int]): Specific device ID to use. If None, the user will select a device interactively. 48 | """ 49 | self.is_input_device = is_input_device 50 | self.device_id = device_id 51 | self.py_audio = pyaudio.PyAudio() 52 | 53 | self.ignore_device_encoding_names = ignore_device_encoding_names 54 | self.device_encoding_names = device_encoding_names 55 | 56 | if self.device_id is None: 57 | self.__select_audio_device() 58 | else: 59 | self.__select_audio_device_by_device_id() 60 | 61 | def __select_audio_device_by_device_id(self): 62 | self.device_index = self.device_id 63 | try: 64 | dev = self.py_audio.get_device_info_by_index(self.device_index) 65 | except Exception as e: 66 | raise Exception(f"Selected invalid device id - {self.device_index}: {e}") 67 | 68 | self.device_name = self.__decode_string(dev['name']) 69 | self.sample_rate = int(dev['defaultSampleRate']) 70 | 71 | if dev['maxInputChannels'] > 0: 72 | self.channels = dev['maxInputChannels'] 73 | else: 74 | self.channels = dev['maxOutputChannels'] 75 | 76 | def __select_audio_device(self): 77 | """ 78 | Prompts the user to select an audio device from the list of available devices. 79 | """ 80 | if self.is_input_device: 81 | device_type = 'Input' 82 | else: 83 | device_type = 'Output' 84 | 85 | device_list = self.__get_id_list_of_devices(device_type) 86 | if len(device_list) == 0: 87 | raise Exception(f"No connected {device_type} audio devices found!") 88 | 89 | self.__print_list_of_audio_devices(device_type, device_list) 90 | device_indexes = [device.id for device in device_list] 91 | device_index = None 92 | 93 | while device_index not in device_indexes: 94 | try: 95 | device_index = input() 96 | device_index = int(device_index) 97 | except Exception as e: 98 | logging.error(f'Selected invalid device id: {device_index}{os.linesep}{e}') 99 | 100 | self.device_index = device_index 101 | self.device_id = device_index 102 | dev = self.py_audio.get_device_info_by_index(self.device_index) 103 | self.device_name = self.__decode_string(dev['name']) 104 | self.channels = dev[f'max{device_type}Channels'] 105 | self.sample_rate = int(dev['defaultSampleRate']) 106 | 107 | @staticmethod 108 | def __print_list_of_audio_devices(audio_device_type: str, device_list: List[Device]): 109 | logging.info( 110 | f"Select id of {audio_device_type} audio devices:{os.linesep}" 111 | f"{os.linesep.join(device.audio_devices_str for device in device_list)}") 112 | 113 | def __get_id_list_of_devices(self, device_type: str) -> List[Device]: 114 | device_list = [] 115 | device_count = self.py_audio.get_device_count() 116 | 117 | if device_count == 0: 118 | raise Exception(f"No connected audio devices found!") 119 | 120 | for i in range(device_count): 121 | dev = self.py_audio.get_device_info_by_index(i) 122 | 123 | device_name = self.__decode_string(dev['name']) 124 | 125 | if dev[f'max{device_type}Channels'] > 0: 126 | audio_devices_str = ( 127 | f"{i}: {device_name}, " 128 | f"Channels: {dev[f'max{device_type}Channels']}, " 129 | f"Sample rate: {int(dev['defaultSampleRate'])}Hz" 130 | ) 131 | device_list.append(Device(i, dev, device_name, audio_devices_str)) 132 | 133 | if platform.system() == 'Windows': 134 | device_list = self.__get_data_from_powershell(device_list) 135 | 136 | return device_list 137 | 138 | def __get_data_from_powershell(self, device_list: List[Device]) -> List[Device]: 139 | command = """ 140 | $OutputEncoding = [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; 141 | Get-PnpDevice | 142 | Where-Object { $_.Class -eq 'AudioEndpoint' -and $_.Status -eq 'OK' } | 143 | Select-Object Name | 144 | Out-String 145 | """ 146 | 147 | try: 148 | result = subprocess.run(["powershell", "-Command", command], capture_output=True, text=True, 149 | encoding='utf-8') 150 | ps_device_names = self.__parse_powershell_stdout(result.stdout) 151 | except Exception as e: 152 | logging.warning(f"Failed to get device names from PowerShell: {e}") 153 | 154 | return device_list 155 | 156 | py_audio_device_names = [device.device_name for device in device_list] 157 | is_ps_device_names_exists = any(ps_device_name in py_audio_device_names for ps_device_name in ps_device_names) 158 | 159 | if not is_ps_device_names_exists: 160 | logging.warning("Failed to size list of Windows audio devices!") 161 | 162 | return device_list 163 | else: 164 | sized_device_list = device_list.copy() 165 | for id_device in device_list: 166 | if id_device.device_name not in ps_device_names: 167 | sized_device_list.remove(id_device) 168 | 169 | return sized_device_list 170 | 171 | @staticmethod 172 | def __parse_powershell_stdout(stdout: str) -> List[str]: 173 | lines = stdout.split('\n') 174 | device_names = [] 175 | collect_names = False 176 | 177 | for line in lines: 178 | if '----' in line: 179 | collect_names = True 180 | continue 181 | 182 | if collect_names and line.strip(): 183 | device_names.append(line.strip()) 184 | 185 | return device_names 186 | 187 | def __decode_string(self, string: str) -> str: 188 | if self.ignore_device_encoding_names: 189 | return string 190 | 191 | try: 192 | return string.encode(self.device_encoding_names).decode('utf-8') 193 | except (UnicodeEncodeError, UnicodeDecodeError): 194 | try: 195 | return string.encode(self.device_encoding_names).decode( 196 | chardet.detect(string.encode(self.device_encoding_names))['encoding'] 197 | ) 198 | except (UnicodeEncodeError, UnicodeDecodeError): 199 | return string 200 | -------------------------------------------------------------------------------- /echowarp/models/ban_list.py: -------------------------------------------------------------------------------- 1 | import locale 2 | import logging 3 | import os 4 | from typing import Dict 5 | 6 | from echowarp.models.default_values_and_options import DefaultValuesAndOptions 7 | from echowarp.start_modes.config_parser import ConfigParser 8 | 9 | 10 | class ClientStatus: 11 | __reconnect_attempts: int 12 | __is_banned: bool 13 | __is_first_time_message: bool 14 | __failed_connect_attempts: int 15 | __all_failed_connect_attempts: int 16 | __success_connect_attempts: int 17 | 18 | def __init__(self, is_banned: bool, reconnect_attempts: int): 19 | self.__reconnect_attempts = reconnect_attempts 20 | self.__is_banned = is_banned 21 | self.__is_first_time_message = True 22 | 23 | if is_banned: 24 | self.__failed_connect_attempts = reconnect_attempts 25 | self.__all_failed_connect_attempts = reconnect_attempts 26 | else: 27 | self.__failed_connect_attempts = 0 28 | self.__all_failed_connect_attempts = 0 29 | 30 | self.__success_connect_attempts = 0 31 | 32 | def is_banned(self) -> bool: 33 | return self.__is_banned 34 | 35 | def success_connect_attempt(self): 36 | self.__failed_connect_attempts = 0 37 | self.__success_connect_attempts += 1 38 | self.__is_banned = False 39 | self.__is_first_time_message = True 40 | 41 | def get_failed_connect_attempts(self) -> int: 42 | return self.__failed_connect_attempts 43 | 44 | def get_success_connect_attempts(self) -> int: 45 | return self.__success_connect_attempts 46 | 47 | def get_all_failed_connect_attempts(self) -> int: 48 | return self.__all_failed_connect_attempts 49 | 50 | def fail_connect_attempt(self): 51 | if not self.__is_banned: 52 | self.__failed_connect_attempts += 1 53 | self.__all_failed_connect_attempts += 1 54 | 55 | if self.__failed_connect_attempts >= self.__reconnect_attempts > 0: 56 | self.__is_banned = True 57 | 58 | def is_first_time_message(self) -> bool: 59 | if self.__is_first_time_message: 60 | self.__is_first_time_message = False 61 | 62 | return True 63 | else: 64 | return self.__is_first_time_message 65 | 66 | 67 | class BanList: 68 | __ban_list: Dict[str, ClientStatus] 69 | __reconnect_attempts: int 70 | 71 | def __init__(self, reconnect_attempts: int): 72 | self.__reconnect_attempts = reconnect_attempts 73 | self.__get_ban_list() 74 | 75 | def is_banned(self, client_ip: str) -> bool: 76 | if client_ip in self.__ban_list: 77 | return self.__ban_list[client_ip].is_banned() 78 | else: 79 | return False 80 | 81 | def success_connect_attempt(self, client_ip: str): 82 | if client_ip in self.__ban_list: 83 | self.__ban_list[client_ip].success_connect_attempt() 84 | 85 | def fail_connect_attempt(self, client_ip: str): 86 | if client_ip in self.__ban_list: 87 | self.__ban_list[client_ip].fail_connect_attempt() 88 | 89 | def add_client_to_ban_list(self, client_ip: str): 90 | if client_ip not in self.__ban_list: 91 | self.__ban_list[client_ip] = ClientStatus(False, self.__reconnect_attempts) 92 | 93 | def is_first_time_message(self, client_ip: str) -> bool: 94 | if client_ip in self.__ban_list: 95 | return self.__ban_list[client_ip].is_first_time_message() 96 | else: 97 | return True 98 | 99 | def get_failed_connect_attempts(self, client_ip: str) -> int: 100 | return self.__ban_list[client_ip].get_failed_connect_attempts() 101 | 102 | def get_success_connect_attempts(self, client_ip: str) -> int: 103 | return self.__ban_list[client_ip].get_success_connect_attempts() 104 | 105 | def get_all_failed_connect_attempts(self, client_ip: str) -> int: 106 | return self.__ban_list[client_ip].get_all_failed_connect_attempts() 107 | 108 | def update_ban_list_file(self): 109 | if self.__reconnect_attempts <= 0: 110 | return 111 | 112 | banned_clients = [ip for ip, client in self.__ban_list.items() 113 | if client.is_banned()] 114 | 115 | if len(banned_clients) > 0: 116 | try: 117 | with open(DefaultValuesAndOptions.BAN_LIST_FILE, 'w', encoding=locale.getpreferredencoding()) as file: 118 | file.write(os.linesep.join(banned_clients)) 119 | except Exception as e: 120 | logging.error(f'Failed to update ban list file: {e}') 121 | 122 | def __get_ban_list(self): 123 | self.__ban_list = {} 124 | 125 | if os.path.isfile(DefaultValuesAndOptions.BAN_LIST_FILE) and self.__reconnect_attempts > 0: 126 | str_lines = ConfigParser.get_file_str(DefaultValuesAndOptions.BAN_LIST_FILE).split(os.linesep) 127 | for line in str_lines: 128 | line = line.strip() 129 | if line != '' and line is not None: 130 | self.__ban_list[line] = ClientStatus(True, self.__reconnect_attempts) 131 | -------------------------------------------------------------------------------- /echowarp/models/default_values_and_options.py: -------------------------------------------------------------------------------- 1 | import locale 2 | 3 | from echowarp.models.options_data_creater import OptionsData 4 | import version 5 | 6 | 7 | class DefaultValuesAndOptions: 8 | """ 9 | Provides default configuration values and options for various settings within the EchoWarp project. 10 | This class facilitates the retrieval of default values and options for user interface and setup configurations. 11 | 12 | Attributes: 13 | __DEFAULT_PORT (int): Default port number used for UDP and TCP communication. 14 | __DEFAULT_WORKERS_COUNT (int): Default number of worker threads or processes. 15 | __DEFAULT_RECONNECT_ATTEMPT (int): Default number of heartbeat attempts before considering the connection lost. 16 | __DEFAULT_SERVER_MODE (list): Default server mode option and its boolean value. 17 | __SERVER_MODE_OPTIONS (list of lists): Available server mode options. 18 | __DEFAULT_AUDIO_DEVICE_TYPE (list): Default audio device type option and its boolean value. 19 | __AUDIO_DEVICE_OPTIONS (list of lists): Available audio device options. 20 | __DEFAULT_SSL (list): Default SSL option and its boolean value. 21 | __SSL_OPTIONS (list of lists): Available SSL options. 22 | __DEFAULT_HASH_CONTROL (list): Default hash control option and its boolean value. 23 | __HASH_CONTROL_OPTIONS (list of lists): Available integrity control options. 24 | """ 25 | __DEFAULT_PORT = 4415 26 | __DEFAULT_WORKERS_COUNT = 1 27 | __DEFAULT_RECONNECT_ATTEMPT = 5 28 | __DEFAULT_BUFFER_SIZE = 6144 29 | __DEFAULT_TIMEOUT = 5 30 | __DEFAULT_HEARTBEAT_DELAY = 2 31 | 32 | CONFIG_TITLE = 'echowarp_conf' 33 | BAN_LIST_FILE = 'echowarp_ban_list.txt' 34 | 35 | __DEFAULT_SERVER_MODE = [ 36 | 'Server mode (Mode in which the audio stream from the specified device is captured and ' 37 | 'sent to the client over the network)', 38 | True] 39 | __SERVER_MODE_OPTIONS = [ 40 | __DEFAULT_SERVER_MODE, 41 | ['Client mode (Mode in which the device receives an audio stream from the server over the network and ' 42 | 'plays it on the specified audio device)', False] 43 | ] 44 | 45 | __DEFAULT_SOCKET_BUFFER_SIZE = [f'{__DEFAULT_BUFFER_SIZE}', __DEFAULT_BUFFER_SIZE] 46 | __SOCKET_BUFFER_SIZE_OPTIONS = [ 47 | __DEFAULT_SOCKET_BUFFER_SIZE, 48 | ['Custom size', False] 49 | ] 50 | 51 | __DEFAULT_AUDIO_DEVICE_TYPE = ['Input audio device (like microphones)', True] 52 | __AUDIO_DEVICE_OPTIONS = [ 53 | __DEFAULT_AUDIO_DEVICE_TYPE, 54 | ['Output audio device (like speakers and headphones)', False] 55 | ] 56 | 57 | __DEFAULT_DEVICE_ENCODING_NAMES = [ 58 | f'Use standard charset encoding of OS for audio device names ({locale.getpreferredencoding()})', 59 | locale.getpreferredencoding()] 60 | __DEVICE_ENCODING_NAMES_OPTIONS = [ 61 | __DEFAULT_DEVICE_ENCODING_NAMES, 62 | ['Use custom charset encoding for audio device names', True] 63 | ] 64 | 65 | __DEFAULT_IGNORE_DEVICE_ENCODING_NAMES = ['Enable charset encoding audio device names', False] 66 | __IGNORE_DEVICE_ENCODING_NAMES_OPTIONS = [ 67 | __DEFAULT_IGNORE_DEVICE_ENCODING_NAMES, 68 | ['Disable charset encoding audio device names', True] 69 | ] 70 | 71 | __DEFAULT_IS_ERROR_LOG = ['Disable writing error log lines to file', False] 72 | __IS_ERROR_LOG_OPTIONS = [ 73 | __DEFAULT_IS_ERROR_LOG, 74 | ['Enable writing error log lines to file', True] 75 | ] 76 | 77 | __DEFAULT_SSL = ['Disable SSL (Encryption)', False] 78 | __SSL_OPTIONS = [ 79 | ['Enable SSL (Encryption)', True], 80 | __DEFAULT_SSL 81 | ] 82 | 83 | __DEFAULT_HASH_CONTROL = ['Disable integrity control of sending data', False] 84 | __HASH_CONTROL_OPTIONS = [ 85 | ['Enable integrity control of sending data', True], 86 | __DEFAULT_HASH_CONTROL 87 | ] 88 | 89 | __DEFAULT_SAVE_PROFILE = ['Skip saving selected params', False] 90 | __SAVE_PROFILE_OPTIONS = [ 91 | ['Save selected params to config file', True], 92 | __DEFAULT_SAVE_PROFILE 93 | ] 94 | 95 | @staticmethod 96 | def get_variants_config_load_options_data() -> OptionsData: 97 | return OptionsData( 98 | ["Load config from file", True], 99 | [ 100 | ["Load config from file", True], 101 | ["Skip configs", False] 102 | ] 103 | ) 104 | 105 | @staticmethod 106 | def get_socket_buffer_size_options_data() -> OptionsData: 107 | return OptionsData( 108 | DefaultValuesAndOptions.__DEFAULT_SOCKET_BUFFER_SIZE, 109 | DefaultValuesAndOptions.__SOCKET_BUFFER_SIZE_OPTIONS 110 | ) 111 | 112 | @staticmethod 113 | def get_util_mods_options_data() -> OptionsData: 114 | return OptionsData( 115 | DefaultValuesAndOptions.__DEFAULT_SERVER_MODE, 116 | DefaultValuesAndOptions.__SERVER_MODE_OPTIONS 117 | ) 118 | 119 | @staticmethod 120 | def get_audio_device_type_options_data() -> OptionsData: 121 | return OptionsData( 122 | DefaultValuesAndOptions.__DEFAULT_AUDIO_DEVICE_TYPE, 123 | DefaultValuesAndOptions.__AUDIO_DEVICE_OPTIONS 124 | ) 125 | 126 | @staticmethod 127 | def get_encoding_charset_options_data() -> OptionsData: 128 | return OptionsData( 129 | DefaultValuesAndOptions.__DEFAULT_DEVICE_ENCODING_NAMES, 130 | DefaultValuesAndOptions.__DEVICE_ENCODING_NAMES_OPTIONS 131 | ) 132 | 133 | @staticmethod 134 | def get_ignore_encoding_options_data() -> OptionsData: 135 | return OptionsData( 136 | DefaultValuesAndOptions.__DEFAULT_IGNORE_DEVICE_ENCODING_NAMES, 137 | DefaultValuesAndOptions.__IGNORE_DEVICE_ENCODING_NAMES_OPTIONS 138 | ) 139 | 140 | @staticmethod 141 | def get_error_log_options_data() -> OptionsData: 142 | return OptionsData( 143 | DefaultValuesAndOptions.__DEFAULT_IS_ERROR_LOG, 144 | DefaultValuesAndOptions.__IS_ERROR_LOG_OPTIONS 145 | ) 146 | 147 | @staticmethod 148 | def get_ssl_options_data() -> OptionsData: 149 | return OptionsData( 150 | DefaultValuesAndOptions.__DEFAULT_SSL, 151 | DefaultValuesAndOptions.__SSL_OPTIONS 152 | ) 153 | 154 | @staticmethod 155 | def get_hash_control_options_data() -> OptionsData: 156 | return OptionsData( 157 | DefaultValuesAndOptions.__DEFAULT_HASH_CONTROL, 158 | DefaultValuesAndOptions.__HASH_CONTROL_OPTIONS 159 | ) 160 | 161 | @staticmethod 162 | def get_save_profile_options_data() -> OptionsData: 163 | return OptionsData( 164 | DefaultValuesAndOptions.__DEFAULT_SAVE_PROFILE, 165 | DefaultValuesAndOptions.__SAVE_PROFILE_OPTIONS 166 | ) 167 | 168 | @staticmethod 169 | def get_default_port() -> int: 170 | return DefaultValuesAndOptions.__DEFAULT_PORT 171 | 172 | @staticmethod 173 | def get_default_reconnect_attempt() -> int: 174 | return DefaultValuesAndOptions.__DEFAULT_RECONNECT_ATTEMPT 175 | 176 | @staticmethod 177 | def get_default_workers() -> int: 178 | return DefaultValuesAndOptions.__DEFAULT_WORKERS_COUNT 179 | 180 | @staticmethod 181 | def get_util_comparability_version() -> str: 182 | return version.__comparability_version__ 183 | 184 | @staticmethod 185 | def get_heartbeat_delay() -> float: 186 | return DefaultValuesAndOptions.__DEFAULT_HEARTBEAT_DELAY 187 | 188 | @staticmethod 189 | def get_timeout() -> int: 190 | return DefaultValuesAndOptions.__DEFAULT_TIMEOUT 191 | -------------------------------------------------------------------------------- /echowarp/models/json_message.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from dataclasses import dataclass 4 | from typing import Optional 5 | 6 | from echowarp.models.default_values_and_options import DefaultValuesAndOptions 7 | 8 | 9 | @dataclass 10 | class ResponseMessage: 11 | response_code: int 12 | response_message: str 13 | 14 | 15 | class JSONMessage: 16 | """ 17 | Encapsulates the structure of JSON messages used for communication between the client and server 18 | in the EchoWarp application. 19 | Provides methods for encoding and decoding these messages. 20 | 21 | Attributes: 22 | message (str): The main content of the message. 23 | response_code (int): An HTTP-like response code indicating the status of the message. 24 | version (float): The version of the utility compatible with this message format. 25 | """ 26 | _json_message: dict 27 | 28 | message: str 29 | response_code: int 30 | version: str 31 | failed_connections: int 32 | reconnect_attempts: Optional[int] 33 | 34 | OK_MESSAGE = ResponseMessage(200, "OK") 35 | ACCEPTED_MESSAGE = ResponseMessage(202, "Accepted") 36 | UNAUTHORIZED_MESSAGE = ResponseMessage(401, "Unauthorized") 37 | FORBIDDEN_MESSAGE = ResponseMessage(403, "Forbidden") 38 | CONFLICT_MESSAGE = ResponseMessage(409, "Conflict") 39 | LOCKED_MESSAGE = ResponseMessage(423, "Locked") 40 | 41 | _MESSAGE_KEY = "message" 42 | _RESPONSE_CODE = "response_code" 43 | _COMPARABILITY_VERSION_KEY = "comparability_version" 44 | _FAILED_CONNECTIONS_KEY = "failed_connections" 45 | _RECONNECT_ATTEMPTS_KEY = "reconnect_attempts" 46 | 47 | def __init__(self, json_bytes: bytes): 48 | """ 49 | Initializes a new instance of JSONMessage from a byte array containing a JSON string. 50 | 51 | Args: 52 | json_bytes (bytes): A byte array containing the JSON-encoded string. 53 | 54 | Raises: 55 | Exception: If there is an error decoding the JSON data. 56 | """ 57 | try: 58 | self._json_message = json.loads(json_bytes.decode('utf-8')) 59 | 60 | self.message = self._json_message[self._MESSAGE_KEY] 61 | self.response_code = self._json_message[self._RESPONSE_CODE] 62 | self.version = self._json_message[self._COMPARABILITY_VERSION_KEY] 63 | self.failed_connections = self._json_message[self._FAILED_CONNECTIONS_KEY] 64 | self.reconnect_attempts = self._json_message[self._RECONNECT_ATTEMPTS_KEY] 65 | except Exception as e: 66 | logging.error(f"Decode json message error: {e}") 67 | raise 68 | 69 | @staticmethod 70 | def encode_message_to_json_bytes(message: str, response_code: int, 71 | failed_connections: Optional[int], reconnect_attempts: Optional[int]) -> bytes: 72 | """ 73 | Encodes a message with its response code and version into a byte array containing JSON data. 74 | 75 | Args: 76 | message (str): The main content of the message. 77 | response_code (int): The HTTP-like response code. 78 | failed_connections (int): Failed connections. 79 | reconnect_attempts (int): Reconnect attempts. 80 | 81 | Returns: 82 | bytes: A byte array containing the JSON-encoded message. 83 | """ 84 | if reconnect_attempts is not None and reconnect_attempts <= 0: 85 | reconnect_attempts = None 86 | 87 | message = { 88 | JSONMessage._MESSAGE_KEY: message, 89 | JSONMessage._RESPONSE_CODE: response_code, 90 | JSONMessage._COMPARABILITY_VERSION_KEY: DefaultValuesAndOptions.get_util_comparability_version(), 91 | JSONMessageServer._FAILED_CONNECTIONS_KEY: failed_connections, 92 | JSONMessageServer._RECONNECT_ATTEMPTS_KEY: reconnect_attempts, 93 | } 94 | 95 | return json.dumps(message).encode('utf-8') 96 | 97 | 98 | class JSONMessageServer(JSONMessage): 99 | """ 100 | Extends JSONMessage to include server-specific configuration settings such as heartbeat attempts, 101 | encryption status, and integrity control settings. 102 | 103 | Attributes: 104 | is_encrypt (bool): Indicates if encryption is enabled for the communication. 105 | is_integrity_control (bool): Indicates if data integrity checks are enabled. 106 | aes_key_base64 (bytes): AES key used for encryption, provided as a base64-encoded string. 107 | aes_iv_base64 (bytes): AES initialization vector used for encryption, provided as a base64-encoded string. 108 | """ 109 | is_encrypt: bool 110 | is_integrity_control: bool 111 | aes_key_base64: str 112 | aes_iv_base64: str 113 | 114 | _CONFIGS_KEY = "config" 115 | _IS_ENCRYPT_KEY = "is_encrypt" 116 | _IS_INTEGRITY_CONTROL_KEY = "is_integrity_control" 117 | _AES_KEY = "aes_key" 118 | _AES_IV_KEY = "aes_iv" 119 | 120 | def __init__(self, json_bytes: bytes): 121 | """ 122 | Initializes a new instance of JSONMessageServer from a byte array containing a JSON string with server-specific settings. 123 | 124 | Args: 125 | json_bytes (bytes): A byte array containing the JSON-encoded string. 126 | 127 | Raises: 128 | ValueError: If the configuration keys are missing in the JSON data. 129 | Exception: If there is an error decoding the JSON data. 130 | """ 131 | super().__init__(json_bytes) 132 | 133 | if self._CONFIGS_KEY in self._json_message: 134 | try: 135 | self.is_encrypt = self._json_message[self._CONFIGS_KEY][self._IS_ENCRYPT_KEY] 136 | self.is_integrity_control = self._json_message[self._CONFIGS_KEY][self._IS_INTEGRITY_CONTROL_KEY] 137 | self.aes_key_base64 = self._json_message[self._CONFIGS_KEY][self._AES_KEY] 138 | self.aes_iv_base64 = self._json_message[self._CONFIGS_KEY][self._AES_IV_KEY] 139 | except Exception as e: 140 | raise Exception(f"Decode json message error: {e}") 141 | else: 142 | raise ValueError(self._CONFIGS_KEY + " not in json config message") 143 | 144 | @staticmethod 145 | def encode_server_config_to_json_bytes(is_encrypt: bool, is_hash_control: bool, 146 | aes_key_base64: str, aes_iv_base64: str, 147 | failed_connections: int, reconnect_attempts: int) -> bytes: 148 | """ 149 | Encodes server configuration into a byte array containing JSON data. 150 | 151 | Args: 152 | is_encrypt (bool): Enable or disable encryption. 153 | is_hash_control (bool): Enable or disable integrity control. 154 | aes_key_base64 (str): AES key in Base64 for encryption. 155 | aes_iv_base64 (str): AES initialization vector in Base64. 156 | failed_connections (int): Failed connections if client. 157 | reconnect_attempts (int): Max reconnect attempts on server. 158 | 159 | Returns: 160 | bytes: A byte array containing the JSON-encoded server configuration. 161 | """ 162 | if reconnect_attempts <= 0: 163 | reconnect_attempts = None 164 | 165 | config = { 166 | JSONMessageServer._MESSAGE_KEY: JSONMessageServer.OK_MESSAGE.response_message, 167 | JSONMessageServer._RESPONSE_CODE: JSONMessageServer.OK_MESSAGE.response_code, 168 | JSONMessageServer._COMPARABILITY_VERSION_KEY: DefaultValuesAndOptions.get_util_comparability_version(), 169 | JSONMessageServer._FAILED_CONNECTIONS_KEY: failed_connections, 170 | JSONMessageServer._RECONNECT_ATTEMPTS_KEY: reconnect_attempts, 171 | 172 | JSONMessageServer._CONFIGS_KEY: { 173 | JSONMessageServer._IS_ENCRYPT_KEY: is_encrypt, 174 | JSONMessageServer._IS_INTEGRITY_CONTROL_KEY: is_hash_control, 175 | JSONMessageServer._AES_KEY: aes_key_base64, 176 | JSONMessageServer._AES_IV_KEY: aes_iv_base64, 177 | } 178 | } 179 | 180 | return json.dumps(config).encode('utf-8') 181 | -------------------------------------------------------------------------------- /echowarp/models/net_interfaces_info.py: -------------------------------------------------------------------------------- 1 | import os 2 | import socket 3 | from dataclasses import dataclass 4 | from typing import List, Tuple 5 | 6 | import psutil 7 | 8 | 9 | @dataclass 10 | class InterfaceInfo: 11 | interface_name: str 12 | ip_address: str 13 | dns: str 14 | 15 | 16 | class NetInterfacesInfo: 17 | __port: int 18 | __interface_info_list: List[InterfaceInfo] 19 | __is_different_dns: bool 20 | 21 | def __init__(self, port: int): 22 | self.__port = port 23 | self.__interface_info_list, self.__is_different_dns = self.__get_net_interfaces_list() 24 | 25 | def get_formatted_info_str(self) -> str: 26 | if len(self.__interface_info_list) == 1: 27 | formatted_str = [ 28 | (f'Interface name: {interface.interface_name}, ' 29 | f'IP address: {interface.ip_address}:{self.__port}, ' 30 | f'DNS: {interface.dns}:{self.__port}') 31 | for interface in self.__interface_info_list 32 | ] 33 | else: 34 | if self.__is_different_dns: 35 | formatted_str = [ 36 | (f'Interface name: {interface.interface_name}, ' 37 | f'IP address: {interface.ip_address}:{self.__port}, ' 38 | f'DNS: {interface.dns}:{self.__port}') 39 | for interface in self.__interface_info_list 40 | ] 41 | else: 42 | formatted_str = [ 43 | (f'Interface name: {interface.interface_name}, ' 44 | f'IP address: {interface.ip_address}:{self.__port}') 45 | for interface in self.__interface_info_list 46 | ] 47 | formatted_str.append(f'DNS: {socket.getfqdn()}:{self.__port}') 48 | 49 | return os.linesep.join(formatted_str) 50 | 51 | @staticmethod 52 | def __get_net_interfaces_list() -> Tuple[List[InterfaceInfo], bool]: 53 | addresses = psutil.net_if_addrs() 54 | interface_info_list: List[InterfaceInfo] = [] 55 | default_dns = socket.getfqdn() 56 | 57 | for interface_name, interface_addresses in addresses.items(): 58 | for address in interface_addresses: 59 | if address.family == socket.AddressFamily.AF_INET: 60 | ip_address = address.address 61 | if (ip_address == '127.0.0.1' or 62 | (ip_address.startswith('169.254.') and address.netmask == '255.255.0.0')): 63 | continue 64 | 65 | try: 66 | dns_name = socket.gethostbyaddr(ip_address)[0] 67 | except socket.herror: 68 | dns_name = None 69 | 70 | if dns_name is None or dns_name.strip() == '': 71 | dns_name = default_dns 72 | 73 | interface_info_list.append(InterfaceInfo(interface_name, ip_address, dns_name)) 74 | 75 | return interface_info_list, any(interface.dns != default_dns for interface in interface_info_list) 76 | -------------------------------------------------------------------------------- /echowarp/models/options_data_creater.py: -------------------------------------------------------------------------------- 1 | from typing import List, Any 2 | 3 | 4 | class OptionsFormatter: 5 | option_descr: str 6 | option_value: Any 7 | 8 | def __init__(self, option_list: List): 9 | self.option_descr = option_list[0] 10 | self.option_value = option_list[1] 11 | 12 | 13 | class OptionsData: 14 | default_descr: str 15 | default_value: Any 16 | options: List[OptionsFormatter] 17 | 18 | def __init__(self, default_value_list: List, options: List[List]): 19 | self.default_descr = default_value_list[0] 20 | self.default_value = default_value_list[1] 21 | 22 | self.options = [OptionsFormatter(option) for option in options] 23 | -------------------------------------------------------------------------------- /echowarp/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lHumaNl/EchoWarp/62dbb95aea201e9e874fc83720ff93d61f6c74f7/echowarp/services/__init__.py -------------------------------------------------------------------------------- /echowarp/services/crypto_manager.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import logging 3 | import os 4 | import hashlib 5 | from typing import Tuple, Optional 6 | 7 | from cryptography.hazmat.backends import default_backend 8 | from cryptography.hazmat.primitives.asymmetric import rsa, padding 9 | from cryptography.hazmat.primitives import serialization, hashes 10 | from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes 11 | from cryptography.hazmat.primitives import padding as sym_padding 12 | 13 | 14 | class CryptoManager: 15 | """ 16 | Manages cryptographic operations such as RSA and AES encryption/decryption for secure communication in EchoWarp. 17 | 18 | Attributes: 19 | __is_server (bool): True if this instance is configured for server-side operations. 20 | is_ssl (bool): Determines if encryption is enabled. 21 | is_integrity_control (bool): Determines if integrity control via hashing is enabled. 22 | __private_key (rsa.RSAPrivateKey): The RSA private key for decryption. 23 | __public_key (rsa.RSAPublicKey): The RSA public key for encryption. 24 | __aes_key (Optional[bytes]): The AES key for symmetric encryption, used if encryption is enabled. 25 | __aes_iv (Optional[bytes]): The AES IV for symmetric encryption. 26 | __peer_public_key (Optional[rsa.RSAPublicKey]): The public key of the communication peer, 27 | for encrypted communications. 28 | """ 29 | __is_server: bool 30 | is_ssl: Optional[bool] 31 | is_integrity_control: Optional[bool] 32 | __private_key: rsa.RSAPrivateKey 33 | __public_key: rsa.RSAPublicKey 34 | __aes_key: Optional[bytes] 35 | __aes_iv: Optional[bytes] 36 | __peer_public_key: rsa.RSAPublicKey 37 | 38 | def __init__(self, is_server: bool, is_integrity_control: bool, is_ssl: bool): 39 | """ 40 | Initializes a new CryptoManager instance with the specified settings. 41 | 42 | Args: 43 | is_server (bool): Indicates if this instance is used by a server. 44 | is_integrity_control (bool): Enable or disable integrity control via hashing. 45 | is_ssl (bool): Enable or disable encryption. 46 | """ 47 | self.__is_server = is_server 48 | self.is_ssl = is_ssl 49 | self.is_integrity_control = is_integrity_control 50 | 51 | self.__private_key, self.__public_key = self.__generate_and_get_rsa_keys() 52 | self.__aes_key, self.__aes_iv = self.__generate_and_get_aes_key_and_iv() if is_server else (None, None) 53 | 54 | def load_encryption_config_for_client(self, is_encrypt: bool, is_hash_control: bool): 55 | if self.__is_server: 56 | raise ValueError("Load encryption config applied only for client") 57 | 58 | self.is_ssl = is_encrypt 59 | self.is_integrity_control = is_hash_control 60 | 61 | def encrypt_aes_and_sign_data(self, data: bytes) -> bytes: 62 | """ 63 | Encrypts and optionally signs data with a hash for integrity. 64 | 65 | Args: 66 | data (bytes): Data to encrypt and sign. 67 | 68 | Returns: 69 | bytes: Encrypted (and optionally signed) data. 70 | 71 | Raises: 72 | ValueError: If data is None. 73 | Exception: General exceptions during encryption, logged as an error. 74 | """ 75 | if data is None: 76 | raise ValueError("Data to encrypt and sign cannot be None") 77 | 78 | if self.is_integrity_control: 79 | data = self.__calculate_hash_to_data(data) 80 | 81 | if self.is_ssl: 82 | try: 83 | data = self.__encrypt_data_aes(data) 84 | except Exception as e: 85 | logging.error(f"Failed to encrypt data: {e}") 86 | raise 87 | 88 | return data 89 | 90 | def decrypt_aes_and_verify_data(self, data: bytes) -> bytes: 91 | """ 92 | Decrypts and optionally verifies data integrity using a hash. 93 | 94 | Args: 95 | data (bytes): Data to decrypt and verify. 96 | 97 | Returns: 98 | bytes: Decrypted data, with integrity optionally verified. 99 | 100 | Raises: 101 | ValueError: If data is None. 102 | Exception: General exceptions during decryption, logged as an error. 103 | """ 104 | if data is None: 105 | raise ValueError("Data to decrypt and verify cannot be None") 106 | 107 | if self.is_ssl: 108 | try: 109 | data = self.__decrypt_data_aes(data) 110 | except Exception as e: 111 | logging.error(f"Failed to decrypt data: {e}") 112 | raise 113 | 114 | if self.is_integrity_control: 115 | data = self.__compare_hash_and_get_data(data) 116 | 117 | return data 118 | 119 | @staticmethod 120 | def __generate_and_get_rsa_keys() -> Tuple[rsa.RSAPrivateKey, rsa.RSAPublicKey]: 121 | """ 122 | Generates a pair of RSA keys. 123 | 124 | Returns: 125 | A tuple containing the private key and public key. 126 | """ 127 | private_key = rsa.generate_private_key( 128 | public_exponent=65537, 129 | key_size=4096, 130 | backend=default_backend() 131 | ) 132 | public_key = private_key.public_key() 133 | 134 | return private_key, public_key 135 | 136 | def get_serialized_public_key(self): 137 | return self.__public_key.public_bytes( 138 | encoding=serialization.Encoding.PEM, 139 | format=serialization.PublicFormat.SubjectPublicKeyInfo 140 | ) 141 | 142 | def load_peer_public_key(self, pem_public_key): 143 | """ 144 | Loads and stores the peer's public key from PEM format. 145 | 146 | Args: 147 | pem_public_key: The peer's public key in PEM format. 148 | """ 149 | self.__peer_public_key = serialization.load_pem_public_key( 150 | pem_public_key, 151 | backend=default_backend() 152 | ) 153 | 154 | def encrypt_rsa_message(self, message): 155 | """ 156 | Encrypts a message using RSA encryption with the public key of a peer. 157 | 158 | Args: 159 | message (bytes): The plaintext message to be encrypted. 160 | 161 | Returns: 162 | bytes: The ciphertext resulting from encrypting the input message with RSA. 163 | """ 164 | return self.__peer_public_key.encrypt( 165 | message, 166 | padding.OAEP( 167 | mgf=padding.MGF1(algorithm=hashes.SHA256()), 168 | algorithm=hashes.SHA256(), 169 | label=None 170 | ) 171 | ) 172 | 173 | def decrypt_rsa_message(self, encrypted_message): 174 | """ 175 | Decrypts an RSA encrypted message using the private key of this instance. 176 | 177 | Args: 178 | encrypted_message (bytes): The encrypted message to be decrypted. 179 | 180 | Returns: 181 | bytes: The plaintext resulting from decrypting the input message. 182 | """ 183 | 184 | return self.__private_key.decrypt( 185 | encrypted_message, 186 | padding.OAEP( 187 | mgf=padding.MGF1(algorithm=hashes.SHA256()), 188 | algorithm=hashes.SHA256(), 189 | label=None 190 | ) 191 | ) 192 | 193 | @staticmethod 194 | def __generate_and_get_aes_key_and_iv() -> Tuple[bytes, bytes]: 195 | """ 196 | Generates a new AES key and IV for symmetric encryption. 197 | """ 198 | aes_key = os.urandom(32) 199 | aes_iv = os.urandom(16) 200 | 201 | return aes_key, aes_iv 202 | 203 | def get_aes_key_base64(self) -> str: 204 | """ 205 | Returns the AES key in Base64. 206 | """ 207 | 208 | return base64.b64encode(self.__aes_key).decode('utf-8') 209 | 210 | def get_aes_iv_base64(self) -> str: 211 | """ 212 | Returns the AES IV in Base64. 213 | """ 214 | 215 | return base64.b64encode(self.__aes_iv).decode('utf-8') 216 | 217 | def load_aes_key_and_iv(self, aes_key_base64: str, aes_iv_base64: str): 218 | """ 219 | Loads the AES key and IV from peer. 220 | """ 221 | if self.__is_server: 222 | logging.error("Only client can load AES key and IV") 223 | raise ValueError 224 | 225 | self.__aes_key = base64.b64decode(aes_key_base64) 226 | self.__aes_iv = base64.b64decode(aes_iv_base64) 227 | 228 | def __encrypt_data_aes(self, data): 229 | """ 230 | Encrypts data using AES. 231 | 232 | Args: 233 | data: The plaintext data to encrypt. 234 | 235 | Returns: 236 | The encrypted data. 237 | """ 238 | padder = sym_padding.PKCS7(128).padder() 239 | padded_data = padder.update(data) + padder.finalize() 240 | 241 | encryptor = Cipher(algorithms.AES(self.__aes_key), modes.CBC(self.__aes_iv)).encryptor() 242 | 243 | return encryptor.update(padded_data) + encryptor.finalize() 244 | 245 | def __decrypt_data_aes(self, encrypted_data): 246 | """ 247 | Decrypts data using AES. 248 | 249 | Args: 250 | encrypted_data: The encrypted data to decrypt. 251 | 252 | Returns: 253 | The decrypted plaintext data. 254 | """ 255 | decryptor = Cipher(algorithms.AES(self.__aes_key), modes.CBC(self.__aes_iv)).decryptor() 256 | padded_data = decryptor.update(encrypted_data) + decryptor.finalize() 257 | 258 | unpadder = sym_padding.PKCS7(128).unpadder() 259 | 260 | return unpadder.update(padded_data) + unpadder.finalize() 261 | 262 | @staticmethod 263 | def __calculate_hash_to_data(data: bytes) -> bytes: 264 | hasher = hashlib.sha256() 265 | hasher.update(data) 266 | data_hash = hasher.digest() 267 | message = data_hash + data 268 | 269 | return message 270 | 271 | @staticmethod 272 | def __compare_hash_and_get_data(message: bytes) -> bytes: 273 | received_hash = message[:32] 274 | data = message[32:] 275 | hasher = hashlib.sha256() 276 | hasher.update(data) 277 | calculated_hash = hasher.digest() 278 | 279 | if received_hash != calculated_hash: 280 | logging.error("Data integrity check failed") 281 | raise ValueError 282 | else: 283 | return data 284 | -------------------------------------------------------------------------------- /echowarp/settings.py: -------------------------------------------------------------------------------- 1 | from concurrent.futures import ThreadPoolExecutor 2 | from typing import Optional 3 | 4 | from echowarp.services.crypto_manager import CryptoManager 5 | from echowarp.models.audio_device import AudioDevice 6 | 7 | 8 | class Settings: 9 | """ 10 | Configuration settings for the EchoWarp application, which can run in either server or client mode. 11 | 12 | Attributes: 13 | is_server (bool): True if the instance is configured as a server, otherwise False for a client. 14 | udp_port (int): The UDP port used for audio streaming. 15 | server_address (Optional[str]): The address of the server (only relevant in client mode). 16 | reconnect_attempt (int): The number of allowed missed reconnects before the connection is considered lost. 17 | audio_device (AudioDevice): The audio device configuration. 18 | password (Optional[str]): The password for authentication. 19 | crypto_manager (Optional[CryptoManager]): Manages cryptographic operations, optional for non-secure mode. 20 | executor (ThreadPoolExecutor): Executor for managing concurrent tasks. 21 | is_error_log (bool): Indicates if error logging is enabled. 22 | socket_buffer_size (int): The buffer size for the socket. 23 | """ 24 | is_server: bool 25 | udp_port: int 26 | server_address: Optional[str] 27 | reconnect_attempt: int 28 | audio_device: AudioDevice 29 | password: Optional[str] 30 | crypto_manager: Optional[CryptoManager] 31 | executor: ThreadPoolExecutor 32 | is_error_log: bool 33 | socket_buffer_size: int 34 | 35 | def __init__(self, is_server: bool, udp_port: int, server_address: Optional[str], reconnect_attempt: int, 36 | is_ssl: bool, is_integrity_control: bool, workers: int, audio_device: AudioDevice, 37 | password: Optional[str], is_error_log: bool, socket_buffer_size: int): 38 | """ 39 | Initializes the settings for the EchoWarp application. 40 | 41 | Args: 42 | is_server (bool): Specifies if the settings are for a server. 43 | udp_port (int): Port number for UDP communication. 44 | server_address (Optional[str]): Server address, necessary in client mode. 45 | reconnect_attempt (int): Number of reconnect attempts before considering a disconnect. 46 | is_ssl (bool): Enables SSL for secure communication. 47 | is_integrity_control (bool): Enables integrity control using hashing. 48 | workers (int): Number of worker threads for handling concurrent operations. 49 | audio_device (AudioDevice): Configured audio device. 50 | password (Optional[str]): Password for authentication. 51 | is_error_log (bool): Indicates if error logging is enabled. 52 | socket_buffer_size (int): Buffer size for the socket. 53 | """ 54 | self.is_server = is_server 55 | self.udp_port = udp_port 56 | self.server_address = server_address 57 | self.reconnect_attempt = reconnect_attempt 58 | self.audio_device = audio_device 59 | self.password = password 60 | self.crypto_manager = CryptoManager(self.is_server, is_integrity_control, is_ssl) 61 | self.executor = ThreadPoolExecutor(max_workers=workers) 62 | self.is_error_log = is_error_log 63 | self.socket_buffer_size = socket_buffer_size 64 | -------------------------------------------------------------------------------- /echowarp/start_modes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lHumaNl/EchoWarp/62dbb95aea201e9e874fc83720ff93d61f6c74f7/echowarp/start_modes/__init__.py -------------------------------------------------------------------------------- /echowarp/start_modes/args_mode.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | 4 | from echowarp.logging_config import Logger 5 | from echowarp.models.audio_device import AudioDevice 6 | from echowarp.models.default_values_and_options import DefaultValuesAndOptions 7 | from echowarp.settings import Settings 8 | from echowarp.start_modes.config_parser import ConfigParser 9 | 10 | 11 | class ArgsParser: 12 | @staticmethod 13 | def get_settings_from_cli_args() -> Settings: 14 | """ 15 | Parses command line arguments to configure the EchoWarp utility settings. This function 16 | constructs the settings based on the provided command line arguments, handling different modes 17 | and configurations such as server/client mode, audio device selection, and network settings. 18 | 19 | Returns: 20 | Settings: An instance of the Settings class populated with values derived from parsed command line arguments. 21 | 22 | Raises: 23 | argparse.ArgumentError: If there are issues with the provided arguments, such as missing required arguments 24 | or invalid values. 25 | """ 26 | parser = argparse.ArgumentParser(description="EchoWarp Audio Streamer") 27 | 28 | parser.add_argument("-c", "--client", action='store_false', 29 | help=f"Start util in client mode. " 30 | f"(default={DefaultValuesAndOptions.get_util_mods_options_data().default_descr})") 31 | parser.add_argument( 32 | "-o", "--output", action='store_false', 33 | help=f"Use output audio device.{os.linesep}" 34 | f"(default={DefaultValuesAndOptions.get_audio_device_type_options_data().default_descr})" 35 | ) 36 | parser.add_argument("-u", "--udp_port", type=int, default=DefaultValuesAndOptions.get_default_port(), 37 | help=f"UDP port for audio streaming. " 38 | f"(default={DefaultValuesAndOptions.get_default_port()})") 39 | parser.add_argument( 40 | "-b", "--socket_buffer_size", type=int, 41 | default=DefaultValuesAndOptions.get_socket_buffer_size_options_data().default_value, 42 | help=f"Size of socket buffer. " 43 | f"(default={DefaultValuesAndOptions.get_socket_buffer_size_options_data().default_value})" 44 | ) 45 | parser.add_argument("-d", "--device_id", type=int, 46 | help="Specify the device ID to bypass interactive selection.") 47 | parser.add_argument("-p", "--password", type=str, help="Password, if needed.") 48 | parser.add_argument("-f", "--config_file", type=str, help="Path to config file " 49 | "(Ignoring other args, if they added).") 50 | parser.add_argument("-e", "--device_encoding_names", type=str, 51 | help=f"Charset encoding for audio device. " 52 | f"(default=preferred system encoding - " 53 | f"{DefaultValuesAndOptions.get_encoding_charset_options_data().default_value})", 54 | default=DefaultValuesAndOptions.get_encoding_charset_options_data().default_value) 55 | parser.add_argument("--ignore_device_encoding_names", action='store_true', 56 | help="Ignoring device names encoding.") 57 | 58 | parser.add_argument("-l", "--is_error_log", action='store_true', help="Init error file logger.") 59 | parser.add_argument("-r", "--reconnect", type=int, 60 | default=DefaultValuesAndOptions.get_default_reconnect_attempt(), 61 | help=f"The number of failed connections in client mode before closing the application. " 62 | f"The number of failed client authorization attempts in server mode " 63 | f"before banning the client. (0 = infinite). " 64 | f"(default={DefaultValuesAndOptions.get_default_reconnect_attempt()})") 65 | 66 | parser.add_argument("-s", "--save_config", type=str, help="Save config file from selected args " 67 | "(Ignored default values)") 68 | 69 | client_args = parser.add_argument_group('Client Only Options') 70 | client_args.add_argument("-a", "--server_address", type=str, help="Server address.") 71 | 72 | server_args = parser.add_argument_group('Server Only Options') 73 | server_args.add_argument("--ssl", action='store_true', 74 | help=f"Init SSL (Encryption) mode (server mode only). " 75 | f"(default={DefaultValuesAndOptions.get_ssl_options_data().default_descr})") 76 | server_args.add_argument( 77 | "-i", "--integrity_control", action='store_true', 78 | help=f"Init integrity control of sending data (server mode only). " 79 | f"(default={DefaultValuesAndOptions.get_hash_control_options_data().default_descr})" 80 | ) 81 | server_args.add_argument("-w", "--workers", type=int, 82 | help="Max workers in multithreading (server mode only).", 83 | default=DefaultValuesAndOptions.get_default_workers()) 84 | 85 | args = parser.parse_args() 86 | 87 | if args.is_error_log: 88 | Logger.init_warning_logger() 89 | 90 | if args.config_file is not None: 91 | return ArgsParser.__load_from_config(args.config_file) 92 | 93 | if not args.client: 94 | if any([args.ssl, args.integrity_control]) and args.workers != 1: 95 | parser.error("Options --ssl, --integrity_control and --workers are only available in server mode.") 96 | if not args.server_address: 97 | parser.error("The --server_address argument is required in client mode.") 98 | else: 99 | if args.server_address: 100 | parser.error("The --server_address argument is only valid in client mode.") 101 | 102 | if args.ignore_device_encoding_names: 103 | args.device_encoding_names = DefaultValuesAndOptions.get_encoding_charset_options_data().default_value 104 | 105 | audio_device = AudioDevice(args.output, 106 | args.device_id, 107 | args.ignore_device_encoding_names, 108 | args.device_encoding_names) 109 | 110 | settings = Settings( 111 | args.client, 112 | args.udp_port, 113 | args.server_address, 114 | args.reconnect, 115 | args.ssl, 116 | args.integrity_control, 117 | args.workers, 118 | audio_device, 119 | args.password, 120 | args.is_error_log, 121 | args.socket_buffer_size 122 | ) 123 | 124 | if args.save_config is not None: 125 | ConfigParser(filename=args.save_config, settings=settings) 126 | 127 | return settings 128 | 129 | @staticmethod 130 | def __load_from_config(filepath: str) -> Settings: 131 | configs = ConfigParser(filename=filepath) 132 | audio_device = AudioDevice(configs.is_input_audio_device, 133 | configs.device_id, 134 | configs.ignore_device_encoding_names, 135 | configs.device_encoding_names) 136 | 137 | return Settings( 138 | configs.is_server, 139 | configs.udp_port, 140 | configs.server_address, 141 | configs.reconnect_attempt, 142 | configs.is_ssl, 143 | configs.is_integrity_control, 144 | configs.workers, 145 | audio_device, 146 | configs.password, 147 | configs.is_error_log, 148 | configs.socket_buffer_size 149 | ) 150 | -------------------------------------------------------------------------------- /echowarp/start_modes/config_parser.py: -------------------------------------------------------------------------------- 1 | import json 2 | import locale 3 | import logging 4 | import os 5 | import sys 6 | import time 7 | from typing import Optional, List, Dict, get_type_hints 8 | import configparser 9 | 10 | from echowarp.logging_config import Logger 11 | from echowarp.models.default_values_and_options import DefaultValuesAndOptions 12 | from echowarp.settings import Settings 13 | 14 | 15 | class ConfigParser: 16 | is_server: bool 17 | is_input_audio_device: bool 18 | udp_port: int 19 | socket_buffer_size: int 20 | device_id: Optional[int] 21 | password: Optional[str] 22 | device_encoding_names: str 23 | ignore_device_encoding_names: bool 24 | is_error_log: bool 25 | server_address: Optional[str] 26 | reconnect_attempt: int 27 | is_ssl: bool 28 | is_integrity_control: bool 29 | workers: int 30 | 31 | __IS_SERVER = ['is_server', 32 | DefaultValuesAndOptions.get_util_mods_options_data().default_value] 33 | __IS_INPUT_DEVICE = ['is_input_audio_device', 34 | DefaultValuesAndOptions.get_audio_device_type_options_data().default_value] 35 | __UDP_PORT = ['udp_port', 36 | DefaultValuesAndOptions.get_default_port()] 37 | __SOCKET_BUFFER_SIZE = ['socket_buffer_size', 38 | DefaultValuesAndOptions.get_socket_buffer_size_options_data().default_value] 39 | __DEVICE_ID = ['device_id', 40 | None] 41 | __PASSWORD = ['password', 42 | None] 43 | __DEVICE_ENCODING_NAMES = ['device_encoding_names', 44 | DefaultValuesAndOptions.get_encoding_charset_options_data().default_value] 45 | __IGNORE_DEVICE_ENCODING_NAMES = ['ignore_device_encoding_names', 46 | DefaultValuesAndOptions.get_ignore_encoding_options_data().default_value] 47 | __IS_ERROR_LOG = ['is_error_log', 48 | DefaultValuesAndOptions.get_error_log_options_data().default_value] 49 | 50 | __SERVER_ADDRESS = ['server_address', 51 | None] 52 | __RECONNECT_ATTEMPT = ['reconnect_attempt', 53 | DefaultValuesAndOptions.get_default_reconnect_attempt()] 54 | 55 | __IS_SSL = ['is_ssl', 56 | DefaultValuesAndOptions.get_ssl_options_data().default_value] 57 | __IS_INTEGRITY_CONTROL = ['is_integrity_control', 58 | DefaultValuesAndOptions.get_hash_control_options_data().default_value] 59 | __WORKERS = ['workers', 60 | DefaultValuesAndOptions.get_default_workers()] 61 | 62 | __CLIENT_PROPS = [ 63 | __SERVER_ADDRESS, 64 | ] 65 | 66 | __SERVER_PROPS = [ 67 | __IS_SSL, 68 | __IS_INTEGRITY_CONTROL, 69 | __WORKERS, 70 | ] 71 | 72 | __OTHER_PROPS = [ 73 | __SOCKET_BUFFER_SIZE, 74 | __RECONNECT_ATTEMPT, 75 | __IS_SERVER, 76 | __IS_INPUT_DEVICE, 77 | __UDP_PORT, 78 | __DEVICE_ID, 79 | __PASSWORD, 80 | __DEVICE_ENCODING_NAMES, 81 | __IGNORE_DEVICE_ENCODING_NAMES, 82 | __IS_ERROR_LOG, 83 | ] 84 | 85 | def __init__(self, filename: str, settings: Settings = None): 86 | if settings is not None: 87 | self.__save_config(filename, settings) 88 | else: 89 | self.__load_config(filename) 90 | 91 | def __save_config(self, filename: str, settings: Settings): 92 | save_dict = {} 93 | 94 | if settings.is_server != DefaultValuesAndOptions.get_util_mods_options_data().default_value: 95 | save_dict[self.__IS_SERVER[0]] = settings.is_server 96 | 97 | if settings.socket_buffer_size != DefaultValuesAndOptions.get_socket_buffer_size_options_data().default_value: 98 | save_dict[self.__SOCKET_BUFFER_SIZE[0]] = settings.socket_buffer_size 99 | 100 | if (settings.audio_device.is_input_device != DefaultValuesAndOptions.get_audio_device_type_options_data() 101 | .default_value): 102 | save_dict[self.__IS_INPUT_DEVICE[0]] = settings.audio_device.is_input_device 103 | 104 | if settings.udp_port != DefaultValuesAndOptions.get_default_port(): 105 | save_dict[self.__UDP_PORT[0]] = settings.udp_port 106 | 107 | if settings.password is not None: 108 | save_dict[self.__PASSWORD[0]] = settings.password 109 | 110 | if settings.reconnect_attempt != DefaultValuesAndOptions.get_default_reconnect_attempt(): 111 | save_dict[self.__RECONNECT_ATTEMPT[0]] = settings.reconnect_attempt 112 | 113 | if settings.audio_device.device_id is not None: 114 | save_dict[self.__DEVICE_ID[0]] = settings.audio_device.device_id 115 | 116 | if (settings.audio_device.ignore_device_encoding_names != DefaultValuesAndOptions 117 | .get_ignore_encoding_options_data().default_value): 118 | save_dict[self.__IGNORE_DEVICE_ENCODING_NAMES[0]] = settings.audio_device.ignore_device_encoding_names 119 | 120 | if (settings.audio_device.device_encoding_names != DefaultValuesAndOptions.get_encoding_charset_options_data() 121 | .default_value): 122 | save_dict[self.__DEVICE_ENCODING_NAMES[0]] = settings.audio_device.device_encoding_names 123 | 124 | if settings.is_error_log != DefaultValuesAndOptions.get_error_log_options_data().default_value: 125 | save_dict[self.__IS_ERROR_LOG[0]] = settings.is_error_log 126 | 127 | if settings.is_server: 128 | if settings.crypto_manager.is_ssl != DefaultValuesAndOptions.get_ssl_options_data().default_value: 129 | save_dict[self.__IS_SSL[0]] = settings.crypto_manager.is_ssl 130 | 131 | if (settings.crypto_manager.is_integrity_control != DefaultValuesAndOptions.get_hash_control_options_data() 132 | .default_value): 133 | save_dict[self.__IS_INTEGRITY_CONTROL[0]] = settings.crypto_manager.is_integrity_control 134 | else: 135 | save_dict[self.__SERVER_ADDRESS[0]] = settings.server_address 136 | 137 | if not filename.endswith('.conf'): 138 | filename += '.conf' 139 | 140 | with open(filename, 'w', encoding=locale.getpreferredencoding()) as file: 141 | file.write(f"[{DefaultValuesAndOptions.CONFIG_TITLE}]\n") 142 | for key, value in save_dict.items(): 143 | file.write(f"{key}={value}\n") 144 | 145 | logging.info(f'Config file successfully saved in "{filename}"') 146 | 147 | def __load_config(self, filename: str): 148 | try: 149 | properties = self.__read_properties(filename) 150 | 151 | if self.__IS_ERROR_LOG[0] in properties: 152 | try: 153 | bool_value = json.loads(properties[self.__IS_ERROR_LOG[0]].lower()) 154 | if bool_value: 155 | Logger.init_warning_logger() 156 | except Exception: 157 | raise ValueError(f"Invalid Boolean param in key {self.__IS_ERROR_LOG[0]}") 158 | 159 | self.__validate_keys(properties) 160 | properties = self.__validate_values(properties) 161 | self.__fill_class_field_with_default_values() 162 | 163 | for key, value in properties.items(): 164 | setattr(self, key, value) 165 | except ValueError as e: 166 | logging.error(e) 167 | time.sleep(1) 168 | 169 | input("Press Enter to exit...") 170 | sys.exit(1) 171 | 172 | def __fill_class_field_with_default_values(self): 173 | class_fields = list(get_type_hints(self).keys()) 174 | 175 | client_default_values = [config_key for config_key in ConfigParser.__CLIENT_PROPS] 176 | server_default_values = [config_key for config_key in ConfigParser.__SERVER_PROPS] 177 | 178 | all_default_values = [config_key for config_key in ConfigParser.__OTHER_PROPS] 179 | all_default_values.extend(client_default_values) 180 | all_default_values.extend(server_default_values) 181 | 182 | all_default_values = dict(all_default_values) 183 | 184 | for field in class_fields: 185 | setattr(self, field, all_default_values[field]) 186 | 187 | def __validate_values(self, properties: Dict) -> Dict: 188 | class_fields = dict(get_type_hints(self).items()) 189 | 190 | incorrect_values = [] 191 | for key, value in properties.items(): 192 | if class_fields[key] == bool: 193 | try: 194 | bool_value = json.loads(value.lower()) 195 | if not isinstance(bool_value, bool): 196 | incorrect_values.append(self.__format_invalid_value_type_error_str(key, 197 | value, 198 | "Boolean")) 199 | continue 200 | except Exception: 201 | incorrect_values.append(self.__format_invalid_value_type_error_str(key, 202 | value, 203 | "Boolean")) 204 | continue 205 | 206 | if (class_fields[key] == int or class_fields[key] == Optional[int]) and ( 207 | value is not None and not value.isdigit()): 208 | incorrect_values.append(self.__format_invalid_value_type_error_str(key, 209 | value, 210 | "Integer")) 211 | 212 | if len(incorrect_values) > 0: 213 | raise ValueError( 214 | f"Next keys in config file has invalid type of value:{os.linesep}{os.linesep.join(incorrect_values)}" 215 | ) 216 | else: 217 | for key, value in properties.items(): 218 | if class_fields[key] == bool: 219 | properties[key] = json.loads(value.lower()) 220 | continue 221 | 222 | if class_fields[key] == int or class_fields[key] == Optional[int]: 223 | properties[key] = int(value) 224 | 225 | return properties 226 | 227 | @staticmethod 228 | def __format_invalid_value_type_error_str(key: str, value: str, need_type: str) -> str: 229 | return f'Value "{value}" of key "{key}" must be {need_type} type' 230 | 231 | @staticmethod 232 | def __validate_keys(properties: Dict): 233 | client_keys = [config_key[0] for config_key in ConfigParser.__CLIENT_PROPS] 234 | server_keys = [config_key[0] for config_key in ConfigParser.__SERVER_PROPS] 235 | 236 | valid_config_keys = [config_key[0] for config_key in ConfigParser.__OTHER_PROPS] 237 | valid_config_keys.extend(client_keys) 238 | valid_config_keys.extend(server_keys) 239 | 240 | properties_keys = list(properties.keys()) 241 | bad_keys = [] 242 | for key in properties_keys: 243 | if key not in valid_config_keys: 244 | bad_keys.append(key) 245 | 246 | if len(bad_keys) > 0: 247 | raise ValueError(f"Invalid keys in config file: {' ,'.join(bad_keys)}{os.linesep}" 248 | f"List of valid keys: {os.linesep}{os.linesep.join(valid_config_keys)}") 249 | 250 | if ConfigParser.__IS_SERVER[0] in properties: 251 | is_server = json.loads(properties[ConfigParser.__IS_SERVER[0]].lower()) 252 | 253 | if is_server: 254 | mode = 'Server' 255 | mode_keys = server_keys 256 | non_valid_mode_keys = ConfigParser.__validate_keys_in_cycle(properties_keys, client_keys) 257 | else: 258 | mode = 'Client' 259 | mode_keys = client_keys 260 | non_valid_mode_keys = ConfigParser.__validate_keys_in_cycle(properties_keys, server_keys) 261 | else: 262 | mode = 'Server' 263 | mode_keys = server_keys 264 | non_valid_mode_keys = ConfigParser.__validate_keys_in_cycle(properties_keys, client_keys) 265 | 266 | if len(non_valid_mode_keys) > 0: 267 | raise ValueError( 268 | f"Invalid keys in config file for {mode} mode: {' ,'.join(non_valid_mode_keys)}{os.linesep}" 269 | f"List of valid keys for {mode} mode: {os.linesep}{os.linesep.join(mode_keys)}") 270 | 271 | @staticmethod 272 | def __validate_keys_in_cycle(properties_keys: List, keys: List) -> List: 273 | non_valid_mode_keys = [] 274 | for key in properties_keys: 275 | if key in keys: 276 | non_valid_mode_keys.append(key) 277 | 278 | return non_valid_mode_keys 279 | 280 | @staticmethod 281 | def __read_properties(file_path: str) -> Dict: 282 | if not os.path.isfile(file_path): 283 | raise ValueError(f'Config file "{file_path}" not found!') 284 | 285 | config = configparser.ConfigParser() 286 | file_str = ConfigParser.get_file_str(file_path) 287 | 288 | if file_str.split('\n')[0] != f'[{DefaultValuesAndOptions.CONFIG_TITLE}]': 289 | raise ValueError(f'Config file does not have title [{DefaultValuesAndOptions.CONFIG_TITLE}]') 290 | 291 | config.read_string(file_str) 292 | properties = dict(config[DefaultValuesAndOptions.CONFIG_TITLE]) 293 | 294 | return properties 295 | 296 | @staticmethod 297 | def get_file_str(file_path: str) -> str: 298 | try: 299 | with open(file_path, 'r', encoding=locale.getpreferredencoding()) as file: 300 | file_str = file.read() 301 | except Exception as e: 302 | raise ValueError(f"Failed to decode config file: {e}") 303 | 304 | return file_str 305 | -------------------------------------------------------------------------------- /echowarp/start_modes/interactive_mode.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import os 3 | 4 | import logging 5 | 6 | from echowarp.logging_config import Logger 7 | from echowarp.models.audio_device import AudioDevice 8 | from echowarp.models.default_values_and_options import DefaultValuesAndOptions 9 | from echowarp.settings import Settings 10 | from echowarp.models.options_data_creater import OptionsData 11 | from echowarp.start_modes.config_parser import ConfigParser 12 | 13 | 14 | class NumberValidator: 15 | """ 16 | A custom validator for prompt_toolkit that ensures user input is a valid number within a specified range. 17 | 18 | Attributes: 19 | valid_numbers (set): A set of numbers that are considered valid for input. 20 | """ 21 | 22 | def __init__(self, valid_numbers: list): 23 | """ 24 | Initializes the NumberValidator with the specified set of valid numbers. 25 | 26 | Args: 27 | valid_numbers (list): The set of valid numbers. 28 | """ 29 | self.valid_numbers = valid_numbers 30 | 31 | def validate(self, input_str: str): 32 | """ 33 | Validates the user input against the allowed numbers. 34 | 35 | Raises: 36 | ValidationError: If the input is not a valid number or not in the allowed set. 37 | """ 38 | if input_str.strip() == '': 39 | return None 40 | 41 | if not input_str.isdigit() or int(input_str) not in self.valid_numbers: 42 | return False 43 | else: 44 | return True 45 | 46 | 47 | class InteractiveSettings: 48 | """ 49 | Handles the interactive configuration of EchoWarp settings through command line prompts. 50 | Allows users to configure settings such as server/client mode, audio device selection, and network options 51 | by answering interactive questions. 52 | """ 53 | 54 | @staticmethod 55 | def get_settings_in_interactive_mode() -> Settings: 56 | """ 57 | Prompts the user interactively to configure the EchoWarp settings and returns the configured settings object. 58 | 59 | Returns: 60 | Settings: A fully configured Settings object based on user input. 61 | """ 62 | server_address = None 63 | is_ssl = None 64 | is_integrity_control = None 65 | workers = 1 66 | 67 | conf_files = [] 68 | for filename in os.listdir(): 69 | if os.path.isfile(os.path.join(filename)) and filename.endswith('.conf'): 70 | file_str = ConfigParser.get_file_str(filename) 71 | if file_str.split('\n')[0] == f'[{DefaultValuesAndOptions.CONFIG_TITLE}]': 72 | conf_files.append(filename) 73 | 74 | if len(conf_files) == 1: 75 | is_load_config = InteractiveSettings.__select_in_interactive_from_values( 76 | f'load config from "{conf_files[0]}" file', 77 | DefaultValuesAndOptions.get_variants_config_load_options_data() 78 | ) 79 | 80 | if is_load_config: 81 | return InteractiveSettings.__get_settings_from_config(conf_files[0]) 82 | elif len(conf_files) > 1: 83 | config_files_num_list = [[value, i] for i, value in zip(itertools.count(), conf_files)] 84 | config_file_num = InteractiveSettings.__select_in_interactive_from_values( 85 | 'founded config files', 86 | OptionsData( 87 | ["Skip configs", False], 88 | config_files_num_list 89 | ) 90 | ) 91 | 92 | if type(config_file_num) is int: 93 | selected_file_name = config_files_num_list[config_file_num][0] 94 | 95 | # noinspection PyTypeChecker 96 | return InteractiveSettings.__get_settings_from_config(selected_file_name) 97 | else: 98 | config_file_path = input("Input path to config file (empty field to skip): ") 99 | if config_file_path.strip() != '': 100 | return InteractiveSettings.__get_settings_from_config(config_file_path.strip()) 101 | 102 | is_error_log = InteractiveSettings.__select_in_interactive_from_values( 103 | 'error file logger switcher', 104 | DefaultValuesAndOptions.get_error_log_options_data() 105 | ) 106 | 107 | if is_error_log: 108 | Logger.init_warning_logger() 109 | 110 | is_server_mode = InteractiveSettings.__select_in_interactive_from_values( 111 | 'util mode', 112 | DefaultValuesAndOptions.get_util_mods_options_data() 113 | ) 114 | 115 | socket_buffer_size = InteractiveSettings.__select_in_interactive_from_values( 116 | 'size of socket buffer', 117 | DefaultValuesAndOptions.get_socket_buffer_size_options_data() 118 | ) 119 | 120 | if type(socket_buffer_size) is bool: 121 | socket_buffer_size = InteractiveSettings.__get_not_null_int_input("custom buffer size in KB") 122 | 123 | is_input_device = InteractiveSettings.__select_in_interactive_from_values( 124 | 'capture audio device type', 125 | DefaultValuesAndOptions.get_audio_device_type_options_data() 126 | ) 127 | 128 | udp_port = InteractiveSettings.__input_in_interactive_int_value( 129 | DefaultValuesAndOptions.get_default_port(), 130 | 'UDP port for audio streaming' 131 | ) 132 | 133 | password = input("Input password, if needed: ") 134 | if password.strip() == '': 135 | password = None 136 | 137 | if is_server_mode: 138 | reconnect_str = 'number of failed client authorization before ban (0=infinite)' 139 | else: 140 | reconnect_str = 'number of failed connections before closing the application (0=infinite)' 141 | 142 | reconnect_attempt = InteractiveSettings.__input_in_interactive_int_value( 143 | DefaultValuesAndOptions.get_default_reconnect_attempt(), 144 | reconnect_str 145 | ) 146 | 147 | ignore_device_encoding_names = InteractiveSettings.__select_in_interactive_from_values( 148 | 'ignore audio device encoding names', 149 | DefaultValuesAndOptions.get_ignore_encoding_options_data() 150 | ) 151 | 152 | if not ignore_device_encoding_names: 153 | device_encoding_names = InteractiveSettings.__select_in_interactive_from_values( 154 | 'audio device charset encoding names', 155 | DefaultValuesAndOptions.get_encoding_charset_options_data() 156 | ) 157 | 158 | if type(device_encoding_names) is bool: 159 | device_encoding_names = InteractiveSettings.__get_not_null_str_input('custom charset encoding') 160 | else: 161 | device_encoding_names = DefaultValuesAndOptions.get_encoding_charset_options_data().default_value 162 | 163 | if not is_server_mode: 164 | server_address = input("Select server host: ") 165 | else: 166 | is_ssl = InteractiveSettings.__select_in_interactive_from_values( 167 | 'init SSL (Encryption) mode', 168 | DefaultValuesAndOptions.get_ssl_options_data() 169 | ) 170 | 171 | is_integrity_control = InteractiveSettings.__select_in_interactive_from_values( 172 | 'init integrity control', 173 | DefaultValuesAndOptions.get_hash_control_options_data() 174 | ) 175 | 176 | workers = InteractiveSettings.__input_in_interactive_int_value( 177 | DefaultValuesAndOptions.get_default_workers(), 'thread workers count') 178 | 179 | audio_device = AudioDevice(is_input_device, None, ignore_device_encoding_names, device_encoding_names) 180 | settings = Settings(is_server_mode, udp_port, server_address, reconnect_attempt, is_ssl, 181 | is_integrity_control, workers, audio_device, password, is_error_log, socket_buffer_size) 182 | 183 | save_to_config_file = InteractiveSettings.__select_in_interactive_from_values( 184 | 'save selected values to config file', 185 | DefaultValuesAndOptions.get_save_profile_options_data() 186 | ) 187 | 188 | if save_to_config_file: 189 | config_file_name = InteractiveSettings.__get_not_null_str_input('config file name') 190 | ConfigParser(filename=config_file_name, settings=settings) 191 | 192 | return settings 193 | 194 | @staticmethod 195 | def __get_not_null_str_input(descr: str) -> str: 196 | str_input = '' 197 | while str_input == '': 198 | str_input = input(f"Input {descr}: ").strip() 199 | if str_input == '': 200 | logging.error(f"Selected {descr} is NULL!") 201 | 202 | return str_input 203 | 204 | @staticmethod 205 | def __get_not_null_int_input(descr: str) -> str: 206 | int_input = '' 207 | while type(int_input) is not int: 208 | try: 209 | int_input = input(f'Input {descr}: ').strip() 210 | if int_input == '': 211 | raise Exception 212 | int_input = int(int_input) 213 | except Exception: 214 | logging.error(f"Invalid {descr}: {int_input}") 215 | 216 | return int_input 217 | 218 | @staticmethod 219 | def __select_in_interactive_from_values(descr: str, options_data: OptionsData): 220 | """ 221 | Displays a list of options to the user and allows them to select one interactively. 222 | 223 | Args: 224 | descr (str): Description of the setting being configured. 225 | options_data (OptionsData): Data containing the options and their descriptions. 226 | 227 | Returns: 228 | Any: The value of the selected option. 229 | """ 230 | options_dict = {index: opt for index, opt in enumerate(options_data.options, start=1)} 231 | 232 | choices = os.linesep.join([f"{num}. {desc.option_descr}" for num, desc in options_dict.items()]) 233 | prompt_text = (f"Select id of {descr}:" 234 | f"{os.linesep + choices + os.linesep}" 235 | f"Empty field for default value (default={options_data.default_descr}): {os.linesep}") 236 | 237 | number_validator = NumberValidator(list(options_dict.keys())) 238 | while True: 239 | try: 240 | choice = input(prompt_text) 241 | is_validating = number_validator.validate(choice) 242 | 243 | if is_validating is None: 244 | return options_data.default_value 245 | elif is_validating: 246 | return options_dict[int(choice)].option_value 247 | else: 248 | raise ValueError 249 | except ValueError: 250 | logging.error(f"Invalid input, please try again.{os.linesep}" 251 | f"Valid input id's: {number_validator.valid_numbers}") 252 | 253 | @staticmethod 254 | def __input_in_interactive_int_value(default_value: int, descr: str) -> int: 255 | """ 256 | Prompts the user to input an integer value interactively, providing a default if no input is given. 257 | 258 | Args: 259 | default_value (int): The default value to use if no input is provided. 260 | descr (str): Description of the setting being configured. 261 | 262 | Returns: 263 | int: The user-input value or the default value. 264 | """ 265 | while True: 266 | try: 267 | value = input(f"Select {descr} (default={default_value}): ") 268 | 269 | return int(value) if value else default_value 270 | except ValueError as ve: 271 | logging.error(f"Invalid input, please enter a valid integer: {ve}") 272 | 273 | @staticmethod 274 | def __get_settings_from_config(filepath: str) -> Settings: 275 | configs = ConfigParser(filepath) 276 | audio_device = AudioDevice(configs.is_input_audio_device, 277 | configs.device_id, 278 | configs.ignore_device_encoding_names, 279 | configs.device_encoding_names) 280 | 281 | return Settings( 282 | configs.is_server, 283 | configs.udp_port, 284 | configs.server_address, 285 | configs.reconnect_attempt, 286 | configs.is_ssl, 287 | configs.is_integrity_control, 288 | configs.workers, 289 | audio_device, 290 | configs.password, 291 | configs.is_error_log, 292 | configs.socket_buffer_size 293 | ) 294 | -------------------------------------------------------------------------------- /echowarp/streamer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lHumaNl/EchoWarp/62dbb95aea201e9e874fc83720ff93d61f6c74f7/echowarp/streamer/__init__.py -------------------------------------------------------------------------------- /echowarp/streamer/audio_client.py: -------------------------------------------------------------------------------- 1 | import threading 2 | from concurrent.futures import ThreadPoolExecutor 3 | 4 | import pyaudio 5 | import logging 6 | 7 | from echowarp.auth_and_heartbeat.transport_client import TransportClient 8 | from echowarp.models.audio_device import AudioDevice 9 | from echowarp.settings import Settings 10 | 11 | 12 | class ClientStreamReceiver(TransportClient): 13 | """ 14 | Handles receiving and decoding audio streams on the client side over UDP. 15 | 16 | Attributes: 17 | _udp_port (int): The port number used for receiving UDP audio streams. 18 | _audio_device (AudioDevice): Audio device configuration for output. 19 | _executor (ThreadPoolExecutor): Executor for asynchronous task handling. 20 | """ 21 | _audio_device: AudioDevice 22 | _executor: ThreadPoolExecutor 23 | 24 | def __init__(self, settings: Settings, stop_util_event: threading.Event(), stop_stream_event: threading.Event()): 25 | """ 26 | Initializes a UDP client to receive and play back audio streams. 27 | 28 | Args: 29 | settings (Settings): Settings object. 30 | """ 31 | super().__init__(settings, stop_util_event, stop_stream_event) 32 | self._audio_device = settings.audio_device 33 | self._executor = settings.executor 34 | 35 | def receive_audio_and_decode(self): 36 | """ 37 | Starts the process of receiving audio data from the server over UDP, decrypting and decoding it, 38 | and then playing it back using the configured audio device. 39 | 40 | This method handles continuous audio streaming until a stop event is triggered. 41 | """ 42 | if self._audio_device.is_input_device: 43 | stream = self._audio_device.py_audio.open( 44 | format=pyaudio.paInt16, 45 | channels=self._audio_device.channels, 46 | rate=self._audio_device.sample_rate, 47 | input=True, 48 | input_device_index=self._audio_device.device_index 49 | ) 50 | else: 51 | stream = self._audio_device.py_audio.open( 52 | format=pyaudio.paInt16, 53 | channels=self._audio_device.channels, 54 | rate=self._audio_device.sample_rate, 55 | output=True, 56 | output_device_index=self._audio_device.device_index 57 | ) 58 | 59 | self._print_udp_listener_and_start_stream() 60 | try: 61 | while not self._stop_util_event.is_set(): 62 | try: 63 | data, _ = self._udp_socket.recvfrom(self._socket_buffer_size) 64 | self._executor.submit(self.__decode_and_play, data, stream) 65 | except Exception: 66 | pass 67 | 68 | self._stop_stream_event.wait() 69 | finally: 70 | self._executor.shutdown() 71 | stream.stop_stream() 72 | stream.close() 73 | 74 | self._audio_device.py_audio.terminate() 75 | 76 | logging.info("UDP listening finished...") 77 | 78 | def __decode_and_play(self, data, stream): 79 | """ 80 | Decodes the received encrypted audio data and plays it back. 81 | 82 | Args: 83 | data (bytes): Encrypted audio data. 84 | stream (pyaudio.Stream): PyAudio stream for audio playback. 85 | """ 86 | data = self._crypto_manager.decrypt_aes_and_verify_data(data) 87 | stream.write(data) 88 | -------------------------------------------------------------------------------- /echowarp/streamer/audio_server.py: -------------------------------------------------------------------------------- 1 | import threading 2 | from concurrent.futures import ThreadPoolExecutor 3 | 4 | import pyaudio 5 | import logging 6 | 7 | from echowarp.auth_and_heartbeat.transport_server import TransportServer 8 | from echowarp.services.crypto_manager import CryptoManager 9 | from echowarp.models.audio_device import AudioDevice 10 | from echowarp.settings import Settings 11 | 12 | 13 | class ServerStreamer(TransportServer): 14 | """ 15 | Handles streaming audio from the server to the client over UDP. 16 | 17 | Attributes: 18 | _client_address (str): The client's IP address to send audio data to. 19 | _udp_port (int): The port used for UDP streaming. 20 | _audio_device (AudioDevice): Audio device used for capturing audio. 21 | _stop_util_event (threading.Event): Event to signal util to stop. 22 | _stop_stream_event (threading.Event): Event to signal stream to stop. 23 | _crypto_manager (CryptoManager): Manager for cryptographic operations. 24 | _executor (Executor): Executor for asynchronous task execution. 25 | """ 26 | _audio_device: AudioDevice 27 | _executor: ThreadPoolExecutor 28 | 29 | def __init__(self, settings: Settings, stop_util_event: threading.Event(), stop_stream_event: threading.Event()): 30 | """ 31 | Initializes the UDPServerStreamer with specified client address, port, and audio settings. 32 | 33 | Args: 34 | settings (Settings): Settings object. 35 | """ 36 | super().__init__(settings, stop_util_event, stop_stream_event) 37 | self._audio_device = settings.audio_device 38 | self._executor = settings.executor 39 | 40 | def encode_audio_and_send_to_client(self): 41 | """ 42 | Captures audio from the selected device, encodes it, encrypts, and sends it to the client over UDP. 43 | This method continuously captures and sends audio until a stop event is triggered. 44 | """ 45 | if self._audio_device.is_input_device: 46 | stream = self._audio_device.py_audio.open( 47 | format=pyaudio.paInt16, 48 | channels=self._audio_device.channels, 49 | rate=self._audio_device.sample_rate, 50 | input=True, 51 | input_device_index=self._audio_device.device_index, 52 | frames_per_buffer=1024 53 | ) 54 | else: 55 | stream = self._audio_device.py_audio.open( 56 | format=pyaudio.paInt16, 57 | channels=self._audio_device.channels, 58 | rate=self._audio_device.sample_rate, 59 | output=True, 60 | output_device_index=self._audio_device.device_index, 61 | frames_per_buffer=1024 62 | ) 63 | 64 | self._print_udp_listener_and_start_stream() 65 | try: 66 | while not self._stop_util_event.is_set(): 67 | try: 68 | data = stream.read(1024, exception_on_overflow=False) 69 | self._executor.submit(self.__send_stream_to_client, data) 70 | except Exception: 71 | pass 72 | 73 | self._stop_stream_event.wait() 74 | finally: 75 | self._executor.shutdown() 76 | stream.stop_stream() 77 | stream.close() 78 | 79 | self._audio_device.py_audio.terminate() 80 | 81 | logging.info("UDP streaming finished...") 82 | 83 | def __send_stream_to_client(self, data): 84 | """ 85 | Encodes and encrypts audio data, then sends it to the client using UDP. 86 | 87 | Args: 88 | data (bytes): Raw audio data to encode and send. 89 | """ 90 | encoded_data = self._crypto_manager.encrypt_aes_and_sign_data(data) 91 | self._udp_socket.sendto(encoded_data, (self._client_address, self._udp_port)) 92 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyAudio~=0.2.14 2 | cryptography~=42.0.5 3 | chardet~=5.2.0 4 | psutil~=5.9.8 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | import version 3 | 4 | setup( 5 | name='echowarp', 6 | version=version.__version__, 7 | packages=find_packages(include=['echowarp', 'echowarp.*']), 8 | include_package_data=True, 9 | package_data={ 10 | '': ['../version.py'], 11 | }, 12 | install_requires=[ 13 | 'PyAudio', 14 | 'cryptography', 15 | 'chardet' 16 | ], 17 | entry_points={ 18 | 'console_scripts': [ 19 | 'echowarp=echowarp.main:main', 20 | ], 21 | }, 22 | author='lHumaNl', 23 | author_email='fisher_sam@mail.ru', 24 | description='Network audio streaming tool using UDP', 25 | long_description=open('README.md').read(), 26 | long_description_content_type='text/markdown', 27 | url='https://github.com/lHumaNl/EchoWarp', 28 | license='MIT', 29 | classifiers=[ 30 | 'Development Status :: 3 - Alpha', 31 | 'Intended Audience :: Developers', 32 | 'Topic :: Multimedia :: Sound/Audio', 33 | 'License :: OSI Approved :: MIT License', 34 | 'Programming Language :: Python :: 3', 35 | 'Programming Language :: Python :: 3.6', 36 | ], 37 | keywords='audio streaming network', 38 | ) 39 | -------------------------------------------------------------------------------- /version.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.4.0' 2 | __comparability_version__ = '0.4.0' 3 | --------------------------------------------------------------------------------