mytunnelSDK 1.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mytunnelsdk-1.2.0/LICENSE +13 -0
- mytunnelsdk-1.2.0/PKG-INFO +91 -0
- mytunnelsdk-1.2.0/README.md +73 -0
- mytunnelsdk-1.2.0/pyproject.toml +39 -0
- mytunnelsdk-1.2.0/setup.cfg +4 -0
- mytunnelsdk-1.2.0/src/mytunnelSDK.egg-info/PKG-INFO +91 -0
- mytunnelsdk-1.2.0/src/mytunnelSDK.egg-info/SOURCES.txt +17 -0
- mytunnelsdk-1.2.0/src/mytunnelSDK.egg-info/dependency_links.txt +1 -0
- mytunnelsdk-1.2.0/src/mytunnelSDK.egg-info/requires.txt +4 -0
- mytunnelsdk-1.2.0/src/mytunnelSDK.egg-info/top_level.txt +1 -0
- mytunnelsdk-1.2.0/src/tunnel_sdk/__init__.py +23 -0
- mytunnelsdk-1.2.0/src/tunnel_sdk/client.py +201 -0
- mytunnelsdk-1.2.0/src/tunnel_sdk/config.py +51 -0
- mytunnelsdk-1.2.0/src/tunnel_sdk/connection.py +201 -0
- mytunnelsdk-1.2.0/src/tunnel_sdk/dispatcher.py +188 -0
- mytunnelsdk-1.2.0/src/tunnel_sdk/events.py +31 -0
- mytunnelsdk-1.2.0/src/tunnel_sdk/exceptions.py +23 -0
- mytunnelsdk-1.2.0/src/tunnel_sdk/protocol.py +98 -0
- mytunnelsdk-1.2.0/src/tunnel_sdk/stats.py +45 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Custom License
|
|
2
|
+
|
|
3
|
+
You are permitted to use, copy, and modify this software for personal or internal business purposes.
|
|
4
|
+
|
|
5
|
+
You are NOT permitted to redistribute, sub-license, or sell this software or any derivatives of this software in any form, whether modified or unmodified, without explicit prior written permission from the author.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
8
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
9
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
10
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
11
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
12
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
13
|
+
SOFTWARE.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mytunnelSDK
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: A Python SDK for ...
|
|
5
|
+
Author-email: Bhargav Barman <bhargavbprd@gmail.com>
|
|
6
|
+
License: Custom
|
|
7
|
+
Keywords: sdk,api
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: requests>=2.31.0
|
|
14
|
+
Requires-Dist: httpx>=0.24.0
|
|
15
|
+
Requires-Dist: websocket-client>=1.6.0
|
|
16
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# Tunnel SDK
|
|
20
|
+
|
|
21
|
+
This is the production-ready Python SDK for the Tunnel Gateway. It allows you to establish secure WebSocket tunnels and seamlessly proxy HTTP traffic to your local development server.
|
|
22
|
+
|
|
23
|
+
## Features
|
|
24
|
+
|
|
25
|
+
* **Thread-safe**: Runs in the background without blocking your main application loop. No `asyncio` required!
|
|
26
|
+
* **Header Authentication**: Secures your API key in HTTP headers (`Authorization: Bearer`), preventing exposure in URL logs.
|
|
27
|
+
* **Payload Compression**: Transparent zlib compression for data exchange over WebSocket, boosting bandwidth efficiency and speed.
|
|
28
|
+
* **Connection Pooling**: Optimized HTTP connection pooling with keep-alive limits for high-concurrency dispatching.
|
|
29
|
+
* **Automated Reconnection**: Built-in exponential backoff means you don't have to manually manage network hiccups.
|
|
30
|
+
* **Smart Streaming**: Automatically switches to chunked streaming for large files, keeping memory overhead low.
|
|
31
|
+
* **Context Manager**: Start and stop the tunnel cleanly using `with Tunnel.from_env() as tunnel:`.
|
|
32
|
+
* **Events API**: Listen to hooks like `on_connect`, `on_disconnect`, and `on_error`.
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
Create a `.env` file in your root folder:
|
|
37
|
+
```env
|
|
38
|
+
TUNNEL_API_KEY=your-secret-key
|
|
39
|
+
TUNNEL_TARGET_PATH=/api
|
|
40
|
+
TUNNEL_PORT=5000
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Run the tunnel:
|
|
44
|
+
```python
|
|
45
|
+
from tunnel_sdk import Tunnel
|
|
46
|
+
|
|
47
|
+
with Tunnel.from_env() as tunnel:
|
|
48
|
+
# This blocks until interrupted!
|
|
49
|
+
tunnel.wait()
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
When connected, you'll see a success message like:
|
|
53
|
+
```
|
|
54
|
+
============================================================
|
|
55
|
+
TUNNEL CONNECTED SUCCESSFULLY!
|
|
56
|
+
Public URL : https://your-gateway.com/api
|
|
57
|
+
Forwarding to : http://127.0.0.1:5000
|
|
58
|
+
============================================================
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## SDK Information
|
|
62
|
+
|
|
63
|
+
The SDK provides an API to access current connection statistics and configuration info:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from tunnel_sdk import Tunnel
|
|
67
|
+
|
|
68
|
+
with Tunnel.from_env() as tunnel:
|
|
69
|
+
# Print SDK info as a dictionary
|
|
70
|
+
print(tunnel.sd.info())
|
|
71
|
+
|
|
72
|
+
tunnel.wait()
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The dictionary returned by `tunnel.sd.info()` contains information like:
|
|
76
|
+
* `sdk_version`: The SDK version.
|
|
77
|
+
* `protocol_version`: The protocol version.
|
|
78
|
+
* `is_connected`: Whether the tunnel is currently connected.
|
|
79
|
+
* `uptime`: The time in seconds the tunnel has been running.
|
|
80
|
+
* `reconnect_count`: The number of times the tunnel has reconnected.
|
|
81
|
+
* `statistics`: A dictionary containing stats like requests handled, bytes uploaded/downloaded, and active requests.
|
|
82
|
+
|
|
83
|
+
## Structure
|
|
84
|
+
|
|
85
|
+
* `client.py` - The main `Tunnel` interface.
|
|
86
|
+
* `connection.py` - WebSocket loop and reconnect logic.
|
|
87
|
+
* `dispatcher.py` - HTTP request proxying via `httpx`.
|
|
88
|
+
* `protocol.py` - Data structures and base64 encoders.
|
|
89
|
+
* `config.py` - Environment loader.
|
|
90
|
+
|
|
91
|
+
For full API reference, see `docs/sdk_reference.md` in the project root.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Tunnel SDK
|
|
2
|
+
|
|
3
|
+
This is the production-ready Python SDK for the Tunnel Gateway. It allows you to establish secure WebSocket tunnels and seamlessly proxy HTTP traffic to your local development server.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
* **Thread-safe**: Runs in the background without blocking your main application loop. No `asyncio` required!
|
|
8
|
+
* **Header Authentication**: Secures your API key in HTTP headers (`Authorization: Bearer`), preventing exposure in URL logs.
|
|
9
|
+
* **Payload Compression**: Transparent zlib compression for data exchange over WebSocket, boosting bandwidth efficiency and speed.
|
|
10
|
+
* **Connection Pooling**: Optimized HTTP connection pooling with keep-alive limits for high-concurrency dispatching.
|
|
11
|
+
* **Automated Reconnection**: Built-in exponential backoff means you don't have to manually manage network hiccups.
|
|
12
|
+
* **Smart Streaming**: Automatically switches to chunked streaming for large files, keeping memory overhead low.
|
|
13
|
+
* **Context Manager**: Start and stop the tunnel cleanly using `with Tunnel.from_env() as tunnel:`.
|
|
14
|
+
* **Events API**: Listen to hooks like `on_connect`, `on_disconnect`, and `on_error`.
|
|
15
|
+
|
|
16
|
+
## Quick Start
|
|
17
|
+
|
|
18
|
+
Create a `.env` file in your root folder:
|
|
19
|
+
```env
|
|
20
|
+
TUNNEL_API_KEY=your-secret-key
|
|
21
|
+
TUNNEL_TARGET_PATH=/api
|
|
22
|
+
TUNNEL_PORT=5000
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Run the tunnel:
|
|
26
|
+
```python
|
|
27
|
+
from tunnel_sdk import Tunnel
|
|
28
|
+
|
|
29
|
+
with Tunnel.from_env() as tunnel:
|
|
30
|
+
# This blocks until interrupted!
|
|
31
|
+
tunnel.wait()
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
When connected, you'll see a success message like:
|
|
35
|
+
```
|
|
36
|
+
============================================================
|
|
37
|
+
TUNNEL CONNECTED SUCCESSFULLY!
|
|
38
|
+
Public URL : https://your-gateway.com/api
|
|
39
|
+
Forwarding to : http://127.0.0.1:5000
|
|
40
|
+
============================================================
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## SDK Information
|
|
44
|
+
|
|
45
|
+
The SDK provides an API to access current connection statistics and configuration info:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from tunnel_sdk import Tunnel
|
|
49
|
+
|
|
50
|
+
with Tunnel.from_env() as tunnel:
|
|
51
|
+
# Print SDK info as a dictionary
|
|
52
|
+
print(tunnel.sd.info())
|
|
53
|
+
|
|
54
|
+
tunnel.wait()
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The dictionary returned by `tunnel.sd.info()` contains information like:
|
|
58
|
+
* `sdk_version`: The SDK version.
|
|
59
|
+
* `protocol_version`: The protocol version.
|
|
60
|
+
* `is_connected`: Whether the tunnel is currently connected.
|
|
61
|
+
* `uptime`: The time in seconds the tunnel has been running.
|
|
62
|
+
* `reconnect_count`: The number of times the tunnel has reconnected.
|
|
63
|
+
* `statistics`: A dictionary containing stats like requests handled, bytes uploaded/downloaded, and active requests.
|
|
64
|
+
|
|
65
|
+
## Structure
|
|
66
|
+
|
|
67
|
+
* `client.py` - The main `Tunnel` interface.
|
|
68
|
+
* `connection.py` - WebSocket loop and reconnect logic.
|
|
69
|
+
* `dispatcher.py` - HTTP request proxying via `httpx`.
|
|
70
|
+
* `protocol.py` - Data structures and base64 encoders.
|
|
71
|
+
* `config.py` - Environment loader.
|
|
72
|
+
|
|
73
|
+
For full API reference, see `docs/sdk_reference.md` in the project root.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mytunnelSDK"
|
|
7
|
+
version = "1.2.0"
|
|
8
|
+
description = "A Python SDK for ..."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Bhargav Barman", email = "bhargavbprd@gmail.com" }
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
license = { text = "Custom" }
|
|
17
|
+
|
|
18
|
+
dependencies = [
|
|
19
|
+
"requests>=2.31.0",
|
|
20
|
+
"httpx>=0.24.0",
|
|
21
|
+
"websocket-client>=1.6.0",
|
|
22
|
+
"python-dotenv>=1.0.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
keywords = [
|
|
26
|
+
"sdk",
|
|
27
|
+
"api",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
classifiers = [
|
|
31
|
+
"Programming Language :: Python :: 3",
|
|
32
|
+
"Operating System :: OS Independent",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[tool.setuptools]
|
|
36
|
+
package-dir = {"" = "src"}
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.packages.find]
|
|
39
|
+
where = ["src"]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mytunnelSDK
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: A Python SDK for ...
|
|
5
|
+
Author-email: Bhargav Barman <bhargavbprd@gmail.com>
|
|
6
|
+
License: Custom
|
|
7
|
+
Keywords: sdk,api
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: requests>=2.31.0
|
|
14
|
+
Requires-Dist: httpx>=0.24.0
|
|
15
|
+
Requires-Dist: websocket-client>=1.6.0
|
|
16
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# Tunnel SDK
|
|
20
|
+
|
|
21
|
+
This is the production-ready Python SDK for the Tunnel Gateway. It allows you to establish secure WebSocket tunnels and seamlessly proxy HTTP traffic to your local development server.
|
|
22
|
+
|
|
23
|
+
## Features
|
|
24
|
+
|
|
25
|
+
* **Thread-safe**: Runs in the background without blocking your main application loop. No `asyncio` required!
|
|
26
|
+
* **Header Authentication**: Secures your API key in HTTP headers (`Authorization: Bearer`), preventing exposure in URL logs.
|
|
27
|
+
* **Payload Compression**: Transparent zlib compression for data exchange over WebSocket, boosting bandwidth efficiency and speed.
|
|
28
|
+
* **Connection Pooling**: Optimized HTTP connection pooling with keep-alive limits for high-concurrency dispatching.
|
|
29
|
+
* **Automated Reconnection**: Built-in exponential backoff means you don't have to manually manage network hiccups.
|
|
30
|
+
* **Smart Streaming**: Automatically switches to chunked streaming for large files, keeping memory overhead low.
|
|
31
|
+
* **Context Manager**: Start and stop the tunnel cleanly using `with Tunnel.from_env() as tunnel:`.
|
|
32
|
+
* **Events API**: Listen to hooks like `on_connect`, `on_disconnect`, and `on_error`.
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
Create a `.env` file in your root folder:
|
|
37
|
+
```env
|
|
38
|
+
TUNNEL_API_KEY=your-secret-key
|
|
39
|
+
TUNNEL_TARGET_PATH=/api
|
|
40
|
+
TUNNEL_PORT=5000
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Run the tunnel:
|
|
44
|
+
```python
|
|
45
|
+
from tunnel_sdk import Tunnel
|
|
46
|
+
|
|
47
|
+
with Tunnel.from_env() as tunnel:
|
|
48
|
+
# This blocks until interrupted!
|
|
49
|
+
tunnel.wait()
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
When connected, you'll see a success message like:
|
|
53
|
+
```
|
|
54
|
+
============================================================
|
|
55
|
+
TUNNEL CONNECTED SUCCESSFULLY!
|
|
56
|
+
Public URL : https://your-gateway.com/api
|
|
57
|
+
Forwarding to : http://127.0.0.1:5000
|
|
58
|
+
============================================================
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## SDK Information
|
|
62
|
+
|
|
63
|
+
The SDK provides an API to access current connection statistics and configuration info:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from tunnel_sdk import Tunnel
|
|
67
|
+
|
|
68
|
+
with Tunnel.from_env() as tunnel:
|
|
69
|
+
# Print SDK info as a dictionary
|
|
70
|
+
print(tunnel.sd.info())
|
|
71
|
+
|
|
72
|
+
tunnel.wait()
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The dictionary returned by `tunnel.sd.info()` contains information like:
|
|
76
|
+
* `sdk_version`: The SDK version.
|
|
77
|
+
* `protocol_version`: The protocol version.
|
|
78
|
+
* `is_connected`: Whether the tunnel is currently connected.
|
|
79
|
+
* `uptime`: The time in seconds the tunnel has been running.
|
|
80
|
+
* `reconnect_count`: The number of times the tunnel has reconnected.
|
|
81
|
+
* `statistics`: A dictionary containing stats like requests handled, bytes uploaded/downloaded, and active requests.
|
|
82
|
+
|
|
83
|
+
## Structure
|
|
84
|
+
|
|
85
|
+
* `client.py` - The main `Tunnel` interface.
|
|
86
|
+
* `connection.py` - WebSocket loop and reconnect logic.
|
|
87
|
+
* `dispatcher.py` - HTTP request proxying via `httpx`.
|
|
88
|
+
* `protocol.py` - Data structures and base64 encoders.
|
|
89
|
+
* `config.py` - Environment loader.
|
|
90
|
+
|
|
91
|
+
For full API reference, see `docs/sdk_reference.md` in the project root.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/mytunnelSDK.egg-info/PKG-INFO
|
|
5
|
+
src/mytunnelSDK.egg-info/SOURCES.txt
|
|
6
|
+
src/mytunnelSDK.egg-info/dependency_links.txt
|
|
7
|
+
src/mytunnelSDK.egg-info/requires.txt
|
|
8
|
+
src/mytunnelSDK.egg-info/top_level.txt
|
|
9
|
+
src/tunnel_sdk/__init__.py
|
|
10
|
+
src/tunnel_sdk/client.py
|
|
11
|
+
src/tunnel_sdk/config.py
|
|
12
|
+
src/tunnel_sdk/connection.py
|
|
13
|
+
src/tunnel_sdk/dispatcher.py
|
|
14
|
+
src/tunnel_sdk/events.py
|
|
15
|
+
src/tunnel_sdk/exceptions.py
|
|
16
|
+
src/tunnel_sdk/protocol.py
|
|
17
|
+
src/tunnel_sdk/stats.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tunnel_sdk
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tunnel Gateway Python SDK
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
A production-quality Python SDK for the Tunnel Gateway.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from mysdk.src.tunnel_sdk.client import Tunnel
|
|
9
|
+
from mysdk.src.tunnel_sdk.config import TunnelConfig
|
|
10
|
+
from mysdk.src.tunnel_sdk.exceptions import (
|
|
11
|
+
TunnelError, AuthenticationError, ConnectionError,
|
|
12
|
+
ProtocolError, TimeoutError
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Tunnel",
|
|
17
|
+
"TunnelConfig",
|
|
18
|
+
"TunnelError",
|
|
19
|
+
"AuthenticationError",
|
|
20
|
+
"ConnectionError",
|
|
21
|
+
"ProtocolError",
|
|
22
|
+
"TimeoutError"
|
|
23
|
+
]
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The main Tunnel client class for the SDK.
|
|
3
|
+
"""
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from typing import Optional, Callable
|
|
9
|
+
|
|
10
|
+
from mysdk.src.tunnel_sdk.config import TunnelConfig
|
|
11
|
+
from mysdk.src.tunnel_sdk.stats import TunnelStats
|
|
12
|
+
from mysdk.src.tunnel_sdk.events import EventEmitter
|
|
13
|
+
from mysdk.src.tunnel_sdk.connection import TunnelConnection
|
|
14
|
+
from mysdk.src.tunnel_sdk.dispatcher import RequestDispatcher
|
|
15
|
+
from mysdk.src.tunnel_sdk.protocol import PROTOCOL_VERSION
|
|
16
|
+
|
|
17
|
+
__version__ = "1.2.0"
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("tunnel_sdk")
|
|
20
|
+
|
|
21
|
+
class SDKInfo:
|
|
22
|
+
def __init__(self, tunnel: 'Tunnel'):
|
|
23
|
+
self._tunnel = tunnel
|
|
24
|
+
|
|
25
|
+
def info(self) -> dict:
|
|
26
|
+
return {
|
|
27
|
+
"sdk_version": self._tunnel.sdk_version,
|
|
28
|
+
"protocol_version": self._tunnel.protocol_version,
|
|
29
|
+
"gateway_version": self._tunnel.gateway_version,
|
|
30
|
+
"is_connected": self._tunnel.is_connected,
|
|
31
|
+
"uptime": self._tunnel.uptime,
|
|
32
|
+
"reconnect_count": self._tunnel.reconnect_count,
|
|
33
|
+
"statistics": self._tunnel.statistics,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
class Tunnel:
|
|
37
|
+
def __init__(self, api_key: str = None, target_path: str = None, gateway: str = None, port: int = None, local_url: str = None, **kwargs):
|
|
38
|
+
# Support default from env if not provided
|
|
39
|
+
if gateway is None: gateway = os.environ.get("TUNNEL_GATEWAY", "wss://tunnel-g09n.onrender.com")
|
|
40
|
+
if api_key is None: api_key = os.environ.get("TUNNEL_API_KEY")
|
|
41
|
+
if target_path is None: target_path = os.environ.get("TUNNEL_TARGET_PATH")
|
|
42
|
+
|
|
43
|
+
if port is None: port = int(os.environ.get("TUNNEL_PORT", 5000))
|
|
44
|
+
if local_url is None: local_url = os.environ.get("TUNNEL_LOCAL_URL")
|
|
45
|
+
|
|
46
|
+
if not local_url:
|
|
47
|
+
local_url = f"http://127.0.0.1:{port}"
|
|
48
|
+
|
|
49
|
+
if not api_key or not target_path:
|
|
50
|
+
raise ValueError("api_key and target_path must be provided or set in environment variables")
|
|
51
|
+
|
|
52
|
+
self.config = TunnelConfig(
|
|
53
|
+
api_key=api_key,
|
|
54
|
+
target_path=target_path,
|
|
55
|
+
gateway=gateway,
|
|
56
|
+
local_url=local_url,
|
|
57
|
+
port=port,
|
|
58
|
+
**kwargs
|
|
59
|
+
)
|
|
60
|
+
self.stats = TunnelStats()
|
|
61
|
+
self.events = EventEmitter()
|
|
62
|
+
|
|
63
|
+
self._connection = TunnelConnection(self.config, self.stats, self.events)
|
|
64
|
+
self._dispatcher: Optional[RequestDispatcher] = None
|
|
65
|
+
self._main_thread: Optional[threading.Thread] = None
|
|
66
|
+
|
|
67
|
+
# To handle graceful shutdown
|
|
68
|
+
self._is_running = False
|
|
69
|
+
|
|
70
|
+
# SDK Info API
|
|
71
|
+
self.sd = SDKInfo(self)
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_env(cls, **kwargs) -> "Tunnel":
|
|
75
|
+
config = TunnelConfig.from_env()
|
|
76
|
+
return cls(
|
|
77
|
+
api_key=config.api_key,
|
|
78
|
+
target_path=config.target_path,
|
|
79
|
+
gateway=config.gateway,
|
|
80
|
+
local_url=config.local_url,
|
|
81
|
+
port=config.port,
|
|
82
|
+
**kwargs
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def on(self, event: str, callback: Callable) -> None:
|
|
86
|
+
"""Register an event callback."""
|
|
87
|
+
self.events.on(event, callback)
|
|
88
|
+
|
|
89
|
+
def off(self, event: str, callback: Callable) -> None:
|
|
90
|
+
"""Unregister an event callback."""
|
|
91
|
+
self.events.off(event, callback)
|
|
92
|
+
|
|
93
|
+
def start(self, port: Optional[int] = None, local_url: Optional[str] = None) -> None:
|
|
94
|
+
"""Starts the tunnel in a background thread."""
|
|
95
|
+
if self._is_running:
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
url_to_use = local_url or self.config.local_url
|
|
99
|
+
if port is not None:
|
|
100
|
+
url_to_use = f"http://127.0.0.1:{port}"
|
|
101
|
+
|
|
102
|
+
if not url_to_use:
|
|
103
|
+
raise ValueError("local_url or port must be provided either in Tunnel() or start()")
|
|
104
|
+
|
|
105
|
+
self._is_running = True
|
|
106
|
+
self._dispatcher = RequestDispatcher(
|
|
107
|
+
local_url=url_to_use,
|
|
108
|
+
ws_send_func=self._connection.send,
|
|
109
|
+
stats=self.stats,
|
|
110
|
+
events=self.events
|
|
111
|
+
)
|
|
112
|
+
self._connection.dispatcher = self._dispatcher
|
|
113
|
+
|
|
114
|
+
self._main_thread = threading.Thread(target=self._connection.start, daemon=True)
|
|
115
|
+
self._main_thread.start()
|
|
116
|
+
logger.info("Tunnel background thread started.")
|
|
117
|
+
|
|
118
|
+
def run(self, port: Optional[int] = None, local_url: Optional[str] = None) -> None:
|
|
119
|
+
"""Starts the tunnel and blocks the current thread until stopped."""
|
|
120
|
+
self.start(port=port, local_url=local_url)
|
|
121
|
+
self.wait()
|
|
122
|
+
|
|
123
|
+
def stop(self) -> None:
|
|
124
|
+
"""Stops the tunnel and performs graceful shutdown."""
|
|
125
|
+
if not self._is_running:
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
self._is_running = False
|
|
129
|
+
logger.info("Stopping tunnel...")
|
|
130
|
+
|
|
131
|
+
self._connection.stop()
|
|
132
|
+
if self._dispatcher:
|
|
133
|
+
self._dispatcher.shutdown()
|
|
134
|
+
|
|
135
|
+
if self._main_thread and self._main_thread.is_alive():
|
|
136
|
+
self._main_thread.join(timeout=5.0)
|
|
137
|
+
|
|
138
|
+
def restart(self, port: Optional[int] = None, local_url: Optional[str] = None) -> None:
|
|
139
|
+
"""Restarts the tunnel connection."""
|
|
140
|
+
self.stop()
|
|
141
|
+
# Wait a moment for resources to clean up
|
|
142
|
+
time.sleep(1)
|
|
143
|
+
self.start(port=port, local_url=local_url)
|
|
144
|
+
|
|
145
|
+
def wait(self) -> None:
|
|
146
|
+
"""Blocks until the tunnel is stopped."""
|
|
147
|
+
try:
|
|
148
|
+
while self._is_running and self._main_thread and self._main_thread.is_alive():
|
|
149
|
+
time.sleep(1.0)
|
|
150
|
+
except KeyboardInterrupt:
|
|
151
|
+
logger.info("Keyboard interrupt received.")
|
|
152
|
+
self.stop()
|
|
153
|
+
|
|
154
|
+
def __enter__(self):
|
|
155
|
+
self.start()
|
|
156
|
+
return self
|
|
157
|
+
|
|
158
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
159
|
+
self.stop()
|
|
160
|
+
|
|
161
|
+
# Properties
|
|
162
|
+
@property
|
|
163
|
+
def is_connected(self) -> bool:
|
|
164
|
+
return self._connection.connected
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def gateway_version(self) -> Optional[str]:
|
|
168
|
+
# Would be fetched via admin info or headers, but not required right now
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def protocol_version(self) -> str:
|
|
173
|
+
return PROTOCOL_VERSION
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def sdk_version(self) -> str:
|
|
177
|
+
return __version__
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def last_error(self) -> Optional[Exception]:
|
|
181
|
+
return None # Could store the last error caught in connection.py
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def reconnect_count(self) -> int:
|
|
185
|
+
return self.stats.reconnect_count
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def uptime(self) -> float:
|
|
189
|
+
return self.stats.uptime
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def statistics(self) -> dict:
|
|
193
|
+
return {
|
|
194
|
+
"requests_handled": self.stats.requests_handled,
|
|
195
|
+
"active_requests": self.stats.active_requests,
|
|
196
|
+
"bytes_uploaded": self.stats.bytes_uploaded,
|
|
197
|
+
"bytes_downloaded": self.stats.bytes_downloaded,
|
|
198
|
+
"reconnect_count": self.stats.reconnect_count,
|
|
199
|
+
"uptime": self.stats.uptime,
|
|
200
|
+
"last_heartbeat_latency": self.stats.last_heartbeat_latency,
|
|
201
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration models for the Tunnel SDK.
|
|
3
|
+
"""
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from dotenv import load_dotenv
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class TunnelConfig:
|
|
11
|
+
api_key: str
|
|
12
|
+
target_path: str
|
|
13
|
+
gateway: str = "wss://tunnel-g09n.onrender.com"
|
|
14
|
+
local_url: Optional[str] = None
|
|
15
|
+
port: int = 5000
|
|
16
|
+
|
|
17
|
+
# Optional parameters
|
|
18
|
+
max_reconnects: int = -1 # -1 for infinite
|
|
19
|
+
reconnect_base_delay: float = 1.0
|
|
20
|
+
reconnect_max_delay: float = 60.0
|
|
21
|
+
ping_timeout: float = 45.0
|
|
22
|
+
proxy_timeout: float = 30.0
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_env(cls) -> "TunnelConfig":
|
|
26
|
+
"""Loads configuration from environment variables (including .env file)."""
|
|
27
|
+
load_dotenv() # Load variables from .env if present
|
|
28
|
+
|
|
29
|
+
gateway = os.environ.get("TUNNEL_GATEWAY", "wss://tunnel-g09n.onrender.com")
|
|
30
|
+
api_key = os.environ.get("TUNNEL_API_KEY")
|
|
31
|
+
target_path = os.environ.get("TUNNEL_TARGET_PATH")
|
|
32
|
+
|
|
33
|
+
port_str = os.environ.get("TUNNEL_PORT", "5000")
|
|
34
|
+
port = int(port_str)
|
|
35
|
+
|
|
36
|
+
local_url = os.environ.get("TUNNEL_LOCAL_URL")
|
|
37
|
+
if not local_url:
|
|
38
|
+
local_url = f"http://127.0.0.1:{port}"
|
|
39
|
+
|
|
40
|
+
if not api_key:
|
|
41
|
+
raise ValueError("TUNNEL_API_KEY environment variable is missing")
|
|
42
|
+
if not target_path:
|
|
43
|
+
raise ValueError("TUNNEL_TARGET_PATH environment variable is missing")
|
|
44
|
+
|
|
45
|
+
return cls(
|
|
46
|
+
api_key=api_key,
|
|
47
|
+
target_path=target_path,
|
|
48
|
+
gateway=gateway,
|
|
49
|
+
local_url=local_url,
|
|
50
|
+
port=port
|
|
51
|
+
)
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WebSocket connection manager with reconnection logic.
|
|
3
|
+
"""
|
|
4
|
+
import json
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import urllib.parse
|
|
8
|
+
import websocket
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from mysdk.src.tunnel_sdk.config import TunnelConfig
|
|
13
|
+
from mysdk.src.tunnel_sdk.events import EventEmitter
|
|
14
|
+
from mysdk.src.tunnel_sdk.stats import TunnelStats
|
|
15
|
+
from mysdk.src.tunnel_sdk.protocol import PROTOCOL_VERSION, build_pong, parse_message
|
|
16
|
+
|
|
17
|
+
class Colors:
|
|
18
|
+
GREEN = '\033[92m'
|
|
19
|
+
RED = '\033[91m'
|
|
20
|
+
YELLOW = '\033[93m'
|
|
21
|
+
CYAN = '\033[96m'
|
|
22
|
+
RESET = '\033[0m'
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("tunnel_sdk.connection")
|
|
25
|
+
|
|
26
|
+
class TunnelConnection:
|
|
27
|
+
def __init__(self, config: TunnelConfig, stats: TunnelStats, events: EventEmitter):
|
|
28
|
+
self.config = config
|
|
29
|
+
self.stats = stats
|
|
30
|
+
self.events = events
|
|
31
|
+
self.ws_app: Optional[websocket.WebSocketApp] = None
|
|
32
|
+
self.ws_thread: Optional[threading.Thread] = None
|
|
33
|
+
|
|
34
|
+
self.dispatcher = None # Injected by client
|
|
35
|
+
|
|
36
|
+
self._stop_event = threading.Event()
|
|
37
|
+
self._reconnect_lock = threading.Lock()
|
|
38
|
+
|
|
39
|
+
self.connected = False
|
|
40
|
+
self.last_ping_time = 0.0
|
|
41
|
+
|
|
42
|
+
def get_url(self) -> str:
|
|
43
|
+
params = {
|
|
44
|
+
"target_path": self.config.target_path,
|
|
45
|
+
"protocol_version": PROTOCOL_VERSION
|
|
46
|
+
}
|
|
47
|
+
query = urllib.parse.urlencode(params)
|
|
48
|
+
base = self.config.gateway
|
|
49
|
+
if not base.endswith("/"):
|
|
50
|
+
base += "/"
|
|
51
|
+
return f"{base}ws/tunnel?{query}"
|
|
52
|
+
|
|
53
|
+
def start(self):
|
|
54
|
+
self._stop_event.clear()
|
|
55
|
+
self._connect_loop()
|
|
56
|
+
|
|
57
|
+
def _connect_loop(self):
|
|
58
|
+
attempt = 0
|
|
59
|
+
while not self._stop_event.is_set():
|
|
60
|
+
url = self.get_url()
|
|
61
|
+
|
|
62
|
+
if attempt == 0:
|
|
63
|
+
self._wake_gateway()
|
|
64
|
+
if self._stop_event.is_set():
|
|
65
|
+
break
|
|
66
|
+
|
|
67
|
+
logger.info(f"Connecting to {self.config.gateway} as {self.config.target_path}")
|
|
68
|
+
|
|
69
|
+
if attempt > 0:
|
|
70
|
+
self.events.emit("on_reconnect_attempt", attempt)
|
|
71
|
+
self.stats.inc_reconnect()
|
|
72
|
+
|
|
73
|
+
headers = [
|
|
74
|
+
f"Authorization: Bearer {self.config.api_key}",
|
|
75
|
+
f"X-API-Key: {self.config.api_key}"
|
|
76
|
+
]
|
|
77
|
+
self.ws_app = websocket.WebSocketApp(
|
|
78
|
+
url,
|
|
79
|
+
header=headers,
|
|
80
|
+
on_open=self._on_open,
|
|
81
|
+
on_message=self._on_message,
|
|
82
|
+
on_error=self._on_error,
|
|
83
|
+
on_close=self._on_close
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# This blocks until disconnected
|
|
87
|
+
self.ws_app.run_forever()
|
|
88
|
+
|
|
89
|
+
if self._stop_event.is_set():
|
|
90
|
+
break
|
|
91
|
+
|
|
92
|
+
self.connected = False
|
|
93
|
+
|
|
94
|
+
if self.config.max_reconnects != -1 and attempt >= self.config.max_reconnects:
|
|
95
|
+
self.events.emit("on_reconnect_failed")
|
|
96
|
+
logger.error("Max reconnects reached. Stopping.")
|
|
97
|
+
break
|
|
98
|
+
|
|
99
|
+
# Backoff
|
|
100
|
+
delay = min(self.config.reconnect_base_delay * (2 ** attempt), self.config.reconnect_max_delay)
|
|
101
|
+
logger.info(f"Reconnecting in {delay} seconds...")
|
|
102
|
+
attempt += 1
|
|
103
|
+
time.sleep(delay)
|
|
104
|
+
|
|
105
|
+
def _wake_gateway(self):
|
|
106
|
+
"""Sends a proactive HTTP request to wake up the gateway (e.g., if hosted on Render free tier)."""
|
|
107
|
+
base_url = self.config.gateway.replace("wss://", "https://").replace("ws://", "http://").rstrip("/")
|
|
108
|
+
if "localhost" in base_url or "127.0.0.1" in base_url or base_url == "https://test":
|
|
109
|
+
return
|
|
110
|
+
wake_url = f"{base_url}/wake"
|
|
111
|
+
|
|
112
|
+
print("Checking if Gateway is awake (this may take 30-50s if Render is sleeping)...")
|
|
113
|
+
try:
|
|
114
|
+
import httpx
|
|
115
|
+
with httpx.Client(timeout=10.0) as client:
|
|
116
|
+
resp = client.get(wake_url)
|
|
117
|
+
if resp.status_code == 200:
|
|
118
|
+
print("Gateway is awake!")
|
|
119
|
+
except Exception as e:
|
|
120
|
+
logger.warning(f"Wake probe failed (will try websocket anyway): {e}")
|
|
121
|
+
|
|
122
|
+
def stop(self):
|
|
123
|
+
self._stop_event.set()
|
|
124
|
+
if self.ws_app:
|
|
125
|
+
self.ws_app.close()
|
|
126
|
+
|
|
127
|
+
def send(self, data: str):
|
|
128
|
+
if self.ws_app and self.connected:
|
|
129
|
+
self.ws_app.send(data)
|
|
130
|
+
|
|
131
|
+
def _on_open(self, ws):
|
|
132
|
+
self.connected = True
|
|
133
|
+
self.events.emit("on_connect")
|
|
134
|
+
|
|
135
|
+
# Determine the public HTTP URL for user convenience
|
|
136
|
+
base_url = self.config.gateway.replace("wss://", "https://").replace("ws://", "http://").rstrip("/")
|
|
137
|
+
public_url = f"{base_url}{self.config.target_path}"
|
|
138
|
+
local_url = self.config.local_url or "your local server"
|
|
139
|
+
|
|
140
|
+
def print_success():
|
|
141
|
+
time.sleep(0.5)
|
|
142
|
+
if self.connected:
|
|
143
|
+
print(f"\n{Colors.GREEN}" + "="*60)
|
|
144
|
+
print("🚀 TUNNEL CONNECTED SUCCESSFULLY!")
|
|
145
|
+
print(f"🌍 Public URL : {Colors.CYAN}{public_url}{Colors.GREEN}")
|
|
146
|
+
print(f"🏠 Forwarding to : {Colors.CYAN}{local_url}{Colors.GREEN}")
|
|
147
|
+
print("="*60 + f"{Colors.RESET}\n")
|
|
148
|
+
logger.info(f"Tunnel connected successfully. Proxying {public_url} -> {local_url}")
|
|
149
|
+
|
|
150
|
+
threading.Thread(target=print_success, daemon=True).start()
|
|
151
|
+
|
|
152
|
+
def _on_message(self, ws, message):
|
|
153
|
+
try:
|
|
154
|
+
msg = parse_message(message)
|
|
155
|
+
except Exception as e:
|
|
156
|
+
logger.error(f"Error parsing message: {e}")
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
mtype = msg.get("type")
|
|
160
|
+
|
|
161
|
+
# Check for immediate connection error frame
|
|
162
|
+
if "error" in msg:
|
|
163
|
+
error_msg = msg['error']
|
|
164
|
+
logger.error(f"Gateway Error: {error_msg}")
|
|
165
|
+
print(f"\n{Colors.RED}❌ TUNNEL ERROR: {error_msg}{Colors.RESET}\n")
|
|
166
|
+
self.events.emit("on_error", Exception(error_msg))
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
if mtype == "ping":
|
|
170
|
+
if self.last_ping_time > 0:
|
|
171
|
+
latency = time.time() - self.last_ping_time
|
|
172
|
+
self.stats.set_latency(latency)
|
|
173
|
+
self.last_ping_time = time.time()
|
|
174
|
+
self.send(build_pong())
|
|
175
|
+
|
|
176
|
+
elif mtype == "req_single":
|
|
177
|
+
if self.dispatcher:
|
|
178
|
+
self.dispatcher.dispatch_single(msg)
|
|
179
|
+
|
|
180
|
+
elif mtype == "req_start":
|
|
181
|
+
if self.dispatcher:
|
|
182
|
+
self.dispatcher.dispatch_start(msg)
|
|
183
|
+
|
|
184
|
+
elif mtype == "req_chunk":
|
|
185
|
+
if self.dispatcher:
|
|
186
|
+
self.dispatcher.dispatch_chunk(msg)
|
|
187
|
+
|
|
188
|
+
elif mtype == "req_end":
|
|
189
|
+
if self.dispatcher:
|
|
190
|
+
self.dispatcher.dispatch_end(msg)
|
|
191
|
+
else:
|
|
192
|
+
logger.warning(f"Unknown message type: {mtype}")
|
|
193
|
+
|
|
194
|
+
def _on_error(self, ws, error):
|
|
195
|
+
logger.error(f"WebSocket Error: {error}")
|
|
196
|
+
self.events.emit("on_error", error)
|
|
197
|
+
|
|
198
|
+
def _on_close(self, ws, close_status_code, close_msg):
|
|
199
|
+
self.connected = False
|
|
200
|
+
self.events.emit("on_disconnect")
|
|
201
|
+
logger.info(f"Tunnel disconnected: {close_status_code} - {close_msg}")
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dispatches incoming WebSocket messages to the local HTTP server.
|
|
3
|
+
"""
|
|
4
|
+
import threading
|
|
5
|
+
import queue
|
|
6
|
+
import time
|
|
7
|
+
import httpx
|
|
8
|
+
import logging
|
|
9
|
+
import urllib.parse
|
|
10
|
+
from typing import Dict, Any, Callable
|
|
11
|
+
|
|
12
|
+
from mysdk.src.tunnel_sdk.protocol import (
|
|
13
|
+
decode_base64, decode_payload, build_res_single, build_res_start,
|
|
14
|
+
build_res_chunk, build_res_end
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("tunnel_sdk.dispatcher")
|
|
18
|
+
|
|
19
|
+
class RequestDispatcher:
|
|
20
|
+
def __init__(self, local_url: str, ws_send_func: Callable[[str], None], stats, events):
|
|
21
|
+
self.local_url = local_url.rstrip("/")
|
|
22
|
+
self.ws_send_func = ws_send_func
|
|
23
|
+
self.stats = stats
|
|
24
|
+
self.events = events
|
|
25
|
+
limits = httpx.Limits(max_keepalive_connections=20, max_connections=100)
|
|
26
|
+
self.http_client = httpx.Client(timeout=None, limits=limits)
|
|
27
|
+
self.executor = threading.concurrent.futures.ThreadPoolExecutor(max_workers=50) if hasattr(threading, 'concurrent') else None
|
|
28
|
+
|
|
29
|
+
# In Python standard library, ThreadPoolExecutor is in concurrent.futures
|
|
30
|
+
import concurrent.futures
|
|
31
|
+
self.executor = concurrent.futures.ThreadPoolExecutor(max_workers=50)
|
|
32
|
+
|
|
33
|
+
# req_id -> Queue for streaming chunks
|
|
34
|
+
self.streaming_queues: Dict[str, queue.Queue] = {}
|
|
35
|
+
# req_id -> StopEvent to signal end of stream
|
|
36
|
+
self.streaming_events: Dict[str, threading.Event] = {}
|
|
37
|
+
|
|
38
|
+
def shutdown(self):
|
|
39
|
+
self.executor.shutdown(wait=False)
|
|
40
|
+
self.http_client.close()
|
|
41
|
+
|
|
42
|
+
def dispatch_single(self, msg: Dict[str, Any]):
|
|
43
|
+
self.executor.submit(self._handle_single, msg)
|
|
44
|
+
|
|
45
|
+
def dispatch_start(self, msg: Dict[str, Any]):
|
|
46
|
+
req_id = msg["req_id"]
|
|
47
|
+
q = queue.Queue(maxsize=100)
|
|
48
|
+
ev = threading.Event()
|
|
49
|
+
self.streaming_queues[req_id] = q
|
|
50
|
+
self.streaming_events[req_id] = ev
|
|
51
|
+
self.executor.submit(self._handle_stream, msg, q, ev)
|
|
52
|
+
|
|
53
|
+
def dispatch_chunk(self, msg: Dict[str, Any]):
|
|
54
|
+
req_id = msg["req_id"]
|
|
55
|
+
if req_id in self.streaming_queues:
|
|
56
|
+
self.streaming_queues[req_id].put(
|
|
57
|
+
decode_payload(msg["data"], compressed=msg.get("compressed", False))
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def dispatch_end(self, msg: Dict[str, Any]):
|
|
61
|
+
req_id = msg["req_id"]
|
|
62
|
+
if req_id in self.streaming_events:
|
|
63
|
+
self.streaming_events[req_id].set()
|
|
64
|
+
|
|
65
|
+
def _build_url(self, subpath: str, query: str) -> str:
|
|
66
|
+
url = self.local_url + subpath
|
|
67
|
+
if query:
|
|
68
|
+
url += "?" + query
|
|
69
|
+
return url
|
|
70
|
+
|
|
71
|
+
def _filter_headers(self, headers: Dict[str, str]) -> Dict[str, str]:
|
|
72
|
+
# Filter out headers that shouldn't be forwarded to local
|
|
73
|
+
filtered = {}
|
|
74
|
+
for k, v in headers.items():
|
|
75
|
+
k_lower = k.lower()
|
|
76
|
+
if k_lower not in ("host", "connection", "upgrade"):
|
|
77
|
+
filtered[k] = v
|
|
78
|
+
return filtered
|
|
79
|
+
|
|
80
|
+
def _handle_single(self, msg: Dict[str, Any]):
|
|
81
|
+
req_id = msg["req_id"]
|
|
82
|
+
try:
|
|
83
|
+
self.stats.inc_active_requests()
|
|
84
|
+
self.events.emit("on_request_start", req_id)
|
|
85
|
+
|
|
86
|
+
url = self._build_url(msg["subpath"], msg["query"])
|
|
87
|
+
headers = self._filter_headers(msg.get("headers", {}))
|
|
88
|
+
body_bytes = decode_payload(msg.get("body", ""), compressed=msg.get("compressed", False)) if msg.get("body") else None
|
|
89
|
+
|
|
90
|
+
if body_bytes:
|
|
91
|
+
self.stats.add_bytes_downloaded(len(body_bytes))
|
|
92
|
+
|
|
93
|
+
for attempt in range(6):
|
|
94
|
+
try:
|
|
95
|
+
resp = self.http_client.request(
|
|
96
|
+
method=msg["method"],
|
|
97
|
+
url=url,
|
|
98
|
+
headers=headers,
|
|
99
|
+
content=body_bytes,
|
|
100
|
+
follow_redirects=False
|
|
101
|
+
)
|
|
102
|
+
break
|
|
103
|
+
except (httpx.HTTPError, OSError) as e:
|
|
104
|
+
if attempt == 5:
|
|
105
|
+
raise
|
|
106
|
+
time.sleep(0.5)
|
|
107
|
+
|
|
108
|
+
resp_body = resp.content
|
|
109
|
+
self.stats.add_bytes_uploaded(len(resp_body))
|
|
110
|
+
|
|
111
|
+
res_msg = build_res_single(
|
|
112
|
+
req_id=req_id,
|
|
113
|
+
status=resp.status_code,
|
|
114
|
+
headers=dict(resp.headers),
|
|
115
|
+
body=resp_body
|
|
116
|
+
)
|
|
117
|
+
self.ws_send_func(res_msg)
|
|
118
|
+
|
|
119
|
+
except Exception as e:
|
|
120
|
+
logger.error(f"Error handling single request {req_id}: {e}")
|
|
121
|
+
res_msg = build_res_single(req_id, 502, {}, str(e).encode('utf-8'))
|
|
122
|
+
self.ws_send_func(res_msg)
|
|
123
|
+
finally:
|
|
124
|
+
self.stats.dec_active_requests()
|
|
125
|
+
self.events.emit("on_request_end", req_id)
|
|
126
|
+
|
|
127
|
+
def _handle_stream(self, msg: Dict[str, Any], q: queue.Queue, ev: threading.Event):
|
|
128
|
+
req_id = msg["req_id"]
|
|
129
|
+
try:
|
|
130
|
+
self.stats.inc_active_requests()
|
|
131
|
+
self.events.emit("on_request_start", req_id)
|
|
132
|
+
|
|
133
|
+
url = self._build_url(msg["subpath"], msg["query"])
|
|
134
|
+
headers = self._filter_headers(msg.get("headers", {}))
|
|
135
|
+
|
|
136
|
+
def chunk_generator():
|
|
137
|
+
while True:
|
|
138
|
+
try:
|
|
139
|
+
chunk = q.get(timeout=0.1)
|
|
140
|
+
self.stats.add_bytes_downloaded(len(chunk))
|
|
141
|
+
yield chunk
|
|
142
|
+
except queue.Empty:
|
|
143
|
+
if ev.is_set() and q.empty():
|
|
144
|
+
break
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
req = self.http_client.build_request(
|
|
148
|
+
method=msg["method"],
|
|
149
|
+
url=url,
|
|
150
|
+
headers=headers,
|
|
151
|
+
content=chunk_generator()
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
for attempt in range(6):
|
|
155
|
+
try:
|
|
156
|
+
resp = self.http_client.send(req, stream=True, follow_redirects=False)
|
|
157
|
+
break
|
|
158
|
+
except (httpx.HTTPError, OSError) as e:
|
|
159
|
+
if attempt == 5:
|
|
160
|
+
raise
|
|
161
|
+
time.sleep(0.5)
|
|
162
|
+
|
|
163
|
+
# Send res_start
|
|
164
|
+
start_msg = build_res_start(
|
|
165
|
+
req_id=req_id,
|
|
166
|
+
status=resp.status_code,
|
|
167
|
+
headers=dict(resp.headers)
|
|
168
|
+
)
|
|
169
|
+
self.ws_send_func(start_msg)
|
|
170
|
+
|
|
171
|
+
# Send chunks
|
|
172
|
+
for chunk in resp.iter_bytes(chunk_size=32768):
|
|
173
|
+
if chunk:
|
|
174
|
+
self.stats.add_bytes_uploaded(len(chunk))
|
|
175
|
+
self.ws_send_func(build_res_chunk(req_id, chunk))
|
|
176
|
+
|
|
177
|
+
# Send res_end
|
|
178
|
+
self.ws_send_func(build_res_end(req_id))
|
|
179
|
+
resp.close()
|
|
180
|
+
|
|
181
|
+
except Exception as e:
|
|
182
|
+
logger.error(f"Error handling streaming request {req_id}: {e}")
|
|
183
|
+
self.ws_send_func(build_res_single(req_id, 502, {}, str(e).encode('utf-8')))
|
|
184
|
+
finally:
|
|
185
|
+
self.stats.dec_active_requests()
|
|
186
|
+
self.events.emit("on_request_end", req_id)
|
|
187
|
+
self.streaming_queues.pop(req_id, None)
|
|
188
|
+
self.streaming_events.pop(req_id, None)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Event emitter for SDK callbacks.
|
|
3
|
+
"""
|
|
4
|
+
from typing import Callable, Dict, List, Any
|
|
5
|
+
|
|
6
|
+
class EventEmitter:
|
|
7
|
+
def __init__(self):
|
|
8
|
+
self._listeners: Dict[str, List[Callable]] = {}
|
|
9
|
+
|
|
10
|
+
def on(self, event: str, callback: Callable) -> None:
|
|
11
|
+
"""Register a callback for an event."""
|
|
12
|
+
if event not in self._listeners:
|
|
13
|
+
self._listeners[event] = []
|
|
14
|
+
self._listeners[event].append(callback)
|
|
15
|
+
|
|
16
|
+
def off(self, event: str, callback: Callable) -> None:
|
|
17
|
+
"""Unregister a callback for an event."""
|
|
18
|
+
if event in self._listeners:
|
|
19
|
+
try:
|
|
20
|
+
self._listeners[event].remove(callback)
|
|
21
|
+
except ValueError:
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
def emit(self, event: str, *args: Any, **kwargs: Any) -> None:
|
|
25
|
+
"""Emit an event, calling all registered callbacks synchronously."""
|
|
26
|
+
for callback in self._listeners.get(event, []):
|
|
27
|
+
try:
|
|
28
|
+
callback(*args, **kwargs)
|
|
29
|
+
except Exception:
|
|
30
|
+
# User callbacks shouldn't crash the SDK loop
|
|
31
|
+
pass
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom exceptions for the Tunnel SDK.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
class TunnelError(Exception):
|
|
6
|
+
"""Base exception for all Tunnel SDK errors."""
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
class AuthenticationError(TunnelError):
|
|
10
|
+
"""Raised when the API key is invalid."""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
class ConnectionError(TunnelError):
|
|
14
|
+
"""Raised when the WebSocket connection fails or drops unexpectedly."""
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
class ProtocolError(TunnelError):
|
|
18
|
+
"""Raised when the gateway sends malformed or incompatible protocol messages."""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
class TimeoutError(TunnelError):
|
|
22
|
+
"""Raised when heartbeat or proxy requests time out."""
|
|
23
|
+
pass
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Protocol utilities for encoding, decoding, and parsing Tunnel Gateway messages.
|
|
3
|
+
"""
|
|
4
|
+
import base64
|
|
5
|
+
import json
|
|
6
|
+
import zlib
|
|
7
|
+
from typing import Dict, Any, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
from mysdk.src.tunnel_sdk.exceptions import ProtocolError
|
|
10
|
+
|
|
11
|
+
PROTOCOL_VERSION = 1.2
|
|
12
|
+
|
|
13
|
+
def decode_base64(data: str) -> bytes:
|
|
14
|
+
"""Decodes a base64 string to bytes."""
|
|
15
|
+
try:
|
|
16
|
+
return base64.b64decode(data)
|
|
17
|
+
except Exception as e:
|
|
18
|
+
raise ProtocolError(f"Failed to decode base64 data: {e}")
|
|
19
|
+
|
|
20
|
+
def encode_base64(data: bytes) -> str:
|
|
21
|
+
"""Encodes bytes to a base64 string."""
|
|
22
|
+
try:
|
|
23
|
+
return base64.b64encode(data).decode('ascii')
|
|
24
|
+
except Exception as e:
|
|
25
|
+
raise ProtocolError(f"Failed to encode base64 data: {e}")
|
|
26
|
+
|
|
27
|
+
def encode_payload(data: bytes, compress: bool = True) -> Tuple[str, bool]:
|
|
28
|
+
"""Encode raw bytes to a base64 string, optionally compressing with zlib if beneficial."""
|
|
29
|
+
if not data:
|
|
30
|
+
return "", False
|
|
31
|
+
if compress and len(data) >= 64:
|
|
32
|
+
try:
|
|
33
|
+
compressed = zlib.compress(data)
|
|
34
|
+
if len(compressed) < len(data):
|
|
35
|
+
return encode_base64(compressed), True
|
|
36
|
+
except Exception:
|
|
37
|
+
pass
|
|
38
|
+
return encode_base64(data), False
|
|
39
|
+
|
|
40
|
+
def decode_payload(data: str, compressed: bool = False) -> bytes:
|
|
41
|
+
"""Decode a base64 string back to raw bytes, decompressing if compressed."""
|
|
42
|
+
if not data:
|
|
43
|
+
return b""
|
|
44
|
+
raw = decode_base64(data)
|
|
45
|
+
if compressed:
|
|
46
|
+
try:
|
|
47
|
+
return zlib.decompress(raw)
|
|
48
|
+
except Exception as e:
|
|
49
|
+
raise ProtocolError(f"Failed to decompress payload: {e}") from e
|
|
50
|
+
return raw
|
|
51
|
+
|
|
52
|
+
def parse_message(raw_msg: str) -> Dict[str, Any]:
|
|
53
|
+
"""Parses a raw WebSocket message string into a JSON dictionary."""
|
|
54
|
+
try:
|
|
55
|
+
return json.loads(raw_msg)
|
|
56
|
+
except json.JSONDecodeError as e:
|
|
57
|
+
raise ProtocolError(f"Invalid JSON in message: {e}")
|
|
58
|
+
|
|
59
|
+
def build_pong() -> str:
|
|
60
|
+
return json.dumps({"type": "pong"})
|
|
61
|
+
|
|
62
|
+
def build_res_single(req_id: str, status: int, headers: Dict[str, str], body: bytes) -> str:
|
|
63
|
+
encoded_body, compressed = encode_payload(body, compress=True)
|
|
64
|
+
payload = {
|
|
65
|
+
"type": "res_single",
|
|
66
|
+
"req_id": req_id,
|
|
67
|
+
"status": status,
|
|
68
|
+
"headers": headers,
|
|
69
|
+
"body": encoded_body
|
|
70
|
+
}
|
|
71
|
+
if compressed:
|
|
72
|
+
payload["compressed"] = True
|
|
73
|
+
return json.dumps(payload)
|
|
74
|
+
|
|
75
|
+
def build_res_start(req_id: str, status: int, headers: Dict[str, str]) -> str:
|
|
76
|
+
return json.dumps({
|
|
77
|
+
"type": "res_start",
|
|
78
|
+
"req_id": req_id,
|
|
79
|
+
"status": status,
|
|
80
|
+
"headers": headers
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
def build_res_chunk(req_id: str, chunk: bytes) -> str:
|
|
84
|
+
encoded_data, compressed = encode_payload(chunk, compress=True)
|
|
85
|
+
payload = {
|
|
86
|
+
"type": "res_chunk",
|
|
87
|
+
"req_id": req_id,
|
|
88
|
+
"data": encoded_data
|
|
89
|
+
}
|
|
90
|
+
if compressed:
|
|
91
|
+
payload["compressed"] = True
|
|
92
|
+
return json.dumps(payload)
|
|
93
|
+
|
|
94
|
+
def build_res_end(req_id: str) -> str:
|
|
95
|
+
return json.dumps({
|
|
96
|
+
"type": "res_end",
|
|
97
|
+
"req_id": req_id
|
|
98
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Thread-safe statistics tracking.
|
|
3
|
+
"""
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
class TunnelStats:
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self._lock = threading.Lock()
|
|
10
|
+
self.requests_handled = 0
|
|
11
|
+
self.active_requests = 0
|
|
12
|
+
self.bytes_uploaded = 0
|
|
13
|
+
self.bytes_downloaded = 0
|
|
14
|
+
self.reconnect_count = 0
|
|
15
|
+
self.start_time = time.time()
|
|
16
|
+
self.last_heartbeat_latency = 0.0
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def uptime(self) -> float:
|
|
20
|
+
return time.time() - self.start_time
|
|
21
|
+
|
|
22
|
+
def inc_active_requests(self):
|
|
23
|
+
with self._lock:
|
|
24
|
+
self.active_requests += 1
|
|
25
|
+
|
|
26
|
+
def dec_active_requests(self):
|
|
27
|
+
with self._lock:
|
|
28
|
+
self.active_requests = max(0, self.active_requests - 1)
|
|
29
|
+
self.requests_handled += 1
|
|
30
|
+
|
|
31
|
+
def add_bytes_uploaded(self, count: int):
|
|
32
|
+
with self._lock:
|
|
33
|
+
self.bytes_uploaded += count
|
|
34
|
+
|
|
35
|
+
def add_bytes_downloaded(self, count: int):
|
|
36
|
+
with self._lock:
|
|
37
|
+
self.bytes_downloaded += count
|
|
38
|
+
|
|
39
|
+
def inc_reconnect(self):
|
|
40
|
+
with self._lock:
|
|
41
|
+
self.reconnect_count += 1
|
|
42
|
+
|
|
43
|
+
def set_latency(self, latency: float):
|
|
44
|
+
with self._lock:
|
|
45
|
+
self.last_heartbeat_latency = latency
|