telethon-webproxy 0.1.0__py3-none-any.whl
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.
- telethon_webproxy/__init__.py +78 -0
- telethon_webproxy/carrier.py +141 -0
- telethon_webproxy/carrier_base.py +347 -0
- telethon_webproxy/carrier_https.py +184 -0
- telethon_webproxy/carrier_lanes.py +210 -0
- telethon_webproxy/connector_v1.py +164 -0
- telethon_webproxy/connector_v2.py +159 -0
- telethon_webproxy/mtproxy.py +68 -0
- telethon_webproxy/protocol.py +189 -0
- telethon_webproxy/reconnect.py +183 -0
- telethon_webproxy-0.1.0.dist-info/METADATA +171 -0
- telethon_webproxy-0.1.0.dist-info/RECORD +15 -0
- telethon_webproxy-0.1.0.dist-info/WHEEL +5 -0
- telethon_webproxy-0.1.0.dist-info/licenses/LICENSE +21 -0
- telethon_webproxy-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
telethon-webproxy — Telegram WEB Proxy connector for Telethon.
|
|
3
|
+
|
|
4
|
+
Supports all four carrier modes:
|
|
5
|
+
• ``websocket`` — single multiplexed WebSocket (default)
|
|
6
|
+
• ``websocket-lanes`` — one WebSocket per stream (best isolation)
|
|
7
|
+
• ``https`` — HTTP long-polling (widest compatibility)
|
|
8
|
+
• ``https-lanes`` — per-stream HTTP long-polling
|
|
9
|
+
|
|
10
|
+
Auto-reconnect is available via :class:`ReconnectingCarrier`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
__version__ = "0.2.0"
|
|
16
|
+
|
|
17
|
+
# ── Protocol core ─────────────────────────────────────────────────────────────
|
|
18
|
+
from .protocol import (
|
|
19
|
+
FrameType,
|
|
20
|
+
Frame,
|
|
21
|
+
compute_capability,
|
|
22
|
+
compute_capability_from_hex,
|
|
23
|
+
encode_frame,
|
|
24
|
+
encode_hello,
|
|
25
|
+
parse_frames,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# ── Carrier base ──────────────────────────────────────────────────────────────
|
|
29
|
+
from .carrier_base import (
|
|
30
|
+
BaseCarrier,
|
|
31
|
+
CarrierError,
|
|
32
|
+
HandshakeError,
|
|
33
|
+
StreamClosedError,
|
|
34
|
+
RelayByeError,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# ── Carrier implementations ──────────────────────────────────────────────────
|
|
38
|
+
from .carrier import WebSocketCarrier
|
|
39
|
+
from .carrier_https import HTTPSCarrier
|
|
40
|
+
from .carrier_lanes import WebSocketLanesCarrier
|
|
41
|
+
from .reconnect import ReconnectingCarrier
|
|
42
|
+
|
|
43
|
+
# ── Telethon connectors ──────────────────────────────────────────────────────
|
|
44
|
+
from .connector_v1 import ConnectionWebProxy
|
|
45
|
+
from .connector_v2 import make_web_proxy_connector, WebProxyStream
|
|
46
|
+
|
|
47
|
+
# ── Version auto-detect ──────────────────────────────────────────────────────
|
|
48
|
+
_telethon_major: int | None = None
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
import importlib.metadata as _meta
|
|
52
|
+
_tv = _meta.version("telethon")
|
|
53
|
+
_telethon_major = int(_tv.split(".")[0])
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
if _telethon_major is not None and _telethon_major >= 2:
|
|
58
|
+
WebProxyConnector = make_web_proxy_connector
|
|
59
|
+
else:
|
|
60
|
+
WebProxyConnector = ConnectionWebProxy
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
# Protocol
|
|
64
|
+
"FrameType", "Frame",
|
|
65
|
+
"compute_capability", "compute_capability_from_hex",
|
|
66
|
+
"encode_frame", "encode_hello", "parse_frames",
|
|
67
|
+
# Carrier base
|
|
68
|
+
"BaseCarrier", "CarrierError", "HandshakeError",
|
|
69
|
+
"StreamClosedError", "RelayByeError",
|
|
70
|
+
# Carriers
|
|
71
|
+
"WebSocketCarrier", "HTTPSCarrier",
|
|
72
|
+
"WebSocketLanesCarrier", "ReconnectingCarrier",
|
|
73
|
+
# Telethon connectors
|
|
74
|
+
"ConnectionWebProxy", "make_web_proxy_connector",
|
|
75
|
+
"WebProxyStream", "WebProxyConnector",
|
|
76
|
+
# Meta
|
|
77
|
+
"__version__",
|
|
78
|
+
]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Multiplexed WebSocket carrier (``websocket`` carrier mode).
|
|
3
|
+
|
|
4
|
+
Reference: PROTOCOL.md §Multiplexed WebSocket
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
import ssl
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
import aiohttp
|
|
15
|
+
|
|
16
|
+
from .carrier_base import BaseCarrier, StreamClosedError
|
|
17
|
+
from .protocol import (
|
|
18
|
+
DATA_CHUNK,
|
|
19
|
+
WS_SUBPROTOCOL_PREFIX,
|
|
20
|
+
WS_UPGRADE_PATH,
|
|
21
|
+
FrameType,
|
|
22
|
+
encode_frame,
|
|
23
|
+
parse_frames,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
log = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class WebSocketCarrier(BaseCarrier):
|
|
30
|
+
"""Single multiplexed WebSocket carrier.
|
|
31
|
+
|
|
32
|
+
All streams share one WSS connection. A WebSocket loss closes the
|
|
33
|
+
entire relay session (per the spec).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
host: str,
|
|
39
|
+
secret_hex: str,
|
|
40
|
+
*,
|
|
41
|
+
ssl_context: Optional[ssl.SSLContext] = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
super().__init__(host, secret_hex, ssl_context=ssl_context)
|
|
44
|
+
self._ws: Optional[aiohttp.ClientWebSocketResponse] = None
|
|
45
|
+
self._reader_task: Optional[asyncio.Task] = None
|
|
46
|
+
|
|
47
|
+
# ── Transport lifecycle ───────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
async def _start_transport(self) -> None:
|
|
50
|
+
si = self._session_info
|
|
51
|
+
subprotocol = WS_SUBPROTOCOL_PREFIX + si.token
|
|
52
|
+
self._ws = await self._http.ws_connect(
|
|
53
|
+
f"wss://{si.host}{WS_UPGRADE_PATH}",
|
|
54
|
+
protocols=[subprotocol],
|
|
55
|
+
ssl=self._ssl,
|
|
56
|
+
max_msg_size=4 * 1024 * 1024,
|
|
57
|
+
origin=f"https://{si.host}",
|
|
58
|
+
)
|
|
59
|
+
self._reader_task = asyncio.get_event_loop().create_task(
|
|
60
|
+
self._reader_loop()
|
|
61
|
+
)
|
|
62
|
+
log.info("WebSocket carrier connected to %s", si.host)
|
|
63
|
+
|
|
64
|
+
async def _stop_transport(self) -> None:
|
|
65
|
+
if self._reader_task:
|
|
66
|
+
self._reader_task.cancel()
|
|
67
|
+
try:
|
|
68
|
+
await self._reader_task
|
|
69
|
+
except (asyncio.CancelledError, Exception):
|
|
70
|
+
pass
|
|
71
|
+
self._reader_task = None
|
|
72
|
+
if self._ws and not self._ws.closed:
|
|
73
|
+
await self._ws.close()
|
|
74
|
+
self._ws = None
|
|
75
|
+
|
|
76
|
+
# ── Stream operations ─────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
async def open_stream(self) -> int:
|
|
79
|
+
sid = self._alloc_stream()
|
|
80
|
+
await self._ws_send(encode_frame(FrameType.OPEN, sid))
|
|
81
|
+
return sid
|
|
82
|
+
|
|
83
|
+
async def close_stream(self, stream_id: int) -> None:
|
|
84
|
+
if stream_id in self._open_streams:
|
|
85
|
+
self._open_streams.discard(stream_id)
|
|
86
|
+
await self._ws_send(encode_frame(FrameType.CLOSE, stream_id))
|
|
87
|
+
|
|
88
|
+
async def send_data(self, stream_id: int, data: bytes) -> None:
|
|
89
|
+
offset = 0
|
|
90
|
+
while offset < len(data):
|
|
91
|
+
chunk_size = min(DATA_CHUNK, len(data) - offset)
|
|
92
|
+
await self._wait_send_window(stream_id, chunk_size)
|
|
93
|
+
chunk = data[offset : offset + chunk_size]
|
|
94
|
+
self._send_windows[stream_id] -= len(chunk)
|
|
95
|
+
await self._ws_send(encode_frame(FrameType.DATA, stream_id, chunk))
|
|
96
|
+
offset += chunk_size
|
|
97
|
+
|
|
98
|
+
async def _send_control(self, frame_bytes: bytes) -> None:
|
|
99
|
+
await self._ws_send(frame_bytes)
|
|
100
|
+
|
|
101
|
+
# ── Internals ─────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
async def _ws_send(self, data: bytes) -> None:
|
|
104
|
+
if self._ws and not self._ws.closed:
|
|
105
|
+
await self._ws.send_bytes(data)
|
|
106
|
+
|
|
107
|
+
async def _reader_loop(self) -> None:
|
|
108
|
+
try:
|
|
109
|
+
async for msg in self._ws:
|
|
110
|
+
if msg.type == aiohttp.WSMsgType.BINARY:
|
|
111
|
+
await self._on_batch(msg.data)
|
|
112
|
+
elif msg.type == aiohttp.WSMsgType.PING:
|
|
113
|
+
await self._ws.pong(msg.data)
|
|
114
|
+
elif msg.type in (
|
|
115
|
+
aiohttp.WSMsgType.CLOSE,
|
|
116
|
+
aiohttp.WSMsgType.CLOSING,
|
|
117
|
+
aiohttp.WSMsgType.CLOSED,
|
|
118
|
+
aiohttp.WSMsgType.ERROR,
|
|
119
|
+
):
|
|
120
|
+
break
|
|
121
|
+
except asyncio.CancelledError:
|
|
122
|
+
return
|
|
123
|
+
except Exception as exc:
|
|
124
|
+
log.warning("WebSocket reader error: %s", exc)
|
|
125
|
+
finally:
|
|
126
|
+
if self._connected:
|
|
127
|
+
await self.disconnect()
|
|
128
|
+
|
|
129
|
+
async def _on_batch(self, data: bytes) -> None:
|
|
130
|
+
try:
|
|
131
|
+
frames = parse_frames(data)
|
|
132
|
+
except ValueError as exc:
|
|
133
|
+
log.warning("Malformed relay batch: %s", exc)
|
|
134
|
+
return
|
|
135
|
+
for frame in frames:
|
|
136
|
+
pong = self._dispatch_frame(frame.type, frame.stream_id, frame.payload)
|
|
137
|
+
if pong:
|
|
138
|
+
await self._ws_send(pong)
|
|
139
|
+
if frame.type == FrameType.BYE:
|
|
140
|
+
await self.disconnect()
|
|
141
|
+
return
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base carrier ABC and shared bootstrap logic.
|
|
3
|
+
|
|
4
|
+
All four carrier modes (https, https-lanes, websocket, websocket-lanes)
|
|
5
|
+
share the same HTTPS bootstrap: GET bridge → POST /session → mode-specific transport.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import abc
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
import re
|
|
14
|
+
import ssl
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
import aiohttp
|
|
18
|
+
|
|
19
|
+
from .protocol import (
|
|
20
|
+
INITIAL_WINDOW,
|
|
21
|
+
SESSION_PATH,
|
|
22
|
+
FrameType,
|
|
23
|
+
compute_capability_from_hex,
|
|
24
|
+
encode_frame,
|
|
25
|
+
encode_hello,
|
|
26
|
+
encode_window,
|
|
27
|
+
parse_frames,
|
|
28
|
+
parse_window,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
log = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
# ── Exceptions ────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CarrierError(Exception):
|
|
37
|
+
"""Base exception for carrier-level errors."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class HandshakeError(CarrierError):
|
|
41
|
+
"""The relay rejected the HELLO or returned an unexpected WELCOME."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class StreamClosedError(CarrierError):
|
|
45
|
+
"""The relay sent CLOSE for a stream we care about."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RelayByeError(CarrierError):
|
|
49
|
+
"""The relay sent BYE, tearing down the whole session."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ── Bootstrap result ──────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SessionInfo:
|
|
56
|
+
"""Result of a successful HTTPS bootstrap."""
|
|
57
|
+
|
|
58
|
+
__slots__ = ("token", "carrier_mode", "base_url", "host")
|
|
59
|
+
|
|
60
|
+
def __init__(self, token: str, carrier_mode: str, base_url: str, host: str):
|
|
61
|
+
self.token = token
|
|
62
|
+
self.carrier_mode = carrier_mode
|
|
63
|
+
self.base_url = base_url
|
|
64
|
+
self.host = host
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ── Stream bookkeeping mixin ─────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class StreamManager:
|
|
71
|
+
"""Per-stream state shared by all carrier implementations."""
|
|
72
|
+
|
|
73
|
+
def __init__(self) -> None:
|
|
74
|
+
self._next_stream_id: int = 1
|
|
75
|
+
self._recv_queues: dict[int, asyncio.Queue[bytes]] = {}
|
|
76
|
+
self._send_windows: dict[int, int] = {}
|
|
77
|
+
self._recv_windows: dict[int, int] = {}
|
|
78
|
+
self._open_streams: set[int] = set()
|
|
79
|
+
self._window_events: dict[int, asyncio.Event] = {}
|
|
80
|
+
|
|
81
|
+
def _alloc_stream(self) -> int:
|
|
82
|
+
sid = self._next_stream_id
|
|
83
|
+
self._next_stream_id += 1
|
|
84
|
+
self._recv_queues[sid] = asyncio.Queue()
|
|
85
|
+
self._send_windows[sid] = INITIAL_WINDOW
|
|
86
|
+
self._recv_windows[sid] = INITIAL_WINDOW
|
|
87
|
+
self._window_events[sid] = asyncio.Event()
|
|
88
|
+
self._window_events[sid].set()
|
|
89
|
+
self._open_streams.add(sid)
|
|
90
|
+
return sid
|
|
91
|
+
|
|
92
|
+
def _remove_stream(self, sid: int) -> None:
|
|
93
|
+
self._open_streams.discard(sid)
|
|
94
|
+
self._recv_queues.pop(sid, None)
|
|
95
|
+
self._send_windows.pop(sid, None)
|
|
96
|
+
self._recv_windows.pop(sid, None)
|
|
97
|
+
ev = self._window_events.pop(sid, None)
|
|
98
|
+
if ev:
|
|
99
|
+
ev.set() # unblock any waiters
|
|
100
|
+
|
|
101
|
+
def _clear_streams(self) -> None:
|
|
102
|
+
for q in self._recv_queues.values():
|
|
103
|
+
try:
|
|
104
|
+
q.put_nowait(b"")
|
|
105
|
+
except asyncio.QueueFull:
|
|
106
|
+
pass
|
|
107
|
+
for ev in self._window_events.values():
|
|
108
|
+
ev.set()
|
|
109
|
+
self._open_streams.clear()
|
|
110
|
+
self._recv_queues.clear()
|
|
111
|
+
self._send_windows.clear()
|
|
112
|
+
self._recv_windows.clear()
|
|
113
|
+
self._window_events.clear()
|
|
114
|
+
|
|
115
|
+
def _dispatch_frame(self, frame_type, sid: int, payload: bytes) -> Optional[bytes]:
|
|
116
|
+
"""Handle one parsed frame, returns PONG bytes to send or None."""
|
|
117
|
+
if frame_type == FrameType.DATA and sid in self._recv_queues:
|
|
118
|
+
self._recv_queues[sid].put_nowait(payload)
|
|
119
|
+
|
|
120
|
+
elif frame_type == FrameType.WINDOW and sid in self._send_windows:
|
|
121
|
+
delta = parse_window(payload)
|
|
122
|
+
self._send_windows[sid] += delta
|
|
123
|
+
ev = self._window_events.get(sid)
|
|
124
|
+
if ev:
|
|
125
|
+
ev.set()
|
|
126
|
+
|
|
127
|
+
elif frame_type == FrameType.CLOSE and sid in self._open_streams:
|
|
128
|
+
self._open_streams.discard(sid)
|
|
129
|
+
q = self._recv_queues.get(sid)
|
|
130
|
+
if q:
|
|
131
|
+
try:
|
|
132
|
+
q.put_nowait(b"")
|
|
133
|
+
except asyncio.QueueFull:
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
elif frame_type == FrameType.PING and sid == 0:
|
|
137
|
+
return encode_frame(FrameType.PONG, 0, payload)
|
|
138
|
+
|
|
139
|
+
elif frame_type == FrameType.BYE:
|
|
140
|
+
log.warning("Relay BYE: %s", payload.decode(errors="replace"))
|
|
141
|
+
return None # caller should disconnect
|
|
142
|
+
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
async def _wait_send_window(self, sid: int, needed: int, timeout: float = 30.0) -> None:
|
|
146
|
+
"""Block until the send window for *sid* has at least *needed* bytes."""
|
|
147
|
+
deadline = asyncio.get_event_loop().time() + timeout
|
|
148
|
+
while self._send_windows.get(sid, 0) < needed:
|
|
149
|
+
if sid not in self._open_streams:
|
|
150
|
+
raise StreamClosedError(f"Stream {sid} closed while waiting for window")
|
|
151
|
+
ev = self._window_events.get(sid)
|
|
152
|
+
if not ev:
|
|
153
|
+
raise StreamClosedError(f"Stream {sid} gone")
|
|
154
|
+
ev.clear()
|
|
155
|
+
remaining = deadline - asyncio.get_event_loop().time()
|
|
156
|
+
if remaining <= 0:
|
|
157
|
+
raise CarrierError("Send window timeout")
|
|
158
|
+
try:
|
|
159
|
+
await asyncio.wait_for(ev.wait(), timeout=remaining)
|
|
160
|
+
except asyncio.TimeoutError:
|
|
161
|
+
raise CarrierError("Send window timeout")
|
|
162
|
+
|
|
163
|
+
async def _recv_data_from_queue(self, sid: int) -> bytes:
|
|
164
|
+
"""Dequeue one DATA payload and return WINDOW credit to grant."""
|
|
165
|
+
q = self._recv_queues.get(sid)
|
|
166
|
+
if q is None:
|
|
167
|
+
raise StreamClosedError(f"Stream {sid} not open")
|
|
168
|
+
|
|
169
|
+
data = await q.get()
|
|
170
|
+
if not data:
|
|
171
|
+
raise StreamClosedError(f"Stream {sid} closed")
|
|
172
|
+
|
|
173
|
+
self._recv_windows[sid] -= len(data)
|
|
174
|
+
return data
|
|
175
|
+
|
|
176
|
+
def _compute_window_grant(self, sid: int) -> Optional[bytes]:
|
|
177
|
+
"""If enough credit has been consumed, return a WINDOW frame to send."""
|
|
178
|
+
current = self._recv_windows.get(sid, INITIAL_WINDOW)
|
|
179
|
+
consumed = INITIAL_WINDOW - current
|
|
180
|
+
if consumed >= INITIAL_WINDOW // 4:
|
|
181
|
+
self._recv_windows[sid] = INITIAL_WINDOW
|
|
182
|
+
return encode_window(sid, consumed)
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ── HTTPS bootstrap ───────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
async def bootstrap_session(
|
|
190
|
+
host: str,
|
|
191
|
+
secret_hex: str,
|
|
192
|
+
session: aiohttp.ClientSession,
|
|
193
|
+
ssl_ctx: Optional[ssl.SSLContext] = None,
|
|
194
|
+
) -> SessionInfo:
|
|
195
|
+
"""Perform the HTTPS bootstrap common to all carrier modes.
|
|
196
|
+
|
|
197
|
+
1. GET /?bridge=<cap> → extract bootstrap token from the bridge page.
|
|
198
|
+
2. POST /api/v1/session with HELLO → receive session token + WELCOME.
|
|
199
|
+
"""
|
|
200
|
+
cap = compute_capability_from_hex(host, secret_hex)
|
|
201
|
+
base_url = f"https://{host}"
|
|
202
|
+
|
|
203
|
+
# 1) Bridge page
|
|
204
|
+
async with session.get(
|
|
205
|
+
f"{base_url}/?bridge={cap}",
|
|
206
|
+
ssl=ssl_ctx,
|
|
207
|
+
) as resp:
|
|
208
|
+
if resp.status != 200:
|
|
209
|
+
raise HandshakeError(f"Bridge page returned HTTP {resp.status}")
|
|
210
|
+
body = await resp.read()
|
|
211
|
+
bootstrap = _extract_bootstrap(body, cap)
|
|
212
|
+
if not bootstrap:
|
|
213
|
+
raise HandshakeError("Could not extract bootstrap token from bridge page")
|
|
214
|
+
|
|
215
|
+
# 2) Session creation
|
|
216
|
+
async with session.post(
|
|
217
|
+
f"{base_url}{SESSION_PATH}",
|
|
218
|
+
headers={
|
|
219
|
+
"Authorization": f"Bearer {bootstrap}",
|
|
220
|
+
"Content-Type": "application/octet-stream",
|
|
221
|
+
},
|
|
222
|
+
data=encode_hello(),
|
|
223
|
+
ssl=ssl_ctx,
|
|
224
|
+
) as resp:
|
|
225
|
+
if resp.status != 200:
|
|
226
|
+
raise HandshakeError(f"Session creation returned HTTP {resp.status}")
|
|
227
|
+
|
|
228
|
+
token = resp.headers.get("X-Session-Token")
|
|
229
|
+
carrier_mode = resp.headers.get("X-Carrier-Mode", "https")
|
|
230
|
+
welcome_body = await resp.read()
|
|
231
|
+
|
|
232
|
+
frames = parse_frames(welcome_body)
|
|
233
|
+
if (
|
|
234
|
+
len(frames) != 1
|
|
235
|
+
or frames[0].type != FrameType.WELCOME
|
|
236
|
+
or frames[0].stream_id != 0
|
|
237
|
+
):
|
|
238
|
+
raise HandshakeError("Invalid WELCOME from relay")
|
|
239
|
+
|
|
240
|
+
log.info(
|
|
241
|
+
"Session bootstrapped (mode=%s, token=%s…)",
|
|
242
|
+
carrier_mode,
|
|
243
|
+
token[:8] if token else "?",
|
|
244
|
+
)
|
|
245
|
+
return SessionInfo(token, carrier_mode, base_url, host)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _extract_bootstrap(page_body: bytes, bridge_cap: str) -> Optional[str]:
|
|
249
|
+
"""Extract the bootstrap bearer token from the bridge HTML."""
|
|
250
|
+
text = page_body.decode("utf-8", errors="replace")
|
|
251
|
+
match = re.search(r'Bearer\s+([A-Za-z0-9_-]{43})', text)
|
|
252
|
+
if match:
|
|
253
|
+
return match.group(1)
|
|
254
|
+
for m in re.finditer(r'"([A-Za-z0-9_-]{43})"', text):
|
|
255
|
+
candidate = m.group(1)
|
|
256
|
+
if candidate != bridge_cap:
|
|
257
|
+
return candidate
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ── Abstract Carrier ──────────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class BaseCarrier(StreamManager, abc.ABC):
|
|
265
|
+
"""Abstract base for all carrier modes."""
|
|
266
|
+
|
|
267
|
+
def __init__(
|
|
268
|
+
self,
|
|
269
|
+
host: str,
|
|
270
|
+
secret_hex: str,
|
|
271
|
+
*,
|
|
272
|
+
ssl_context: Optional[ssl.SSLContext] = None,
|
|
273
|
+
) -> None:
|
|
274
|
+
super().__init__()
|
|
275
|
+
self._host = host
|
|
276
|
+
self._secret_hex = secret_hex
|
|
277
|
+
self._ssl = ssl_context
|
|
278
|
+
self._http: Optional[aiohttp.ClientSession] = None
|
|
279
|
+
self._session_info: Optional[SessionInfo] = None
|
|
280
|
+
self._connected = False
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def connected(self) -> bool:
|
|
284
|
+
return self._connected
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def carrier_mode(self) -> Optional[str]:
|
|
288
|
+
return self._session_info.carrier_mode if self._session_info else None
|
|
289
|
+
|
|
290
|
+
async def connect(self) -> None:
|
|
291
|
+
"""Bootstrap and start the carrier-specific transport."""
|
|
292
|
+
self._http = aiohttp.ClientSession()
|
|
293
|
+
try:
|
|
294
|
+
self._session_info = await bootstrap_session(
|
|
295
|
+
self._host, self._secret_hex, self._http, self._ssl,
|
|
296
|
+
)
|
|
297
|
+
await self._start_transport()
|
|
298
|
+
self._connected = True
|
|
299
|
+
except Exception:
|
|
300
|
+
await self._http.close()
|
|
301
|
+
self._http = None
|
|
302
|
+
raise
|
|
303
|
+
|
|
304
|
+
async def disconnect(self) -> None:
|
|
305
|
+
"""Tear down everything."""
|
|
306
|
+
self._connected = False
|
|
307
|
+
try:
|
|
308
|
+
await self._stop_transport()
|
|
309
|
+
except Exception:
|
|
310
|
+
pass
|
|
311
|
+
self._clear_streams()
|
|
312
|
+
if self._http and not self._http.closed:
|
|
313
|
+
await self._http.close()
|
|
314
|
+
self._http = None
|
|
315
|
+
self._session_info = None
|
|
316
|
+
|
|
317
|
+
@abc.abstractmethod
|
|
318
|
+
async def _start_transport(self) -> None:
|
|
319
|
+
"""Start the carrier-specific transport after bootstrap."""
|
|
320
|
+
|
|
321
|
+
@abc.abstractmethod
|
|
322
|
+
async def _stop_transport(self) -> None:
|
|
323
|
+
"""Stop the carrier-specific transport."""
|
|
324
|
+
|
|
325
|
+
@abc.abstractmethod
|
|
326
|
+
async def open_stream(self) -> int:
|
|
327
|
+
"""Open a new stream. Returns stream id."""
|
|
328
|
+
|
|
329
|
+
@abc.abstractmethod
|
|
330
|
+
async def close_stream(self, stream_id: int) -> None:
|
|
331
|
+
"""Send CLOSE for a stream."""
|
|
332
|
+
|
|
333
|
+
@abc.abstractmethod
|
|
334
|
+
async def send_data(self, stream_id: int, data: bytes) -> None:
|
|
335
|
+
"""Send DATA on a stream, respecting flow control."""
|
|
336
|
+
|
|
337
|
+
async def recv_data(self, stream_id: int) -> bytes:
|
|
338
|
+
"""Receive one DATA payload from a stream."""
|
|
339
|
+
data = await self._recv_data_from_queue(stream_id)
|
|
340
|
+
grant = self._compute_window_grant(stream_id)
|
|
341
|
+
if grant:
|
|
342
|
+
await self._send_control(grant)
|
|
343
|
+
return data
|
|
344
|
+
|
|
345
|
+
@abc.abstractmethod
|
|
346
|
+
async def _send_control(self, frame_bytes: bytes) -> None:
|
|
347
|
+
"""Send a control frame (WINDOW, PONG) via the transport."""
|