borecli 1.0.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.
- bore/__init__.py +0 -0
- bore/auth.py +104 -0
- bore/cli.py +378 -0
- bore/config.py +132 -0
- bore/frames.py +376 -0
- bore/handlers/__init__.py +0 -0
- bore/handlers/error_pages.py +223 -0
- bore/handlers/http_handler.py +694 -0
- bore/handlers/websocket_handler.py +975 -0
- bore/parse_duration.py +32 -0
- bore/protocol.py +72 -0
- bore/tunnel/__init__.py +0 -0
- bore/tunnel/client.py +311 -0
- bore/tunnel/dispatcher.py +277 -0
- bore/tunnel/heartbeat.py +154 -0
- bore/tunnel.py +43 -0
- bore/websocket/__init__.py +0 -0
- bore/websocket/connection.py +108 -0
- bore/websocket/frames.py +162 -0
- bore/websocket/manager.py +174 -0
- bore/websocket/proxy.py +147 -0
- bore/websocket_client.py +338 -0
- borecli-1.0.0.dist-info/METADATA +231 -0
- borecli-1.0.0.dist-info/RECORD +28 -0
- borecli-1.0.0.dist-info/WHEEL +5 -0
- borecli-1.0.0.dist-info/entry_points.txt +2 -0
- borecli-1.0.0.dist-info/licenses/LICENSE +0 -0
- borecli-1.0.0.dist-info/top_level.txt +1 -0
bore/tunnel/heartbeat.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# borecli/bore/tunnel/heartbeat.py
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from bore.frames import make_frame
|
|
10
|
+
from bore.protocol import MessageType
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Heartbeat:
|
|
16
|
+
"""
|
|
17
|
+
Maintains the BoreHook tunnel heartbeat.
|
|
18
|
+
|
|
19
|
+
Responsibilities
|
|
20
|
+
----------------
|
|
21
|
+
- Send periodic ping frames
|
|
22
|
+
- Record pong replies
|
|
23
|
+
- Detect dead connections
|
|
24
|
+
- Allow the tunnel client to reconnect
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
websocket,
|
|
30
|
+
*,
|
|
31
|
+
interval: int = 15,
|
|
32
|
+
timeout: int = 45,
|
|
33
|
+
):
|
|
34
|
+
self.websocket = websocket
|
|
35
|
+
|
|
36
|
+
self.interval = interval
|
|
37
|
+
self.timeout = timeout
|
|
38
|
+
|
|
39
|
+
self._running = False
|
|
40
|
+
self._task: Optional[asyncio.Task] = None
|
|
41
|
+
|
|
42
|
+
self._last_pong = time.monotonic()
|
|
43
|
+
|
|
44
|
+
async def start(self):
|
|
45
|
+
"""
|
|
46
|
+
Start the heartbeat loop.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
if self._running:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
logger.info("Heartbeat started")
|
|
53
|
+
|
|
54
|
+
self._running = True
|
|
55
|
+
|
|
56
|
+
self._task = asyncio.create_task(
|
|
57
|
+
self._run(),
|
|
58
|
+
name="heartbeat",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
async def stop(self):
|
|
62
|
+
"""
|
|
63
|
+
Stop the heartbeat loop.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
self._running = False
|
|
67
|
+
|
|
68
|
+
if self._task:
|
|
69
|
+
|
|
70
|
+
self._task.cancel()
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
await self._task
|
|
74
|
+
|
|
75
|
+
except asyncio.CancelledError:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
logger.info("Heartbeat stopped")
|
|
79
|
+
|
|
80
|
+
def pong(self):
|
|
81
|
+
"""
|
|
82
|
+
Called by the dispatcher whenever
|
|
83
|
+
a pong frame is received.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
self._last_pong = time.monotonic()
|
|
87
|
+
|
|
88
|
+
logger.debug("Heartbeat pong")
|
|
89
|
+
|
|
90
|
+
async def _run(self):
|
|
91
|
+
"""
|
|
92
|
+
Internal heartbeat loop.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
while self._running:
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
|
|
99
|
+
#
|
|
100
|
+
# Send ping
|
|
101
|
+
#
|
|
102
|
+
|
|
103
|
+
frame = make_frame(
|
|
104
|
+
MessageType.PING,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
await self.websocket.send(
|
|
108
|
+
json.dumps(frame),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
logger.debug("Heartbeat ping")
|
|
112
|
+
|
|
113
|
+
#
|
|
114
|
+
# Has the server stopped responding?
|
|
115
|
+
#
|
|
116
|
+
|
|
117
|
+
elapsed = (
|
|
118
|
+
time.monotonic()
|
|
119
|
+
- self._last_pong
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if elapsed > self.timeout:
|
|
123
|
+
|
|
124
|
+
logger.warning(
|
|
125
|
+
"Heartbeat timeout "
|
|
126
|
+
"(%.1fs)",
|
|
127
|
+
elapsed,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
await self.websocket.close()
|
|
131
|
+
|
|
132
|
+
break
|
|
133
|
+
|
|
134
|
+
await asyncio.sleep(
|
|
135
|
+
self.interval,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
except asyncio.CancelledError:
|
|
139
|
+
break
|
|
140
|
+
|
|
141
|
+
except Exception as exc:
|
|
142
|
+
|
|
143
|
+
logger.exception(
|
|
144
|
+
"Heartbeat error: %s",
|
|
145
|
+
exc,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
await self.websocket.close()
|
|
150
|
+
|
|
151
|
+
except Exception:
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
break
|
bore/tunnel.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
|
|
3
|
+
from .config import get_token
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# API_URL = "http://127.0.0.1:8000"
|
|
7
|
+
API_URL = "https://api.borehook.com"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def start_tunnel(subdomain, port):
|
|
11
|
+
|
|
12
|
+
token = get_token()
|
|
13
|
+
print("TOKEN:", token)
|
|
14
|
+
|
|
15
|
+
if not token:
|
|
16
|
+
raise Exception(
|
|
17
|
+
"Run: bore login"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
response = requests.post(
|
|
21
|
+
f"{API_URL}/api/tunnels/start/",
|
|
22
|
+
json={
|
|
23
|
+
"subdomain": subdomain,
|
|
24
|
+
"port": port,
|
|
25
|
+
},
|
|
26
|
+
# headers={
|
|
27
|
+
# "Authorization": f"Bearer {token}"
|
|
28
|
+
# },
|
|
29
|
+
headers = {
|
|
30
|
+
"Authorization": f"Token {token}"
|
|
31
|
+
},
|
|
32
|
+
timeout=30,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# print("STATUS:", response.status_code)
|
|
36
|
+
# print("BODY:", response.text)
|
|
37
|
+
|
|
38
|
+
response.raise_for_status()
|
|
39
|
+
|
|
40
|
+
data = response.json()
|
|
41
|
+
print(response.json())
|
|
42
|
+
|
|
43
|
+
return data
|
|
File without changes
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from websockets.client import WebSocketClientProtocol
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConnectionState(str, Enum):
|
|
14
|
+
CONNECTING = "connecting"
|
|
15
|
+
CONNECTED = "connected"
|
|
16
|
+
CLOSING = "closing"
|
|
17
|
+
CLOSED = "closed"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(slots=True)
|
|
21
|
+
class ConnectionStats:
|
|
22
|
+
bytes_sent: int = 0
|
|
23
|
+
bytes_received: int = 0
|
|
24
|
+
|
|
25
|
+
messages_sent: int = 0
|
|
26
|
+
messages_received: int = 0
|
|
27
|
+
|
|
28
|
+
created_at: float = field(default_factory=time.time)
|
|
29
|
+
last_activity: float = field(default_factory=time.time)
|
|
30
|
+
|
|
31
|
+
def sent(self, message):
|
|
32
|
+
size = len(message) if isinstance(message, bytes) else len(str(message))
|
|
33
|
+
|
|
34
|
+
self.bytes_sent += size
|
|
35
|
+
self.messages_sent += 1
|
|
36
|
+
self.last_activity = time.time()
|
|
37
|
+
|
|
38
|
+
def received(self, message):
|
|
39
|
+
size = len(message) if isinstance(message, bytes) else len(str(message))
|
|
40
|
+
|
|
41
|
+
self.bytes_received += size
|
|
42
|
+
self.messages_received += 1
|
|
43
|
+
self.last_activity = time.time()
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def uptime(self):
|
|
47
|
+
return time.time() - self.created_at
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def idle(self):
|
|
51
|
+
return time.time() - self.last_activity
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(slots=True)
|
|
55
|
+
class WebSocketConnection:
|
|
56
|
+
"""
|
|
57
|
+
Represents one localhost websocket connection.
|
|
58
|
+
|
|
59
|
+
This class stores metadata only.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
websocket: WebSocketClientProtocol
|
|
63
|
+
url: str
|
|
64
|
+
|
|
65
|
+
connection_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
66
|
+
|
|
67
|
+
tunnel_id: Optional[str] = None
|
|
68
|
+
|
|
69
|
+
state: ConnectionState = ConnectionState.CONNECTING
|
|
70
|
+
|
|
71
|
+
stats: ConnectionStats = field(default_factory=ConnectionStats)
|
|
72
|
+
|
|
73
|
+
send_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
74
|
+
|
|
75
|
+
metadata: dict = field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def connected(self) -> bool:
|
|
79
|
+
return self.state == ConnectionState.CONNECTED
|
|
80
|
+
|
|
81
|
+
def mark_connected(self):
|
|
82
|
+
self.state = ConnectionState.CONNECTED
|
|
83
|
+
|
|
84
|
+
def mark_closing(self):
|
|
85
|
+
self.state = ConnectionState.CLOSING
|
|
86
|
+
|
|
87
|
+
def mark_closed(self):
|
|
88
|
+
self.state = ConnectionState.CLOSED
|
|
89
|
+
|
|
90
|
+
def record_sent(self, message):
|
|
91
|
+
self.stats.sent(message)
|
|
92
|
+
|
|
93
|
+
def record_received(self, message):
|
|
94
|
+
self.stats.received(message)
|
|
95
|
+
|
|
96
|
+
def to_dict(self):
|
|
97
|
+
return {
|
|
98
|
+
"connection_id": self.connection_id,
|
|
99
|
+
"tunnel_id": self.tunnel_id,
|
|
100
|
+
"url": self.url,
|
|
101
|
+
"state": self.state.value,
|
|
102
|
+
"uptime": self.stats.uptime,
|
|
103
|
+
"idle": self.stats.idle,
|
|
104
|
+
"messages_sent": self.stats.messages_sent,
|
|
105
|
+
"messages_received": self.stats.messages_received,
|
|
106
|
+
"bytes_sent": self.stats.bytes_sent,
|
|
107
|
+
"bytes_received": self.stats.bytes_received,
|
|
108
|
+
}
|
bore/websocket/frames.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FrameType(str, Enum):
|
|
13
|
+
TEXT = "text"
|
|
14
|
+
BINARY = "binary"
|
|
15
|
+
PING = "ping"
|
|
16
|
+
PONG = "pong"
|
|
17
|
+
CLOSE = "close"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Direction(str, Enum):
|
|
21
|
+
CLIENT_TO_LOCAL = "client_to_local"
|
|
22
|
+
LOCAL_TO_CLIENT = "local_to_client"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(slots=True)
|
|
26
|
+
class WebSocketFrame:
|
|
27
|
+
"""
|
|
28
|
+
Represents a websocket frame flowing through the proxy.
|
|
29
|
+
|
|
30
|
+
This class is transport-independent and can be used for
|
|
31
|
+
logging, inspection, replay, and metrics.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
payload: str | bytes
|
|
35
|
+
|
|
36
|
+
frame_type: FrameType
|
|
37
|
+
|
|
38
|
+
direction: Direction
|
|
39
|
+
|
|
40
|
+
connection_id: Optional[str] = None
|
|
41
|
+
|
|
42
|
+
timestamp: float = field(default_factory=time.time)
|
|
43
|
+
|
|
44
|
+
frame_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def is_text(self) -> bool:
|
|
48
|
+
return self.frame_type == FrameType.TEXT
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def is_binary(self) -> bool:
|
|
52
|
+
return self.frame_type == FrameType.BINARY
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def size(self) -> int:
|
|
56
|
+
if isinstance(self.payload, bytes):
|
|
57
|
+
return len(self.payload)
|
|
58
|
+
|
|
59
|
+
return len(self.payload.encode())
|
|
60
|
+
|
|
61
|
+
def as_json(self) -> Optional[Any]:
|
|
62
|
+
"""
|
|
63
|
+
Return parsed JSON if payload contains JSON.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
if not self.is_text:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
return json.loads(self.payload)
|
|
71
|
+
except Exception:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
def serialize(self) -> dict:
|
|
75
|
+
"""
|
|
76
|
+
Convert to a JSON-safe dictionary.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
if self.is_binary:
|
|
80
|
+
payload = base64.b64encode(self.payload).decode()
|
|
81
|
+
encoding = "base64"
|
|
82
|
+
else:
|
|
83
|
+
payload = self.payload
|
|
84
|
+
encoding = "utf-8"
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
"frame_id": self.frame_id,
|
|
88
|
+
"connection_id": self.connection_id,
|
|
89
|
+
"timestamp": self.timestamp,
|
|
90
|
+
"direction": self.direction.value,
|
|
91
|
+
"type": self.frame_type.value,
|
|
92
|
+
"size": self.size,
|
|
93
|
+
"encoding": encoding,
|
|
94
|
+
"payload": payload,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def text(
|
|
99
|
+
cls,
|
|
100
|
+
payload: str,
|
|
101
|
+
direction: Direction,
|
|
102
|
+
connection_id: str | None = None,
|
|
103
|
+
):
|
|
104
|
+
return cls(
|
|
105
|
+
payload=payload,
|
|
106
|
+
frame_type=FrameType.TEXT,
|
|
107
|
+
direction=direction,
|
|
108
|
+
connection_id=connection_id,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def binary(
|
|
113
|
+
cls,
|
|
114
|
+
payload: bytes,
|
|
115
|
+
direction: Direction,
|
|
116
|
+
connection_id: str | None = None,
|
|
117
|
+
):
|
|
118
|
+
return cls(
|
|
119
|
+
payload=payload,
|
|
120
|
+
frame_type=FrameType.BINARY,
|
|
121
|
+
direction=direction,
|
|
122
|
+
connection_id=connection_id,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def ping(
|
|
127
|
+
cls,
|
|
128
|
+
payload: bytes = b"",
|
|
129
|
+
connection_id: str | None = None,
|
|
130
|
+
):
|
|
131
|
+
return cls(
|
|
132
|
+
payload=payload,
|
|
133
|
+
frame_type=FrameType.PING,
|
|
134
|
+
direction=Direction.CLIENT_TO_LOCAL,
|
|
135
|
+
connection_id=connection_id,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def pong(
|
|
140
|
+
cls,
|
|
141
|
+
payload: bytes = b"",
|
|
142
|
+
connection_id: str | None = None,
|
|
143
|
+
):
|
|
144
|
+
return cls(
|
|
145
|
+
payload=payload,
|
|
146
|
+
frame_type=FrameType.PONG,
|
|
147
|
+
direction=Direction.LOCAL_TO_CLIENT,
|
|
148
|
+
connection_id=connection_id,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
@classmethod
|
|
152
|
+
def close(
|
|
153
|
+
cls,
|
|
154
|
+
payload: str = "",
|
|
155
|
+
connection_id: str | None = None,
|
|
156
|
+
):
|
|
157
|
+
return cls(
|
|
158
|
+
payload=payload,
|
|
159
|
+
frame_type=FrameType.CLOSE,
|
|
160
|
+
direction=Direction.CLIENT_TO_LOCAL,
|
|
161
|
+
connection_id=connection_id,
|
|
162
|
+
)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Dict, Optional
|
|
6
|
+
|
|
7
|
+
import websockets
|
|
8
|
+
from websockets.client import WebSocketClientProtocol
|
|
9
|
+
from websockets.exceptions import ConnectionClosed
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ManagedConnection:
|
|
16
|
+
connection_id: str
|
|
17
|
+
websocket: WebSocketClientProtocol
|
|
18
|
+
url: str
|
|
19
|
+
|
|
20
|
+
created_at: float = field(default_factory=time.time)
|
|
21
|
+
last_activity: float = field(default_factory=time.time)
|
|
22
|
+
|
|
23
|
+
bytes_sent: int = 0
|
|
24
|
+
bytes_received: int = 0
|
|
25
|
+
|
|
26
|
+
send_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def connected(self) -> bool:
|
|
30
|
+
return not self.websocket.closed
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class WebSocketManager:
|
|
34
|
+
"""
|
|
35
|
+
Manages websocket connections from the CLI to localhost.
|
|
36
|
+
|
|
37
|
+
This class DOES NOT proxy websocket traffic.
|
|
38
|
+
It only manages the lifecycle of localhost websocket connections.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self):
|
|
42
|
+
self._connections: Dict[str, ManagedConnection] = {}
|
|
43
|
+
self._lock = asyncio.Lock()
|
|
44
|
+
|
|
45
|
+
async def connect(
|
|
46
|
+
self,
|
|
47
|
+
connection_id: str,
|
|
48
|
+
local_port: int,
|
|
49
|
+
path: str,
|
|
50
|
+
query: str = "",
|
|
51
|
+
headers: Optional[dict] = None,
|
|
52
|
+
) -> ManagedConnection:
|
|
53
|
+
|
|
54
|
+
url = f"ws://127.0.0.1:{local_port}{path}"
|
|
55
|
+
|
|
56
|
+
if query:
|
|
57
|
+
url += f"?{query}"
|
|
58
|
+
|
|
59
|
+
logger.info("Connecting to %s", url)
|
|
60
|
+
|
|
61
|
+
ws = await websockets.connect(
|
|
62
|
+
url,
|
|
63
|
+
additional_headers=headers or {},
|
|
64
|
+
ping_interval=20,
|
|
65
|
+
ping_timeout=20,
|
|
66
|
+
close_timeout=5,
|
|
67
|
+
max_size=None,
|
|
68
|
+
max_queue=1024,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
connection = ManagedConnection(
|
|
72
|
+
connection_id=connection_id,
|
|
73
|
+
websocket=ws,
|
|
74
|
+
url=url,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
async with self._lock:
|
|
78
|
+
self._connections[connection_id] = connection
|
|
79
|
+
|
|
80
|
+
logger.info("Connected websocket %s", connection_id)
|
|
81
|
+
|
|
82
|
+
return connection
|
|
83
|
+
|
|
84
|
+
async def send(
|
|
85
|
+
self,
|
|
86
|
+
connection_id: str,
|
|
87
|
+
message,
|
|
88
|
+
):
|
|
89
|
+
|
|
90
|
+
conn = self.get(connection_id)
|
|
91
|
+
|
|
92
|
+
if conn is None:
|
|
93
|
+
raise KeyError(connection_id)
|
|
94
|
+
|
|
95
|
+
async with conn.send_lock:
|
|
96
|
+
|
|
97
|
+
await conn.websocket.send(message)
|
|
98
|
+
|
|
99
|
+
size = len(message) if isinstance(message, bytes) else len(str(message))
|
|
100
|
+
|
|
101
|
+
conn.bytes_sent += size
|
|
102
|
+
conn.last_activity = time.time()
|
|
103
|
+
|
|
104
|
+
async def recv(
|
|
105
|
+
self,
|
|
106
|
+
connection_id: str,
|
|
107
|
+
):
|
|
108
|
+
|
|
109
|
+
conn = self.get(connection_id)
|
|
110
|
+
|
|
111
|
+
if conn is None:
|
|
112
|
+
raise KeyError(connection_id)
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
|
|
116
|
+
message = await conn.websocket.recv()
|
|
117
|
+
|
|
118
|
+
size = len(message) if isinstance(message, bytes) else len(str(message))
|
|
119
|
+
|
|
120
|
+
conn.bytes_received += size
|
|
121
|
+
conn.last_activity = time.time()
|
|
122
|
+
|
|
123
|
+
return message
|
|
124
|
+
|
|
125
|
+
except ConnectionClosed:
|
|
126
|
+
await self.close(connection_id)
|
|
127
|
+
raise
|
|
128
|
+
|
|
129
|
+
async def close(self, connection_id: str):
|
|
130
|
+
|
|
131
|
+
async with self._lock:
|
|
132
|
+
conn = self._connections.pop(connection_id, None)
|
|
133
|
+
|
|
134
|
+
if conn is None:
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
await conn.websocket.close()
|
|
139
|
+
except Exception:
|
|
140
|
+
logger.exception("Failed closing websocket %s", connection_id)
|
|
141
|
+
|
|
142
|
+
async def close_all(self):
|
|
143
|
+
|
|
144
|
+
ids = list(self._connections.keys())
|
|
145
|
+
|
|
146
|
+
await asyncio.gather(
|
|
147
|
+
*(self.close(cid) for cid in ids),
|
|
148
|
+
return_exceptions=True,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def get(self, connection_id: str) -> Optional[ManagedConnection]:
|
|
152
|
+
return self._connections.get(connection_id)
|
|
153
|
+
|
|
154
|
+
def exists(self, connection_id: str) -> bool:
|
|
155
|
+
return connection_id in self._connections
|
|
156
|
+
|
|
157
|
+
def count(self) -> int:
|
|
158
|
+
return len(self._connections)
|
|
159
|
+
|
|
160
|
+
def stats(self):
|
|
161
|
+
|
|
162
|
+
now = time.time()
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
cid: {
|
|
166
|
+
"url": conn.url,
|
|
167
|
+
"connected": conn.connected,
|
|
168
|
+
"uptime": now - conn.created_at,
|
|
169
|
+
"idle": now - conn.last_activity,
|
|
170
|
+
"bytes_sent": conn.bytes_sent,
|
|
171
|
+
"bytes_received": conn.bytes_received,
|
|
172
|
+
}
|
|
173
|
+
for cid, conn in self._connections.items()
|
|
174
|
+
}
|