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,975 @@
1
+
2
+ # borecli/bore/handlers/websocket_handler.py
3
+
4
+ import asyncio
5
+ import base64
6
+ import logging
7
+ import click
8
+ import websockets
9
+ from websockets.exceptions import ConnectionClosed
10
+
11
+ from bore.frames import (
12
+ make_frame,
13
+ make_ws_message,
14
+ make_ws_close,
15
+ send_frame,
16
+ )
17
+
18
+ from bore.protocol import MessageType
19
+
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ #
25
+ # Prevent concurrent websocket frame corruption
26
+ #
27
+ SEND_LOCK = asyncio.Lock()
28
+
29
+
30
+ class WebSocketConnectionManager:
31
+ """
32
+ Maintains active localhost websocket connections.
33
+
34
+ connection_id:
35
+ BoreHook websocket identifier
36
+
37
+ websocket:
38
+ Local application websocket
39
+ """
40
+
41
+ def __init__(self):
42
+
43
+ self._connections = {}
44
+
45
+ self._relay_tasks = {}
46
+
47
+
48
+ def get(
49
+ self,
50
+ connection_id,
51
+ ):
52
+
53
+ return self._connections.get(
54
+ connection_id
55
+ )
56
+
57
+
58
+ def exists(
59
+ self,
60
+ connection_id,
61
+ ):
62
+
63
+ return connection_id in self._connections
64
+
65
+
66
+ def register(
67
+ self,
68
+ connection_id,
69
+ websocket,
70
+ ):
71
+
72
+ self._connections[
73
+ connection_id
74
+ ] = websocket
75
+
76
+
77
+ def register_task(
78
+ self,
79
+ connection_id,
80
+ task,
81
+ ):
82
+
83
+ self._relay_tasks[
84
+ connection_id
85
+ ] = task
86
+
87
+
88
+ async def remove(
89
+ self,
90
+ connection_id,
91
+ ):
92
+
93
+ #
94
+ # Stop relay task
95
+ #
96
+
97
+ task = self._relay_tasks.pop(
98
+ connection_id,
99
+ None,
100
+ )
101
+
102
+
103
+ if (
104
+ task
105
+ and task != asyncio.current_task()
106
+ ):
107
+
108
+ task.cancel()
109
+
110
+ try:
111
+
112
+ await task
113
+
114
+ except asyncio.CancelledError:
115
+
116
+ pass
117
+
118
+ except Exception:
119
+
120
+ logger.debug(
121
+ "Relay shutdown failed.",exc_info=True
122
+ )
123
+
124
+
125
+
126
+ #
127
+ # Close local websocket
128
+ #
129
+
130
+ websocket = self._connections.pop(
131
+ connection_id,
132
+ None,
133
+ )
134
+
135
+
136
+ if websocket:
137
+
138
+ try:
139
+
140
+ await websocket.close()
141
+
142
+ except Exception:
143
+
144
+ pass
145
+
146
+
147
+
148
+ async def shutdown(self):
149
+
150
+ connections = list(
151
+ self._connections.keys()
152
+ )
153
+
154
+ for connection_id in connections:
155
+
156
+ await self.remove(
157
+ connection_id
158
+ )
159
+
160
+
161
+ #
162
+ # Global websocket manager
163
+ #
164
+
165
+ manager = WebSocketConnectionManager()
166
+
167
+ # ------------------------------------------------------------
168
+ # Connect localhost websocket
169
+ # ------------------------------------------------------------
170
+
171
+ async def handle_ws_connect(
172
+ *,
173
+ websocket,
174
+ local_port,
175
+ frame,
176
+ ):
177
+ """
178
+ Handle ws.connect frame from BoreHook server.
179
+
180
+ Creates a websocket connection to the user's
181
+ local websocket application.
182
+ """
183
+
184
+ connection_id = frame.get(
185
+ "connection_id"
186
+ )
187
+
188
+ path = frame.get(
189
+ "path",
190
+ "/",
191
+ )
192
+
193
+ query = frame.get(
194
+ "query",
195
+ "",
196
+ )
197
+
198
+
199
+ if not connection_id or not local_port:
200
+
201
+ logger.error(
202
+ "Invalid ws.connect frame"
203
+ )
204
+
205
+ return
206
+
207
+
208
+ #
209
+ # Prevent duplicate connections
210
+ #
211
+
212
+ if manager.exists(connection_id):
213
+
214
+ logger.warning(
215
+ "Websocket already exists: %s",
216
+ connection_id,
217
+ )
218
+
219
+ return
220
+
221
+
222
+
223
+ #
224
+ # Build localhost websocket URL
225
+ #
226
+
227
+ url = (
228
+ f"ws://127.0.0.1:"
229
+ f"{local_port}"
230
+ f"{path}"
231
+ )
232
+
233
+
234
+ if query:
235
+
236
+ url += f"?{query}"
237
+
238
+
239
+
240
+ #
241
+ # Copy safe headers
242
+ #
243
+
244
+ incoming_headers = frame.get(
245
+ "headers",
246
+ {},
247
+ )
248
+
249
+
250
+ excluded_headers = {
251
+
252
+ "host",
253
+ "connection",
254
+ "upgrade",
255
+ "origin",
256
+ "sec-websocket-key",
257
+ "sec-websocket-version",
258
+ "sec-websocket-extensions",
259
+ "sec-websocket-protocol",
260
+
261
+ }
262
+
263
+
264
+
265
+ headers = [
266
+
267
+ (
268
+ key,
269
+ value,
270
+ )
271
+
272
+ for key, value in incoming_headers.items()
273
+
274
+ if key.lower()
275
+ not in excluded_headers
276
+
277
+ ]
278
+
279
+
280
+
281
+ #
282
+ # Rewrite local headers
283
+ #
284
+
285
+ headers.append(
286
+ (
287
+ "Host",
288
+ f"127.0.0.1:{local_port}",
289
+ )
290
+ )
291
+
292
+
293
+ headers.append(
294
+ (
295
+ "Origin",
296
+ f"http://127.0.0.1:{local_port}",
297
+ )
298
+ )
299
+
300
+
301
+
302
+ logger.info(
303
+ "Connecting local websocket %s",
304
+ url,
305
+ )
306
+
307
+
308
+
309
+ #
310
+ # Connect to local websocket server
311
+ #
312
+
313
+ try:
314
+
315
+ local_ws = await websockets.connect(
316
+
317
+ url,
318
+
319
+ additional_headers=headers,
320
+
321
+ ping_interval=None,
322
+
323
+ max_size=None,
324
+
325
+ )
326
+
327
+
328
+ except Exception:
329
+
330
+ logger.debug("Local websocket connection failed.", exc_info=True)
331
+
332
+ click.secho(
333
+ f"⚠️ Request received, but nothing is listening on "
334
+ f"127.0.0.1:{local_port} — is your local app running?",
335
+ fg="yellow",
336
+ )
337
+
338
+
339
+ async with SEND_LOCK:
340
+
341
+ await send_frame(
342
+
343
+ websocket,
344
+
345
+ make_ws_close(
346
+
347
+ connection_id=connection_id,
348
+
349
+ code=1011,
350
+
351
+ reason=
352
+ "Unable to connect localhost websocket",
353
+
354
+ ),
355
+
356
+ )
357
+
358
+
359
+ return
360
+
361
+
362
+
363
+ #
364
+ # Register connection
365
+ #
366
+
367
+ manager.register(
368
+
369
+ connection_id,
370
+
371
+ local_ws,
372
+
373
+ )
374
+
375
+
376
+
377
+ #
378
+ # Tell server websocket is ready
379
+ #
380
+
381
+ async with SEND_LOCK:
382
+
383
+ await send_frame(
384
+
385
+ websocket,
386
+
387
+ make_frame(
388
+
389
+ MessageType.WS_CONNECTED,
390
+
391
+ connection_id=connection_id,
392
+
393
+ ),
394
+
395
+ )
396
+
397
+
398
+
399
+ #
400
+ # Start localhost -> BoreHook relay
401
+ #
402
+
403
+ relay_task = asyncio.create_task(
404
+
405
+ relay_local_to_server(
406
+
407
+ websocket=websocket,
408
+
409
+ connection_id=connection_id,
410
+
411
+ local_ws=local_ws,
412
+
413
+ )
414
+
415
+ )
416
+
417
+
418
+ manager.register_task(
419
+
420
+ connection_id,
421
+
422
+ relay_task,
423
+
424
+ )
425
+
426
+
427
+ logger.info(
428
+
429
+ "Websocket connected: %s",
430
+
431
+ connection_id,
432
+
433
+ )
434
+
435
+ # ------------------------------------------------------------
436
+ # Relay localhost websocket -> BoreHook server
437
+ # ------------------------------------------------------------
438
+
439
+ async def relay_local_to_server(
440
+ *,
441
+ websocket,
442
+ connection_id,
443
+ local_ws,
444
+ ):
445
+ """
446
+ Forward websocket messages from the user's
447
+ localhost application back to BoreHook.
448
+
449
+ Direction:
450
+
451
+ localhost websocket
452
+ |
453
+ |
454
+ v
455
+
456
+ BoreHook websocket
457
+ """
458
+
459
+ logger.info(
460
+ "Starting websocket relay: %s",
461
+ connection_id,
462
+ )
463
+
464
+
465
+ try:
466
+
467
+ async for message in local_ws:
468
+
469
+
470
+ #
471
+ # Binary websocket message
472
+ #
473
+
474
+ if isinstance(
475
+ message,
476
+ bytes,
477
+ ):
478
+
479
+
480
+ payload = (
481
+ base64.b64encode(
482
+ message
483
+ )
484
+ .decode("ascii")
485
+ )
486
+
487
+
488
+ frame = make_ws_message(
489
+
490
+ connection_id=connection_id,
491
+
492
+ binary=True,
493
+
494
+ body=payload,
495
+
496
+ )
497
+
498
+
499
+ #
500
+ # Text websocket message
501
+ #
502
+
503
+ else:
504
+
505
+
506
+ frame = make_ws_message(
507
+
508
+ connection_id=connection_id,
509
+
510
+ binary=False,
511
+
512
+ body=message,
513
+
514
+ )
515
+
516
+
517
+
518
+ #
519
+ # Send safely
520
+ #
521
+
522
+ async with SEND_LOCK:
523
+
524
+ await send_frame(
525
+
526
+ websocket,
527
+
528
+ frame,
529
+
530
+ )
531
+
532
+
533
+
534
+ except ConnectionClosed as exc:
535
+
536
+
537
+ logger.info(
538
+
539
+ "Local websocket closed %s (%s)",
540
+
541
+ connection_id,
542
+
543
+ exc.code,
544
+
545
+ )
546
+
547
+
548
+ async with SEND_LOCK:
549
+
550
+ await send_frame(
551
+
552
+ websocket,
553
+
554
+ make_ws_close(
555
+
556
+ connection_id=connection_id,
557
+
558
+ code=exc.code,
559
+
560
+ reason=(
561
+ exc.reason
562
+ or ""
563
+ ),
564
+
565
+ ),
566
+
567
+ )
568
+
569
+
570
+
571
+ except asyncio.CancelledError:
572
+
573
+
574
+ logger.info(
575
+
576
+ "Relay cancelled: %s",
577
+
578
+ connection_id,
579
+
580
+ )
581
+
582
+ raise
583
+
584
+
585
+
586
+ except Exception:
587
+ logger.debug("Websocket relay failed.", exc_info=True)
588
+
589
+ click.secho(
590
+ f"⚠️ Websocket relay failed:{connection_id}",
591
+ fg="yellow",
592
+ )
593
+
594
+
595
+ try:
596
+
597
+ async with SEND_LOCK:
598
+
599
+ await send_frame(
600
+
601
+ websocket,
602
+
603
+ make_ws_close(
604
+
605
+ connection_id=connection_id,
606
+
607
+ code=1011,
608
+
609
+ reason="Relay failure",
610
+
611
+ ),
612
+
613
+ )
614
+
615
+ except Exception:
616
+
617
+ pass
618
+
619
+
620
+
621
+ finally:
622
+
623
+
624
+ await manager.remove(
625
+
626
+ connection_id
627
+
628
+ )
629
+
630
+
631
+ logger.info(
632
+
633
+ "Relay stopped: %s",
634
+
635
+ connection_id,
636
+
637
+ )
638
+ # ------------------------------------------------------------
639
+ # Relay BoreHook server -> localhost websocket
640
+ # ------------------------------------------------------------
641
+
642
+ async def handle_ws_message(
643
+ *,
644
+ frame,
645
+ ):
646
+ """
647
+ Forward a websocket message received from
648
+ BoreHook to the user's local websocket.
649
+
650
+ Direction:
651
+
652
+ BoreHook websocket
653
+ |
654
+ |
655
+ v
656
+
657
+ localhost websocket
658
+ """
659
+
660
+ connection_id = frame.get(
661
+ "connection_id"
662
+ )
663
+
664
+
665
+ if not connection_id:
666
+
667
+ logger.warning(
668
+ "ws.message missing connection_id"
669
+ )
670
+
671
+ return
672
+
673
+
674
+
675
+ #
676
+ # Find local websocket
677
+ #
678
+
679
+ local_ws = manager.get(
680
+ connection_id
681
+ )
682
+
683
+
684
+ if local_ws is None:
685
+
686
+ logger.warning(
687
+
688
+ "Unknown websocket connection: %s",
689
+
690
+ connection_id,
691
+
692
+ )
693
+
694
+ return
695
+
696
+
697
+
698
+ binary = frame.get(
699
+ "binary",
700
+ False,
701
+ )
702
+
703
+
704
+ body = frame.get(
705
+ "body",
706
+ "",
707
+ )
708
+
709
+
710
+
711
+ try:
712
+
713
+
714
+ #
715
+ # Binary websocket frame
716
+ #
717
+
718
+ if binary:
719
+
720
+
721
+ payload = base64.b64decode(
722
+ body
723
+ )
724
+
725
+
726
+ await local_ws.send(
727
+ payload
728
+ )
729
+
730
+
731
+
732
+ #
733
+ # Text websocket frame
734
+ #
735
+
736
+ else:
737
+
738
+
739
+ await local_ws.send(
740
+ body
741
+ )
742
+
743
+
744
+
745
+ logger.debug(
746
+
747
+ "Forwarded websocket message: %s",
748
+
749
+ connection_id,
750
+
751
+ )
752
+
753
+
754
+
755
+ except ConnectionClosed:
756
+
757
+
758
+ logger.info(
759
+
760
+ "Local websocket closed: %s",
761
+
762
+ connection_id,
763
+
764
+ )
765
+
766
+
767
+ await manager.remove(
768
+
769
+ connection_id
770
+
771
+ )
772
+
773
+
774
+
775
+ except Exception:
776
+ logger.debug("Unable to forward websocket message.", exc_info=True)
777
+
778
+ click.secho(
779
+ f"⚠️ Unable to forward websocket message",
780
+ fg="yellow",
781
+ )
782
+
783
+
784
+ await manager.remove(
785
+
786
+ connection_id
787
+
788
+ )
789
+
790
+ # ------------------------------------------------------------
791
+ # Close localhost websocket
792
+ # ------------------------------------------------------------
793
+
794
+ async def handle_ws_close(
795
+ *,
796
+ frame,
797
+ ):
798
+ """
799
+ Handle ws.close frame from BoreHook server.
800
+
801
+ Direction:
802
+
803
+ BoreHook
804
+ |
805
+ |
806
+ v
807
+
808
+ localhost websocket close
809
+ """
810
+
811
+ connection_id = frame.get(
812
+ "connection_id"
813
+ )
814
+
815
+
816
+ if not connection_id:
817
+
818
+ logger.warning(
819
+ "ws.close missing connection_id"
820
+ )
821
+
822
+ return
823
+
824
+
825
+
826
+ code = frame.get(
827
+ "code",
828
+ 1000,
829
+ )
830
+
831
+
832
+ reason = frame.get(
833
+ "reason",
834
+ "",
835
+ )
836
+
837
+
838
+
839
+ local_ws = manager.get(
840
+ connection_id
841
+ )
842
+
843
+
844
+ #
845
+ # Already removed
846
+ #
847
+
848
+ if local_ws is None:
849
+
850
+
851
+ logger.debug(
852
+
853
+ "Websocket already closed: %s",
854
+
855
+ connection_id,
856
+
857
+ )
858
+
859
+ return
860
+
861
+
862
+
863
+ logger.info(
864
+
865
+ "Closing websocket %s (%s)",
866
+
867
+ connection_id,
868
+
869
+ code,
870
+
871
+ )
872
+
873
+
874
+
875
+ try:
876
+
877
+
878
+ await local_ws.close(
879
+
880
+ code=code,
881
+
882
+ reason=reason,
883
+
884
+ )
885
+
886
+
887
+
888
+ except ConnectionClosed:
889
+
890
+
891
+ pass
892
+
893
+
894
+
895
+ except Exception:
896
+ logger.debug("Failed closing local websocket.", exc_info=True)
897
+
898
+ click.secho(
899
+ f"⚠️ Request received, but "
900
+ f"Failed closing local websocket",
901
+ fg="yellow",
902
+ )
903
+
904
+
905
+
906
+ finally:
907
+
908
+
909
+ await manager.remove(
910
+
911
+ connection_id
912
+
913
+ )
914
+
915
+
916
+ logger.info(
917
+
918
+ "Websocket removed: %s",
919
+
920
+ connection_id,
921
+
922
+ )
923
+
924
+ # ------------------------------------------------------------
925
+ # Shutdown
926
+ # ------------------------------------------------------------
927
+
928
+ async def shutdown():
929
+ """
930
+ Close every active websocket connection.
931
+
932
+ Called when TunnelClient stops.
933
+ """
934
+
935
+ logger.info(
936
+ "Shutting down websocket handler..."
937
+ )
938
+
939
+
940
+ try:
941
+
942
+ await manager.shutdown()
943
+
944
+
945
+ logger.info(
946
+ "All websocket connections closed."
947
+ )
948
+
949
+
950
+ except Exception:
951
+
952
+ logger.debug("Websocket shutdown failed.", exc_info=True)
953
+
954
+ click.secho(
955
+ f"⚠️ Websocket shutdown failed",
956
+ fg="yellow",
957
+ )
958
+
959
+
960
+
961
+ # ------------------------------------------------------------
962
+ # Public exports
963
+ # ------------------------------------------------------------
964
+
965
+ __all__ = [
966
+
967
+ "handle_ws_connect",
968
+
969
+ "handle_ws_message",
970
+
971
+ "handle_ws_close",
972
+
973
+ "shutdown",
974
+
975
+ ]