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/__init__.py +0 -0
- bore/auth.py +104 -0
- bore/cli.py +378 -0
- bore/config.py +132 -0
- bore/frames.py +376 -0
- bore/handlers/__init__.py +0 -0
- bore/handlers/error_pages.py +223 -0
- bore/handlers/http_handler.py +694 -0
- bore/handlers/websocket_handler.py +975 -0
- bore/parse_duration.py +32 -0
- bore/protocol.py +72 -0
- bore/tunnel/__init__.py +0 -0
- bore/tunnel/client.py +311 -0
- bore/tunnel/dispatcher.py +277 -0
- bore/tunnel/heartbeat.py +154 -0
- bore/tunnel.py +43 -0
- bore/websocket/__init__.py +0 -0
- bore/websocket/connection.py +108 -0
- bore/websocket/frames.py +162 -0
- bore/websocket/manager.py +174 -0
- bore/websocket/proxy.py +147 -0
- bore/websocket_client.py +338 -0
- borecli-1.0.0.dist-info/METADATA +231 -0
- borecli-1.0.0.dist-info/RECORD +28 -0
- borecli-1.0.0.dist-info/WHEEL +5 -0
- borecli-1.0.0.dist-info/entry_points.txt +2 -0
- borecli-1.0.0.dist-info/licenses/LICENSE +0 -0
- borecli-1.0.0.dist-info/top_level.txt +1 -0
bore/websocket/proxy.py
ADDED
|
@@ -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
|
bore/websocket_client.py
ADDED
|
@@ -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.
|