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,694 @@
1
+ # borecli/bore/handlers/http_handler.py
2
+
3
+ import base64
4
+ import logging
5
+
6
+ import httpx
7
+ import click
8
+ from bore.frames import (
9
+ make_http_response,
10
+ make_http_chunk,
11
+ send_frame,
12
+ )
13
+
14
+ from bore.handlers.error_pages import (
15
+ tunnel_unreachable_page,
16
+ tunnel_timeout_page,
17
+ tunnel_internal_error_page,
18
+ )
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ #
23
+ # ------------------------------------------------------------
24
+ # Limits
25
+ # ------------------------------------------------------------
26
+ #
27
+
28
+
29
+ MAX_RESPONSE_BYTES = 50 * 1024 * 1024
30
+
31
+ CHUNK_SIZE = 512 * 1024
32
+
33
+ TIMEOUT = httpx.Timeout(
34
+ connect=5,
35
+ read=120,
36
+ write=30,
37
+ pool=30,
38
+ )
39
+
40
+ #
41
+ # ------------------------------------------------------------
42
+ # Shared HTTP Client
43
+ # ------------------------------------------------------------
44
+ #
45
+
46
+ CLIENT = httpx.AsyncClient(
47
+ follow_redirects=False,
48
+ timeout=TIMEOUT,
49
+ )
50
+
51
+ #
52
+ # ------------------------------------------------------------
53
+ # Request headers that must never be forwarded
54
+ # ------------------------------------------------------------
55
+ #
56
+
57
+ EXCLUDED_REQUEST_HEADERS = {
58
+
59
+ "host",
60
+
61
+ "connection",
62
+
63
+ "keep-alive",
64
+
65
+ "proxy-authenticate",
66
+
67
+ "proxy-authorization",
68
+
69
+ "te",
70
+
71
+ "trailers",
72
+
73
+ "transfer-encoding",
74
+
75
+ "upgrade",
76
+
77
+ "content-length",
78
+
79
+ "accept-encoding",
80
+
81
+ }
82
+
83
+ TEXT_CONTENT_TYPES = (
84
+
85
+ "text/",
86
+
87
+ "application/json",
88
+
89
+ "application/javascript",
90
+
91
+ "application/x-javascript",
92
+
93
+ "application/xml",
94
+
95
+ "application/xhtml+xml",
96
+
97
+ "application/graphql",
98
+
99
+ "application/problem+json",
100
+
101
+ "application/ld+json",
102
+
103
+ "image/svg+xml",
104
+
105
+ )
106
+
107
+
108
+ def is_binary_content(
109
+ content_type: str | None,
110
+ ) -> bool:
111
+
112
+ if not content_type:
113
+
114
+ return True
115
+
116
+ content_type = content_type.lower()
117
+
118
+ return not any(
119
+
120
+ content_type.startswith(prefix)
121
+
122
+ for prefix in TEXT_CONTENT_TYPES
123
+
124
+ )
125
+ #
126
+ # ------------------------------------------------------------
127
+ # Forward one HTTP request to localhost
128
+ # ------------------------------------------------------------
129
+ #
130
+
131
+ async def forward_request(
132
+ *,
133
+ local_port: int,
134
+ frame: dict,
135
+ ) -> dict:
136
+ """
137
+ Forward a BoreHook HTTP request to the
138
+ user's local application.
139
+ """
140
+
141
+ method = frame.get("method", "GET")
142
+
143
+ path = frame.get("path", "/")
144
+
145
+ query = frame.get("query_string", "")
146
+
147
+ request_id = frame["request_id"]
148
+
149
+ binary = frame.get("binary", False)
150
+
151
+ body = frame.get("body", "")
152
+
153
+ #
154
+ # --------------------------------------------------------
155
+ # Decode request body
156
+ # --------------------------------------------------------
157
+ #
158
+
159
+ if binary:
160
+
161
+ body = base64.b64decode(body)
162
+
163
+ else:
164
+
165
+ body = body.encode("utf-8")
166
+
167
+ #
168
+ # --------------------------------------------------------
169
+ # Copy headers
170
+ # --------------------------------------------------------
171
+ #
172
+
173
+ incoming_headers = frame.get("headers", {})
174
+
175
+ headers = {
176
+
177
+ key: value
178
+
179
+ for key, value in incoming_headers.items()
180
+
181
+ if key.lower() not in EXCLUDED_REQUEST_HEADERS
182
+
183
+ }
184
+
185
+ #
186
+ # --------------------------------------------------------
187
+ # Build localhost URL
188
+ # --------------------------------------------------------
189
+ #
190
+
191
+ url = f"http://127.0.0.1:{local_port}{path}"
192
+
193
+ if query:
194
+
195
+ url += f"?{query}"
196
+
197
+ logger.info(
198
+ "%s %s",
199
+ method,
200
+ url,
201
+ )
202
+
203
+ try:
204
+
205
+ response = await CLIENT.request(
206
+
207
+ method=method,
208
+
209
+ url=url,
210
+
211
+ headers=headers,
212
+
213
+ content=body,
214
+
215
+ )
216
+
217
+ #
218
+ # ----------------------------------------------------
219
+ # Remove hop-by-hop headers
220
+ # ----------------------------------------------------
221
+ #
222
+
223
+ response_headers = dict(response.headers)
224
+
225
+ response_headers.pop(
226
+ "Transfer-Encoding",
227
+ None,
228
+ )
229
+
230
+ response_headers.pop(
231
+ "Content-Encoding",
232
+ None,
233
+ )
234
+
235
+ response_headers.pop(
236
+ "Content-Length",
237
+ None,
238
+ )
239
+
240
+ raw = response.content
241
+
242
+ if len(raw) > MAX_RESPONSE_BYTES:
243
+
244
+ logger.warning(
245
+ "Response exceeded %d bytes.",
246
+ MAX_RESPONSE_BYTES,
247
+ )
248
+
249
+ raw = raw[:MAX_RESPONSE_BYTES]
250
+
251
+ return {
252
+
253
+ "request_id": request_id,
254
+
255
+ "status": response.status_code,
256
+
257
+ "headers": response_headers,
258
+
259
+ "raw": raw,
260
+
261
+ "binary": is_binary_content(
262
+
263
+ response_headers.get(
264
+
265
+ "Content-Type",
266
+
267
+ "",
268
+
269
+ )
270
+
271
+ ),
272
+
273
+ }
274
+
275
+
276
+ except httpx.ConnectError:
277
+
278
+ logger.debug("Unable to connect to localhost.", exc_info=True)
279
+
280
+ click.secho(
281
+ f"⚠️ Request received, but nothing is listening on "
282
+ f"127.0.0.1:{local_port} — is your local app running?",
283
+ fg="yellow",
284
+ )
285
+
286
+ return {
287
+ "request_id": request_id,
288
+ "status": 200,
289
+ "headers": {"Content-Type": "text/html; charset=utf-8"},
290
+ "raw": tunnel_unreachable_page(local_port=local_port).encode("utf-8"),
291
+ "binary": False,
292
+ }
293
+
294
+ except httpx.ReadTimeout:
295
+
296
+ logger.debug("Local application timed out.", exc_info=True)
297
+
298
+ click.secho(
299
+ f"⚠️ Request to 127.0.0.1:{local_port} timed out.",
300
+ fg="yellow",
301
+ )
302
+
303
+ return {
304
+ "request_id": request_id,
305
+ "status": 200,
306
+ "headers": {"Content-Type": "text/html; charset=utf-8"},
307
+ "raw": tunnel_timeout_page().encode("utf-8"),
308
+ "binary": False,
309
+ }
310
+
311
+ except Exception:
312
+
313
+ logger.debug("Local application timed out.", exc_info=True)
314
+
315
+ click.secho(
316
+ f"⚠️ Request to 127.0.0.1:{local_port} timed out.",
317
+ fg="yellow",
318
+ )
319
+
320
+ return {
321
+ "request_id": request_id,
322
+ "status": 200,
323
+ "headers": {"Content-Type": "text/html; charset=utf-8"},
324
+ "raw": tunnel_internal_error_page().encode("utf-8"),
325
+ "binary": False,
326
+ }
327
+
328
+ #
329
+ # ------------------------------------------------------------
330
+ # Encode response body
331
+ # ------------------------------------------------------------
332
+ #
333
+
334
+ def encode_response_body(
335
+ raw: bytes,
336
+ binary: bool,
337
+ ) -> tuple[str, bytes]:
338
+
339
+ """
340
+ Convert raw response bytes into a string suitable
341
+ for transmission over the BoreHook protocol.
342
+
343
+ Returns
344
+
345
+ encoded_body
346
+ encoded_bytes
347
+ """
348
+
349
+ if binary:
350
+
351
+ encoded = base64.b64encode(
352
+ raw,
353
+ ).decode("ascii")
354
+
355
+ encoded_bytes = encoded.encode(
356
+ "ascii",
357
+ )
358
+
359
+ else:
360
+
361
+ encoded = raw.decode(
362
+ "utf-8",
363
+ errors="replace",
364
+ )
365
+
366
+ encoded_bytes = encoded.encode(
367
+ "utf-8",
368
+ )
369
+
370
+ return encoded, encoded_bytes
371
+
372
+
373
+ #
374
+ # ------------------------------------------------------------
375
+ # Split encoded payload into protocol chunks
376
+ # ------------------------------------------------------------
377
+ #
378
+
379
+ def split_into_chunks(
380
+ encoded_bytes: bytes,
381
+ ):
382
+
383
+ """
384
+ Yield encoded payload chunks.
385
+
386
+ This generator is used for large HTTP responses.
387
+ """
388
+
389
+ for offset in range(
390
+
391
+ 0,
392
+
393
+ len(encoded_bytes),
394
+
395
+ CHUNK_SIZE,
396
+
397
+ ):
398
+
399
+ yield encoded_bytes[
400
+ offset:
401
+ offset + CHUNK_SIZE
402
+ ]
403
+
404
+
405
+ #
406
+ # ------------------------------------------------------------
407
+ # Build protocol frames
408
+ # ------------------------------------------------------------
409
+ #
410
+
411
+ def build_response_frames(
412
+ result: dict,
413
+ ):
414
+
415
+ """
416
+ Convert a forwarded HTTP response into one or more
417
+ BoreHook protocol frames.
418
+ """
419
+
420
+ body, body_bytes = encode_response_body(
421
+
422
+ result["raw"],
423
+
424
+ result["binary"],
425
+
426
+ )
427
+
428
+ #
429
+ # Small response
430
+ #
431
+
432
+ if len(body_bytes) <= CHUNK_SIZE:
433
+
434
+ yield make_http_response(
435
+
436
+ request_id=result["request_id"],
437
+
438
+ status=result["status"],
439
+
440
+ headers=result["headers"],
441
+
442
+ body=body,
443
+
444
+ binary=result["binary"],
445
+
446
+ )
447
+
448
+ return
449
+
450
+ #
451
+ # Chunked response
452
+ #
453
+
454
+ chunks = list(
455
+
456
+ split_into_chunks(
457
+ body_bytes,
458
+ )
459
+
460
+ )
461
+
462
+ total = len(chunks)
463
+
464
+ logger.info(
465
+
466
+ "Sending %s in %d chunks.",
467
+
468
+ result["request_id"],
469
+
470
+ total,
471
+
472
+ )
473
+
474
+ for index, chunk in enumerate(chunks):
475
+
476
+ frame = make_http_chunk(
477
+
478
+ request_id=result["request_id"],
479
+
480
+ chunk_index=index,
481
+
482
+ total_chunks=total,
483
+
484
+ body=chunk.decode(
485
+
486
+ "ascii"
487
+
488
+ if result["binary"]
489
+
490
+ else "utf-8"
491
+
492
+ ),
493
+
494
+ binary=result["binary"],
495
+
496
+ )
497
+
498
+ #
499
+ # Metadata only on first chunk
500
+ #
501
+
502
+ if index == 0:
503
+
504
+ frame["status"] = result["status"]
505
+
506
+ frame["headers"] = result["headers"]
507
+
508
+ yield frame
509
+
510
+ #
511
+ # ------------------------------------------------------------
512
+ # Send one HTTP response
513
+ # ------------------------------------------------------------
514
+ #
515
+
516
+ async def send_response(
517
+ websocket,
518
+ result: dict,
519
+ ) -> None:
520
+ """
521
+ Send a forwarded HTTP response back to
522
+ the BoreHook server.
523
+ """
524
+
525
+ request_id = result["request_id"]
526
+
527
+ try:
528
+
529
+ for frame in build_response_frames(result):
530
+
531
+ await send_frame(
532
+ websocket,
533
+ frame,
534
+ )
535
+
536
+ logger.info(
537
+ "Response sent (%s)",
538
+ request_id,
539
+ )
540
+
541
+ except Exception:
542
+
543
+ logger.exception(
544
+ "Failed sending response %s",
545
+ request_id,
546
+ )
547
+
548
+ raise
549
+
550
+ #
551
+ # ------------------------------------------------------------
552
+ # Public API
553
+ # ------------------------------------------------------------
554
+ #
555
+
556
+ async def process_request(
557
+ *,
558
+ websocket,
559
+ local_port: int,
560
+ frame: dict,
561
+ ) -> None:
562
+ """
563
+ Process one HTTP request received from the
564
+ BoreHook server.
565
+
566
+ Workflow
567
+
568
+ Server
569
+ │
570
+ ▼
571
+ forward_request()
572
+ │
573
+ ▼
574
+ build_response_frames()
575
+ │
576
+ ▼
577
+ send_response()
578
+ """
579
+
580
+ request_id = frame.get("request_id")
581
+
582
+ logger.info(
583
+ "Processing HTTP request %s",
584
+ request_id,
585
+ )
586
+
587
+ try:
588
+
589
+ #
590
+ # Forward request to localhost
591
+ #
592
+
593
+ result = await forward_request(
594
+
595
+ local_port=local_port,
596
+
597
+ frame=frame,
598
+
599
+ )
600
+
601
+ #
602
+ # Send response back to BoreHook
603
+ #
604
+
605
+ await send_response(
606
+
607
+ websocket,
608
+
609
+ result,
610
+
611
+ )
612
+
613
+ logger.info(
614
+ "Completed HTTP request %s",
615
+ request_id,
616
+ )
617
+
618
+ except Exception:
619
+
620
+ logger.exception(
621
+
622
+ "HTTP request failed (%s)",
623
+
624
+ request_id,
625
+
626
+ )
627
+
628
+ #
629
+ # Last-resort error response
630
+ #
631
+
632
+ fallback = {
633
+
634
+ "request_id": request_id,
635
+
636
+ "status": 500,
637
+
638
+ "headers": {
639
+
640
+ "Content-Type":
641
+ "text/plain; charset=utf-8",
642
+
643
+ },
644
+
645
+ "raw": b"Internal BoreHook tunnel error.",
646
+
647
+ "binary": False,
648
+
649
+ }
650
+
651
+ try:
652
+
653
+ await send_response(
654
+
655
+ websocket,
656
+
657
+ fallback,
658
+
659
+ )
660
+
661
+ except Exception:
662
+
663
+ logger.exception(
664
+
665
+ "Unable to send fallback response.",
666
+
667
+ )
668
+
669
+ #
670
+ # ------------------------------------------------------------
671
+ # Cleanup
672
+ # ------------------------------------------------------------
673
+ #
674
+
675
+ async def shutdown() -> None:
676
+ """
677
+ Shutdown the shared HTTP client.
678
+
679
+ Called when the BoreHook tunnel client exits.
680
+ """
681
+
682
+ try:
683
+
684
+ await CLIENT.aclose()
685
+
686
+ logger.info(
687
+ "HTTP client closed."
688
+ )
689
+
690
+ except Exception:
691
+
692
+ logger.exception(
693
+ "Failed closing HTTP client."
694
+ )