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,147 @@
1
+ import asyncio
2
+ import contextlib
3
+ import logging
4
+ from typing import Awaitable, Callable, Optional
5
+
6
+ from websockets.exceptions import ConnectionClosed
7
+ from websockets.protocol import State
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class WebSocketProxy:
13
+ """
14
+ Bidirectional websocket proxy.
15
+
16
+ Relays websocket frames between two websocket connections while
17
+ preserving text and binary frames.
18
+
19
+ The proxy exits when either side disconnects.
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ client_ws,
25
+ local_ws,
26
+ *,
27
+ on_close: Optional[Callable[[], Awaitable[None]]] = None,
28
+ ):
29
+ self.client_ws = client_ws
30
+ self.local_ws = local_ws
31
+ self.on_close = on_close
32
+
33
+ self._closed = False
34
+
35
+ async def run(self):
36
+ """
37
+ Start bidirectional proxying.
38
+
39
+ Returns when either websocket closes.
40
+ """
41
+
42
+ task1 = asyncio.create_task(
43
+ self._pipe(
44
+ self.client_ws,
45
+ self.local_ws,
46
+ "CLIENT",
47
+ "LOCAL",
48
+ )
49
+ )
50
+
51
+ task2 = asyncio.create_task(
52
+ self._pipe(
53
+ self.local_ws,
54
+ self.client_ws,
55
+ "LOCAL",
56
+ "CLIENT",
57
+ )
58
+ )
59
+
60
+ done, pending = await asyncio.wait(
61
+ {task1, task2},
62
+ return_when=asyncio.FIRST_COMPLETED,
63
+ )
64
+
65
+ for task in pending:
66
+ task.cancel()
67
+
68
+ for task in pending:
69
+ with contextlib.suppress(asyncio.CancelledError):
70
+ await task
71
+
72
+ await self.close()
73
+
74
+ async def _pipe(
75
+ self,
76
+ source,
77
+ destination,
78
+ source_name: str,
79
+ destination_name: str,
80
+ ):
81
+ """
82
+ Forward websocket frames.
83
+ """
84
+
85
+ try:
86
+
87
+ async for message in source:
88
+
89
+ if isinstance(message, bytes):
90
+ logger.debug(
91
+ "%s → %s (%d bytes)",
92
+ source_name,
93
+ destination_name,
94
+ len(message),
95
+ )
96
+ else:
97
+ logger.debug(
98
+ "%s → %s (%d chars)",
99
+ source_name,
100
+ destination_name,
101
+ len(message),
102
+ )
103
+
104
+ # Preserve frame type automatically.
105
+ await destination.send(message)
106
+
107
+ except ConnectionClosed as exc:
108
+
109
+ logger.info(
110
+ "%s disconnected (%s)",
111
+ source_name,
112
+ exc.code,
113
+ )
114
+
115
+ except asyncio.CancelledError:
116
+ raise
117
+
118
+ except Exception:
119
+
120
+ logger.exception(
121
+ "Proxy error %s -> %s",
122
+ source_name,
123
+ destination_name,
124
+ )
125
+
126
+ async def close(self):
127
+
128
+ if self._closed:
129
+ return
130
+
131
+ self._closed = True
132
+
133
+ await self._close_socket(self.client_ws)
134
+ await self._close_socket(self.local_ws)
135
+
136
+ if self.on_close:
137
+ await self.on_close()
138
+
139
+ async def _close_socket(self, ws):
140
+
141
+ try:
142
+
143
+ if ws.state is not State.CLOSED:
144
+ await ws.close()
145
+
146
+ except Exception:
147
+ pass
@@ -0,0 +1,338 @@
1
+ # borecli/bore/websocket_client.py
2
+
3
+ import base64
4
+ import json
5
+ import logging
6
+ import threading
7
+ from concurrent.futures import ThreadPoolExecutor
8
+
9
+ import requests
10
+ import websocket
11
+
12
+ logging.basicConfig(
13
+ level=logging.INFO,
14
+ format="%(asctime)s [%(threadName)s] %(levelname)s %(message)s",
15
+ )
16
+ logger = logging.getLogger(__name__)
17
+
18
+ DEFAULT_WORKERS = 20
19
+ MAX_RESPONSE_BYTES = 50 * 1024 * 1024
20
+ CHUNK_SIZE = 512 * 1024
21
+
22
+ SESSION = requests.Session()
23
+ adapter = requests.adapters.HTTPAdapter(
24
+ pool_connections=DEFAULT_WORKERS,
25
+ pool_maxsize=DEFAULT_WORKERS,
26
+ )
27
+ SESSION.mount("http://", adapter)
28
+ SESSION.mount("https://", adapter)
29
+
30
+ SEND_LOCK = threading.Lock()
31
+
32
+ EXCLUDED_REQUEST_HEADERS = {
33
+ "host", "connection", "keep-alive",
34
+ "proxy-authenticate", "proxy-authorization",
35
+ "te", "trailers", "transfer-encoding",
36
+ "upgrade", "content-length", "accept-encoding",
37
+ }
38
+
39
+
40
+ def is_binary_content(content_type):
41
+ if not content_type:
42
+ return True
43
+ content_type = content_type.lower()
44
+ text_types = (
45
+ "text/",
46
+ "application/json",
47
+ "application/javascript",
48
+ "application/x-javascript",
49
+ "application/xml",
50
+ "application/xhtml+xml",
51
+ "application/graphql",
52
+ "application/ld+json",
53
+ "application/problem+json",
54
+ "image/svg+xml",
55
+ )
56
+ return not any(content_type.startswith(p) for p in text_types)
57
+
58
+
59
+ def handle_request(local_port, data):
60
+ method = data.get("method", "GET")
61
+ path = data.get("path", "/")
62
+ body = data.get("body", "")
63
+ request_binary = data.get("binary", False)
64
+
65
+ #
66
+ # Decode request body received from BoreHook.
67
+ #
68
+ if request_binary:
69
+ body = base64.b64decode(body)
70
+ else:
71
+ if isinstance(body, str):
72
+ body = body.encode("utf-8")
73
+ request_id = data.get("request_id")
74
+ incoming_headers = dict(data.get("headers", {}))
75
+
76
+ headers = {
77
+ k: v for k, v in incoming_headers.items()
78
+ if k.lower() not in EXCLUDED_REQUEST_HEADERS
79
+ }
80
+
81
+ url = f"http://127.0.0.1:{local_port}{path}"
82
+ logger.info("Incoming %s %s", method, path)
83
+
84
+ try:
85
+ response = SESSION.request(
86
+ method=method,
87
+ url=url,
88
+ headers=headers,
89
+ data=body,
90
+ allow_redirects=False,
91
+ timeout=(5, 120),
92
+ stream=True,
93
+ )
94
+
95
+ response_headers = dict(response.headers)
96
+ response_headers.pop("Content-Encoding", None)
97
+ response_headers.pop("Transfer-Encoding", None)
98
+ response_headers.pop("Content-Length", None)
99
+
100
+ content_type = response_headers.get("Content-Type", "")
101
+ binary = is_binary_content(content_type)
102
+
103
+ chunks = []
104
+ total = 0
105
+ for chunk in response.iter_content(chunk_size=65536):
106
+ if chunk:
107
+ total += len(chunk)
108
+ if total > MAX_RESPONSE_BYTES:
109
+ logger.warning("Response for %s exceeded %d bytes, truncating.", path, MAX_RESPONSE_BYTES)
110
+ break
111
+ chunks.append(chunk)
112
+ raw = b"".join(chunks)
113
+
114
+ logger.info("Completed %s %s -> %s (%d bytes)", method, path, response.status_code, total)
115
+
116
+ return {
117
+ "request_id": request_id,
118
+ "status": response.status_code,
119
+ "headers": response_headers,
120
+ "raw": raw,
121
+ "binary": binary,
122
+ }
123
+
124
+ except requests.Timeout:
125
+ logger.error("Timeout forwarding %s %s", method, path)
126
+ return {
127
+ "request_id": request_id,
128
+ "status": 504,
129
+ "headers": {"Content-Type": "text/plain; charset=utf-8"},
130
+ "raw": b"Tunnel timeout",
131
+ "binary": False,
132
+ }
133
+ except requests.RequestException:
134
+ logger.exception("Error forwarding request.")
135
+ return {
136
+ "request_id": request_id,
137
+ "status": 502,
138
+ "headers": {"Content-Type": "text/plain; charset=utf-8"},
139
+ "raw": b"Unable to reach local application.",
140
+ "binary": False,
141
+ }
142
+ except Exception:
143
+ logger.exception("Unexpected forwarding error.")
144
+ return {
145
+ "request_id": request_id,
146
+ "status": 500,
147
+ "headers": {"Content-Type": "text/plain; charset=utf-8"},
148
+ "raw": b"Internal tunnel error.",
149
+ "binary": False,
150
+ }
151
+
152
+
153
+ def send_response(ws, result):
154
+ request_id = result["request_id"]
155
+ binary = result["binary"]
156
+ raw = result["raw"]
157
+
158
+ if binary:
159
+ encoded = base64.b64encode(raw).decode("ascii")
160
+ body_bytes = encoded.encode("ascii")
161
+ else:
162
+ encoded = raw.decode("utf-8", errors="replace")
163
+ body_bytes = encoded.encode("utf-8")
164
+
165
+ total_size = len(body_bytes)
166
+
167
+ if total_size <= CHUNK_SIZE:
168
+ payload = json.dumps({
169
+ "type": "response",
170
+ "request_id": request_id,
171
+ "status": result["status"],
172
+ "headers": result["headers"],
173
+ "body": encoded,
174
+ "binary": binary,
175
+ })
176
+ with SEND_LOCK:
177
+ ws.send(payload)
178
+ return
179
+
180
+ chunk_list = [
181
+ body_bytes[i: i + CHUNK_SIZE]
182
+ for i in range(0, total_size, CHUNK_SIZE)
183
+ ]
184
+ total_chunks = len(chunk_list)
185
+ logger.info("Sending %s in %d chunks (%d bytes total)", request_id, total_chunks, total_size)
186
+
187
+ for index, chunk in enumerate(chunk_list):
188
+ chunk_body = chunk.decode("ascii" if binary else "utf-8")
189
+ frame = {
190
+ "type": "response_chunk",
191
+ "request_id": request_id,
192
+ "chunk_index": index,
193
+ "total_chunks": total_chunks,
194
+ "body": chunk_body,
195
+ "binary": binary,
196
+ }
197
+ if index == 0:
198
+ frame["status"] = result["status"]
199
+ frame["headers"] = result["headers"]
200
+ with SEND_LOCK:
201
+ ws.send(json.dumps(frame))
202
+
203
+
204
+ def process_request(ws, local_port, data):
205
+ request_id = data.get("request_id")
206
+ try:
207
+ result = handle_request(local_port, data)
208
+ send_response(ws, result)
209
+ except websocket.WebSocketConnectionClosedException:
210
+ logger.warning("WebSocket closed while sending response for %s.", request_id)
211
+ except BrokenPipeError:
212
+ logger.warning("Broken pipe while sending response for %s.", request_id)
213
+ except Exception as e:
214
+ logger.error("Error processing request %s: %r", request_id, e)
215
+
216
+
217
+ def cleanup_futures(futures):
218
+ completed = {f for f in futures if f.done()}
219
+ for future in completed:
220
+ try:
221
+ future.result()
222
+ except Exception:
223
+ logger.exception("Worker raised an exception.")
224
+ futures.difference_update(completed)
225
+
226
+
227
+ def run(
228
+ ws_url,
229
+ local_port,
230
+ workers=DEFAULT_WORKERS,
231
+ should_shutdown=lambda: False,
232
+ ):
233
+ assert isinstance(workers, int), f"workers must be int, got {type(workers)}"
234
+
235
+ logger.info("Forwarding -> http://127.0.0.1:%s", local_port)
236
+ logger.info("WebSocket -> %s", ws_url)
237
+
238
+ executor = ThreadPoolExecutor(
239
+ max_workers=workers,
240
+ thread_name_prefix="worker",
241
+ )
242
+ futures = set()
243
+
244
+ def on_message(ws_app, message):
245
+ logger.info("=" * 60)
246
+ logger.info("RAW MESSAGE FROM SERVER")
247
+ logger.info(message)
248
+ logger.info("=" * 60)
249
+ logger.info("Received WebSocket message:")
250
+ logger.info(message)
251
+ cleanup_futures(futures)
252
+ if not message or should_shutdown():
253
+ ws_app.close()
254
+ return
255
+ try:
256
+ data = json.loads(message)
257
+ except json.JSONDecodeError:
258
+ logger.warning("Received invalid JSON.")
259
+ return
260
+ # if "request_id" not in data:
261
+ # return
262
+ # future = executor.submit(process_request, ws_app, local_port, data)
263
+
264
+ #
265
+ # Only process HTTP forwarding messages.
266
+ #\
267
+ # =============================================================
268
+ logger.info(data.get("type"))
269
+ if data.get("type") != "http.request":
270
+ # if data.get("type") != "request":
271
+ return
272
+ # =============================================================
273
+ if "request_id" not in data:
274
+ return
275
+
276
+ future = executor.submit(
277
+ process_request,
278
+ ws_app,
279
+ local_port,
280
+ data,
281
+ )
282
+ futures.add(future)
283
+
284
+ def on_error(ws_app, error):
285
+ if isinstance(error, (KeyboardInterrupt, SystemExit)):
286
+ ws_app.close()
287
+ return
288
+ logger.error("Tunnel error: %r", error)
289
+
290
+ def on_close(ws_app, close_status_code, close_msg):
291
+ logger.warning("Tunnel closed: %s - %s", close_status_code, close_msg)
292
+
293
+ def on_open(ws_app):
294
+ logger.info("Tunnel connected.")
295
+
296
+ ws_app = websocket.WebSocketApp(
297
+ ws_url,
298
+ on_open=on_open,
299
+ on_message=on_message,
300
+ on_error=on_error,
301
+ on_close=on_close,
302
+ )
303
+
304
+ # ------------------------------------------------------------------ #
305
+ # Run run_forever in a daemon thread so the main thread stays free. #
306
+ # The signal handler calls ws_app.close() which stops the thread. #
307
+ # We then join with a timeout so we never block forever. #
308
+ # ------------------------------------------------------------------ #
309
+ ws_thread = threading.Thread(
310
+ target=ws_app.run_forever,
311
+ kwargs={"ping_interval": 15, "ping_timeout": 5},
312
+ daemon=True, # dies automatically if the process exits
313
+ name="ws-run-forever",
314
+ )
315
+ ws_thread.start()
316
+ logger.info("Tunnel Connected Natively")
317
+
318
+ # Block the caller here, but interruptibly — check the shutdown
319
+ # flag every 0.2 s instead of sleeping inside run_forever.
320
+ try:
321
+ while ws_thread.is_alive():
322
+ if should_shutdown():
323
+ logger.info("Shutdown flag set — closing WebSocket.")
324
+ ws_app.close()
325
+ break
326
+ ws_thread.join(timeout=0.2)
327
+ finally:
328
+ # Give the WS thread up to 3 s to finish its close handshake.
329
+ ws_thread.join(timeout=3)
330
+
331
+ logger.info("Waiting for %d active workers...", len(futures))
332
+ for future in list(futures):
333
+ try:
334
+ future.result(timeout=2)
335
+ except Exception:
336
+ pass
337
+ executor.shutdown(wait=False)
338
+ logger.info("Tunnel closed.")
@@ -0,0 +1,231 @@
1
+ Metadata-Version: 2.4
2
+ Name: borecli
3
+ Version: 1.0.0
4
+ Summary: Official BoreHook CLI for securely exposing localhost through BoreHook.
5
+ Author-email: Chinonso Egwuaka <chinonso_patrick@yahoo.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://borehook.com
8
+ Project-URL: Documentation, https://borehook.com/docs
9
+ Project-URL: Repository, https://github.com/CHI-NONSO1/borecli
10
+ Project-URL: Issues, https://github.com/CHI-NONSO1/borecli/issues
11
+ Keywords: borehook,tunnel,localhost,webhook,cli,proxy
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Internet
21
+ Classifier: Topic :: Software Development
22
+ Classifier: Environment :: Console
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: click>=8.1.7
27
+ Requires-Dist: requests>=2.32.0
28
+ Requires-Dist: httpx>=0.28.0
29
+ Requires-Dist: websockets>=15.0
30
+ Dynamic: license-file
31
+
32
+ # BoreHook CLI
33
+
34
+ The official command-line interface for **BoreHook** — securely expose your localhost to the internet, test webhooks, and debug HTTP requests without deploying your application.
35
+
36
+ ## Features
37
+
38
+ * 🚀 Expose localhost over HTTPS
39
+ * 🔐 Secure authenticated tunnels
40
+ * 🔄 Persistent tunnel management
41
+ * 📡 WebSocket-powered request forwarding
42
+ * ⏱️ Configurable login session duration
43
+ * 💻 Cross-platform (Windows, macOS, Linux)
44
+
45
+ ---
46
+
47
+ ## Installation
48
+
49
+ ### Python (PyPI)
50
+
51
+ ```bash
52
+ pip install borecli
53
+ ```
54
+
55
+ ### Node.js (npm)
56
+
57
+ ```bash
58
+ npm install -g borecli
59
+ ```
60
+
61
+ ---
62
+
63
+ ## Login
64
+
65
+ Authenticate your BoreHook account.
66
+
67
+ ```bash
68
+ bore login
69
+ ```
70
+
71
+ Specify a custom session duration:
72
+
73
+ ```bash
74
+ bore login --time 30m
75
+ ```
76
+
77
+ ```bash
78
+ bore login --time 8h
79
+ ```
80
+
81
+ ```bash
82
+ bore login --time 7d
83
+ ```
84
+
85
+ If no duration is specified, the default session length is **1 hour**.
86
+
87
+ Supported time units:
88
+
89
+ | Example | Meaning |
90
+ | ------- | ---------- |
91
+ | `30m` | 30 Minutes |
92
+ | `2h` | 2 Hours |
93
+ | `7d` | 7 Days |
94
+
95
+ ---
96
+
97
+ ## Check Current User
98
+
99
+ ```bash
100
+ bore whoami
101
+ ```
102
+
103
+ Example:
104
+
105
+ ```text
106
+ Logged in as: john@example.com
107
+ ```
108
+
109
+ ---
110
+
111
+ ## Logout
112
+
113
+ ```bash
114
+ bore logout
115
+ ```
116
+
117
+ ---
118
+
119
+ ## Connect a Tunnel
120
+
121
+ Start forwarding traffic to your local application.
122
+
123
+ ```bash
124
+ bore connect
125
+ ```
126
+
127
+ You'll be prompted to select one of your configured tunnels.
128
+
129
+ Example:
130
+
131
+ ```text
132
+ Available Tunnels
133
+
134
+ [1] my-api
135
+ [2] webhook
136
+ [3] development
137
+
138
+ Select tunnel:
139
+ ```
140
+
141
+ After connecting:
142
+
143
+ ```text
144
+ 🚀 Tunnel Connected
145
+
146
+ Tunnel ID: 99c2c16b-e78b-4790-bab8-93865118f91b
147
+ Subdomain: webhook
148
+ Public URL: https://webhook.borehook.com
149
+
150
+ Forwarding → http://127.0.0.1:8000
151
+
152
+ Press Ctrl+C to disconnect.
153
+ ```
154
+
155
+ ---
156
+
157
+ ## Commands
158
+
159
+ | Command | Description |
160
+ | -------------- | ------------------------------------- |
161
+ | `bore login` | Authenticate your account |
162
+ | `bore connect` | Connect to a tunnel |
163
+ | `bore whoami` | Display the current logged-in account |
164
+ | `bore logout` | Remove the local session |
165
+
166
+ ---
167
+
168
+ ## Examples
169
+
170
+ Login for one hour (default):
171
+
172
+ ```bash
173
+ bore login
174
+ ```
175
+
176
+ Login for 15 minutes:
177
+
178
+ ```bash
179
+ bore login --time 15m
180
+ ```
181
+
182
+ Login for 8 hours:
183
+
184
+ ```bash
185
+ bore login --time 8h
186
+ ```
187
+
188
+ Connect to a tunnel:
189
+
190
+ ```bash
191
+ bore connect
192
+ ```
193
+
194
+ Logout:
195
+
196
+ ```bash
197
+ bore logout
198
+ ```
199
+
200
+ ---
201
+
202
+ ## Requirements
203
+
204
+ * Windows
205
+ * macOS
206
+ * Linux
207
+
208
+ Internet connection required.
209
+
210
+ ---
211
+
212
+ ## Documentation
213
+
214
+ Visit **https://borehook.com** for documentation, tutorials, and guides.
215
+
216
+ ---
217
+
218
+ ## Support
219
+
220
+ * Website: https://borehook.com
221
+ * Email: [support@borehook.com](mailto:support@borehook.com)
222
+
223
+ ---
224
+
225
+ ## License
226
+
227
+ MIT License.
228
+
229
+ ---
230
+
231
+ Built with ❤️ by the BoreHook team.