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/parse_duration.py ADDED
@@ -0,0 +1,32 @@
1
+ import re
2
+
3
+ DEFAULT_TOKEN_LIFETIME = 3600
4
+
5
+
6
+ def parse_duration(value: str) -> int:
7
+ """
8
+ Converts:
9
+ 30m -> 1800
10
+ 2h -> 7200
11
+ 1d -> 86400
12
+ 90s -> 90
13
+ """
14
+
15
+ match = re.fullmatch(r"(\d+)([smhd])", value.lower())
16
+
17
+ if not match:
18
+ raise ValueError(
19
+ "Time must look like 30m, 8h, 2d or 45s."
20
+ )
21
+
22
+ amount = int(match.group(1))
23
+ unit = match.group(2)
24
+
25
+ multipliers = {
26
+ "s": 1,
27
+ "m": 60,
28
+ "h": 3600,
29
+ "d": 86400,
30
+ }
31
+
32
+ return amount * multipliers[unit]
bore/protocol.py ADDED
@@ -0,0 +1,72 @@
1
+ # borecli/bore/protocol.py
2
+
3
+
4
+ PROTOCOL_VERSION = "borehook/1.0"
5
+
6
+
7
+
8
+ class MessageType:
9
+ """
10
+ BoreHook tunnel protocol message types.
11
+ """
12
+
13
+
14
+ # =====================================================
15
+ # Tunnel lifecycle
16
+ # =====================================================
17
+
18
+ TUNNEL_REGISTER = "tunnel.register"
19
+
20
+ TUNNEL_STATUS = "tunnel.status"
21
+
22
+ TUNNEL_CLOSE = "tunnel.close"
23
+
24
+
25
+
26
+ # =====================================================
27
+ # HTTP tunneling
28
+ # =====================================================
29
+
30
+ HTTP_REQUEST = "http.request"
31
+
32
+ HTTP_RESPONSE = "http.response"
33
+
34
+ HTTP_RESPONSE_CHUNK = (
35
+ "http.response.chunk"
36
+ )
37
+
38
+
39
+
40
+ # =====================================================
41
+ # WebSocket tunneling
42
+ # =====================================================
43
+
44
+ WS_CONNECT = "ws.connect"
45
+
46
+ WS_CONNECTED = "ws.connected"
47
+
48
+ WS_MESSAGE = "ws.message"
49
+
50
+ WS_CLOSE = "ws.close"
51
+
52
+ WS_ERROR = "ws.error"
53
+
54
+
55
+
56
+ # =====================================================
57
+ # Heartbeat
58
+ # =====================================================
59
+
60
+ PING = "ping"
61
+
62
+ PONG = "pong"
63
+
64
+
65
+
66
+ # =====================================================
67
+ # System
68
+ # =====================================================
69
+
70
+ ERROR = "error"
71
+
72
+ METRICS = "metrics"
File without changes
bore/tunnel/client.py ADDED
@@ -0,0 +1,311 @@
1
+ # borecli/bore/tunnel/client.py
2
+
3
+ import asyncio
4
+ import logging
5
+
6
+ import websockets
7
+
8
+ from bore.frames import (receive_frame,send_frame,make_frame,)
9
+ from bore.protocol import MessageType
10
+ from bore.tunnel.dispatcher import ( MessageDispatcher,)
11
+ from bore.tunnel.heartbeat import (Heartbeat,)
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class TunnelClient:
16
+ """
17
+ BoreHook async tunnel client.
18
+
19
+ Handles:
20
+
21
+ - websocket lifecycle
22
+ - protocol receive loop
23
+ - dispatcher
24
+ - heartbeat
25
+ - reconnect
26
+ - graceful shutdown
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ *,
32
+ ws_url: str,
33
+ local_port: int,
34
+ tunnel_id: str,
35
+ reconnect_delay: int = 5,
36
+ ):
37
+
38
+ self.ws_url = ws_url
39
+
40
+ self.local_port = local_port
41
+
42
+ self.tunnel_id = tunnel_id
43
+
44
+ self.reconnect_delay = reconnect_delay
45
+
46
+
47
+ self.websocket = None
48
+
49
+ self.heartbeat = None
50
+
51
+ self.dispatcher = None
52
+
53
+
54
+ self._shutdown = False
55
+
56
+ self._tasks = set()
57
+
58
+ async def _safe_dispatch(self, frame):
59
+ try:
60
+ await self.dispatcher.dispatch(frame)
61
+ except asyncio.CancelledError:
62
+ raise
63
+ except Exception:
64
+ logger.exception("Dispatcher crashed")
65
+
66
+ async def start(
67
+ self,
68
+ should_shutdown=lambda: False,
69
+ ):
70
+
71
+ """
72
+ Start tunnel forever.
73
+ """
74
+
75
+ while not self._shutdown:
76
+
77
+
78
+ if should_shutdown():
79
+
80
+ break
81
+
82
+
83
+ try:
84
+
85
+ await self._connect()
86
+
87
+
88
+ await self._receive_loop(
89
+ should_shutdown
90
+ )
91
+
92
+
93
+ except asyncio.CancelledError:
94
+
95
+ break
96
+
97
+
98
+ except Exception:
99
+
100
+ logger.exception(
101
+ "Tunnel connection failed"
102
+ )
103
+
104
+
105
+ finally:
106
+
107
+ await self._cleanup()
108
+
109
+
110
+
111
+ if self._shutdown:
112
+
113
+ break
114
+
115
+
116
+
117
+ if should_shutdown():
118
+
119
+ break
120
+
121
+
122
+
123
+ logger.info(
124
+ "Reconnecting in %s seconds",
125
+ self.reconnect_delay,
126
+ )
127
+
128
+
129
+ # await asyncio.sleep(
130
+ # self.reconnect_delay
131
+ # )
132
+ for _ in range(self.reconnect_delay * 10):
133
+ if should_shutdown():
134
+ return
135
+ await asyncio.sleep(0.1)
136
+
137
+
138
+ # async def stop(self):
139
+
140
+ # self._shutdown = True
141
+
142
+ # await self._cleanup()
143
+
144
+ async def stop(self):
145
+ self._shutdown = True
146
+
147
+ if self.websocket:
148
+ await self.websocket.close()
149
+
150
+ await self._cleanup()
151
+
152
+
153
+
154
+ async def _connect(self):
155
+
156
+ logger.info(
157
+ "Connecting %s",
158
+ self.ws_url,
159
+ )
160
+
161
+
162
+ self.websocket = await websockets.connect(
163
+
164
+ self.ws_url,
165
+
166
+ ping_interval=None,
167
+
168
+ max_size=None,
169
+
170
+ )
171
+
172
+
173
+ logger.info(
174
+ "Tunnel websocket connected"
175
+ )
176
+
177
+
178
+ #
179
+ # Create dispatcher
180
+ #
181
+
182
+ self.heartbeat = Heartbeat(
183
+ self.websocket
184
+ )
185
+
186
+
187
+ self.dispatcher = MessageDispatcher(
188
+
189
+ websocket=self.websocket,
190
+
191
+ local_port=self.local_port,
192
+
193
+ heartbeat=self.heartbeat,
194
+
195
+ )
196
+ await self.heartbeat.start()
197
+
198
+
199
+
200
+ async def _receive_loop(
201
+ self,
202
+ should_shutdown,
203
+ ):
204
+
205
+
206
+ while True:
207
+
208
+
209
+ if self._shutdown:
210
+
211
+ break
212
+
213
+
214
+ if should_shutdown():
215
+
216
+ break
217
+
218
+
219
+ try:
220
+ frame = await asyncio.wait_for(
221
+ receive_frame(self.websocket),
222
+ timeout=1,
223
+ )
224
+ except asyncio.TimeoutError:
225
+ continue
226
+ # frame = await receive_frame(
227
+ # self.websocket
228
+ # )
229
+
230
+
231
+ if frame is None:
232
+
233
+ break
234
+
235
+
236
+
237
+ # task = asyncio.create_task(
238
+
239
+ # self.dispatcher.dispatch(
240
+ # frame
241
+ # )
242
+
243
+ # )
244
+
245
+ task = asyncio.create_task(
246
+ self._safe_dispatch(frame)
247
+ )
248
+
249
+ self._tasks.add(task)
250
+
251
+
252
+ task.add_done_callback(
253
+ self._tasks.discard
254
+ )
255
+
256
+
257
+
258
+ async def _cleanup(self):
259
+
260
+
261
+ #
262
+ # cancel handlers
263
+ #
264
+
265
+ for task in list(self._tasks):
266
+ task.cancel()
267
+
268
+ if self._tasks:
269
+ await asyncio.gather(
270
+ *self._tasks,
271
+ return_exceptions=True,
272
+ )
273
+
274
+ self._tasks.clear()
275
+
276
+
277
+
278
+ #
279
+ # stop heartbeat
280
+ #
281
+
282
+ if self.heartbeat:
283
+
284
+
285
+ await self.heartbeat.stop()
286
+
287
+ self.heartbeat = None
288
+
289
+
290
+
291
+ #
292
+ # close websocket
293
+ #
294
+
295
+ if self.websocket:
296
+
297
+
298
+ try:
299
+
300
+ await self.websocket.close()
301
+
302
+
303
+ except Exception:
304
+
305
+ pass
306
+
307
+
308
+ self.websocket = None
309
+
310
+ logger.info("Cancelling %d tasks", len(self._tasks))
311
+ self.dispatcher = None
@@ -0,0 +1,277 @@
1
+ # borecli/bore/tunnel/dispatcher.py
2
+
3
+ import logging
4
+
5
+ from bore.protocol import (
6
+ PROTOCOL_VERSION,
7
+ MessageType,
8
+ )
9
+
10
+ from bore.frames import (
11
+ send_frame,
12
+ make_frame,
13
+ )
14
+
15
+ from bore.handlers.http_handler import (
16
+ process_request,
17
+ )
18
+
19
+ from bore.handlers.websocket_handler import (
20
+ handle_ws_connect,
21
+ handle_ws_message,
22
+ handle_ws_close,
23
+ )
24
+
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class MessageDispatcher:
30
+ """
31
+ Routes BoreHook protocol frames.
32
+
33
+ The dispatcher does NOT:
34
+ - open connections
35
+ - forward HTTP
36
+ - manage websockets
37
+
38
+ It only decides who handles each frame.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ websocket,
44
+ local_port,
45
+ heartbeat,
46
+ ):
47
+
48
+ self.websocket = websocket
49
+
50
+ self.local_port = local_port
51
+
52
+ self.heartbeat = heartbeat
53
+
54
+
55
+ async def dispatch(
56
+ self,
57
+ frame: dict,
58
+ ):
59
+
60
+ #
61
+ # Validate protocol
62
+ #
63
+
64
+ protocol = frame.get(
65
+ "protocol"
66
+ )
67
+
68
+ if protocol != PROTOCOL_VERSION:
69
+
70
+ logger.warning(
71
+ "Unsupported protocol: %s",
72
+ protocol,
73
+ )
74
+
75
+ return
76
+
77
+
78
+ message_type = frame.get(
79
+ "type"
80
+ )
81
+
82
+
83
+ if not message_type:
84
+
85
+ logger.warning(
86
+ "Frame missing type"
87
+ )
88
+
89
+ return
90
+
91
+
92
+ logger.debug(
93
+ "Dispatching %s",
94
+ message_type,
95
+ )
96
+
97
+
98
+ #
99
+ # -----------------------------
100
+ # HTTP
101
+ # -----------------------------
102
+ #
103
+
104
+ # if message_type == MessageType.HTTP_REQUEST:
105
+
106
+
107
+ # await process_request(
108
+
109
+ # websocket=self.websocket,
110
+
111
+ # local_port=self.local_port,
112
+
113
+ # frame=frame,
114
+
115
+ # )
116
+
117
+ # return
118
+
119
+ if message_type == MessageType.HTTP_REQUEST:
120
+ try:
121
+ await process_request(
122
+ websocket=self.websocket,
123
+ local_port=self.local_port,
124
+ frame=frame,
125
+ )
126
+ except Exception:
127
+ logger.exception("Failed to process HTTP request")
128
+ return
129
+
130
+
131
+
132
+ #
133
+ # -----------------------------
134
+ # WebSocket
135
+ # -----------------------------
136
+ #
137
+
138
+ if message_type == MessageType.WS_CONNECT:
139
+
140
+
141
+ await handle_ws_connect(
142
+
143
+ websocket=self.websocket,
144
+
145
+ local_port=self.local_port,
146
+
147
+ frame=frame,
148
+
149
+ )
150
+
151
+ return
152
+
153
+
154
+
155
+ if message_type == MessageType.WS_MESSAGE:
156
+
157
+
158
+ await handle_ws_message(
159
+
160
+ frame=frame,
161
+
162
+ )
163
+
164
+ return
165
+
166
+
167
+
168
+ if message_type == MessageType.WS_CLOSE:
169
+
170
+
171
+ await handle_ws_close(
172
+
173
+ frame=frame,
174
+
175
+ )
176
+
177
+ return
178
+
179
+
180
+
181
+ #
182
+ # -----------------------------
183
+ # Heartbeat
184
+ # -----------------------------
185
+ #
186
+
187
+ if message_type == MessageType.PING:
188
+
189
+
190
+ await send_frame(
191
+
192
+ self.websocket,
193
+
194
+ make_frame(
195
+
196
+ MessageType.PONG
197
+
198
+ )
199
+
200
+ )
201
+
202
+ return
203
+
204
+
205
+
206
+ if message_type == MessageType.PONG:
207
+
208
+
209
+ if self.heartbeat:
210
+
211
+ self.heartbeat.pong()
212
+
213
+
214
+ return
215
+
216
+
217
+
218
+ #
219
+ # -----------------------------
220
+ # Server messages
221
+ # -----------------------------
222
+ #
223
+
224
+ if message_type == MessageType.ERROR:
225
+
226
+
227
+ logger.error(
228
+
229
+ "Server error: %s",
230
+
231
+ frame.get(
232
+ "message",
233
+ "Unknown error",
234
+ )
235
+
236
+ )
237
+
238
+ return
239
+
240
+
241
+
242
+ if message_type == MessageType.TUNNEL_STATUS:
243
+
244
+
245
+ logger.info(
246
+
247
+ "Tunnel status: %s",
248
+
249
+ frame.get(
250
+ "status"
251
+ )
252
+
253
+ )
254
+
255
+ return
256
+
257
+
258
+
259
+ if message_type == MessageType.METRICS:
260
+
261
+
262
+ logger.debug(
263
+ "Metrics received: %s",
264
+ frame,
265
+ )
266
+
267
+ return
268
+
269
+
270
+
271
+ logger.warning(
272
+
273
+ "Unknown message type: %s",
274
+
275
+ message_type,
276
+
277
+ )