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.
@@ -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
+ }
@@ -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
+ }