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 ADDED
File without changes
bore/auth.py ADDED
@@ -0,0 +1,104 @@
1
+ #borecli/bore/auth.py
2
+ import requests
3
+
4
+ from .config import (DEFAULT_TOKEN_LIFETIME, clear_credentials, save_credentials,)
5
+
6
+ API_URL = "https://api.borehook.com"
7
+
8
+
9
+
10
+ def login(
11
+ email,
12
+ password,
13
+ lifetime=DEFAULT_TOKEN_LIFETIME,
14
+ ):
15
+ """
16
+ Authenticate a user and save the session locally.
17
+ """
18
+
19
+ response = requests.post(
20
+ f"{API_URL}/api/accounts/login/",
21
+ json={
22
+ "email": email,
23
+ "password": password,
24
+ },
25
+ timeout=30,
26
+ )
27
+
28
+ response.raise_for_status()
29
+
30
+ data = response.json()
31
+
32
+ token = data.get("token")
33
+
34
+ if not token:
35
+ raise Exception(
36
+ "Login succeeded but no token was returned."
37
+ )
38
+
39
+ save_credentials(
40
+ email=email,
41
+ token=token,
42
+ lifetime=lifetime,
43
+ )
44
+
45
+ return {
46
+ "email": email,
47
+ "token": token,
48
+ "lifetime": lifetime,
49
+ }
50
+
51
+
52
+ def verify_token(token):
53
+ """
54
+ Verify token validity against the API.
55
+
56
+ Returns:
57
+ True if valid.
58
+ False if invalid.
59
+ """
60
+
61
+ try:
62
+ response = requests.get(
63
+ f"{API_URL}/api/accounts/me/",
64
+ headers={
65
+ "Authorization": (
66
+ f"Token {token}"
67
+ )
68
+ },
69
+ timeout=30,
70
+ )
71
+
72
+ return response.status_code == 200
73
+
74
+ except Exception:
75
+ return False
76
+
77
+
78
+ def logout():
79
+ """
80
+ Remove locally stored credentials.
81
+ """
82
+
83
+ clear_credentials()
84
+
85
+
86
+ def refresh_session(email, token):
87
+ """
88
+ Refresh the saved session timestamp.
89
+ """
90
+
91
+ save_credentials(
92
+ email=email,
93
+ token=token,
94
+ )
95
+
96
+
97
+ def get_authenticated_headers(token):
98
+ """
99
+ Return authorization headers for API requests.
100
+ """
101
+
102
+ return {
103
+ "Authorization": f"Token {token}",
104
+ }
bore/cli.py ADDED
@@ -0,0 +1,378 @@
1
+
2
+ # borecli/bore/cli.py
3
+
4
+ import asyncio
5
+ import signal
6
+ import threading
7
+ import traceback
8
+
9
+ import click
10
+ import requests
11
+
12
+ from bore.config import (
13
+ DEFAULT_TOKEN_LIFETIME,
14
+ get_email,
15
+ get_token,
16
+ is_authenticated,
17
+ get_remaining_session_time,
18
+ )
19
+ from bore.parse_duration import parse_duration
20
+
21
+ from bore.auth import (
22
+ login,
23
+ logout,
24
+ )
25
+
26
+ from bore.tunnel.client import TunnelClient
27
+ API_URL = "https://api.borehook.com"
28
+
29
+ shutdown_requested = False
30
+ _stop_event = threading.Event()
31
+
32
+ #
33
+
34
+ def request_shutdown(signum=None, frame=None):
35
+ global shutdown_requested
36
+ if shutdown_requested:
37
+ return
38
+ shutdown_requested = True
39
+ click.echo("\n\n๐Ÿ›‘ Disconnecting...")
40
+
41
+ try:
42
+ loop = asyncio.get_running_loop()
43
+ current = asyncio.current_task(loop)
44
+ for task in asyncio.all_tasks(loop):
45
+ if task is not current:
46
+ task.cancel()
47
+ except RuntimeError:
48
+ pass # no loop running yet
49
+
50
+
51
+
52
+ @click.group()
53
+ def cli():
54
+ pass
55
+
56
+
57
+
58
+ @cli.command(name="login")
59
+ @click.option("--email", prompt=True)
60
+ @click.option(
61
+ "--password",
62
+ prompt=True,
63
+ hide_input=True,
64
+ )
65
+ @click.option(
66
+ "--time",
67
+ default=None,
68
+ help="Session duration (e.g. 30m, 2h, 1d). Defaults to 1h.",
69
+ )
70
+ def login_cmd(email, password, time):
71
+
72
+ if is_authenticated():
73
+ click.echo(
74
+ f"Already logged in as {get_email()}"
75
+ )
76
+ return
77
+
78
+ try:
79
+
80
+ if time:
81
+ lifetime = parse_duration(time)
82
+ else:
83
+ lifetime = DEFAULT_TOKEN_LIFETIME
84
+
85
+ session = login(
86
+ email=email,
87
+ password=password,
88
+ lifetime=lifetime,
89
+ )
90
+
91
+ click.echo("\nโœ… Login successful")
92
+ click.echo(f"Account: {session['email']}")
93
+
94
+ except ValueError as exc:
95
+ click.echo(f"\nโŒ Invalid time: {exc}")
96
+
97
+ except Exception as exc:
98
+ click.echo(f"\nโŒ {exc}")
99
+
100
+
101
+
102
+ @cli.command()
103
+ def whoami():
104
+
105
+ email = get_email()
106
+
107
+
108
+ if not email:
109
+
110
+ click.echo(
111
+ "Not logged in."
112
+ )
113
+
114
+ return
115
+
116
+
117
+ click.echo(
118
+ f"Logged in as: {email}"
119
+ )
120
+
121
+
122
+
123
+ @cli.command("logout")
124
+ def logout_command():
125
+
126
+ """
127
+ Log out of BoreHook.
128
+ """
129
+
130
+ logout()
131
+
132
+
133
+ click.secho(
134
+ "โœ… Successfully logged out.",
135
+ fg="green",
136
+ )
137
+
138
+
139
+
140
+ @cli.command()
141
+ def connect():
142
+
143
+ """
144
+ Connect local application to BoreHook tunnel.
145
+ """
146
+
147
+ global shutdown_requested
148
+
149
+
150
+ shutdown_requested = False
151
+
152
+ _stop_event.clear()
153
+
154
+
155
+
156
+ signal.signal(
157
+ signal.SIGINT,
158
+ request_shutdown,
159
+ )
160
+
161
+
162
+ try:
163
+
164
+ signal.signal(
165
+ signal.SIGTERM,
166
+ request_shutdown,
167
+ )
168
+
169
+ except Exception:
170
+
171
+ pass
172
+
173
+ token = get_token()
174
+
175
+
176
+ if not token:
177
+
178
+ click.echo(
179
+ "Not logged in. Run: bore login"
180
+ )
181
+
182
+ return
183
+
184
+ remaining = get_remaining_session_time()
185
+
186
+ if remaining <= 0:
187
+ click.echo("Session expired. Please login again.")
188
+ logout()
189
+ return
190
+
191
+ try:
192
+ #
193
+ # Get available tunnels
194
+ #
195
+ response = requests.get(
196
+ f"{API_URL}/api/tunnels/",
197
+ headers={
198
+ "Authorization":
199
+ f"Token {token}"
200
+ },
201
+ timeout=30,
202
+ )
203
+ response.raise_for_status()
204
+ tunnels = response.json()
205
+
206
+ if not tunnels:
207
+
208
+ click.echo(
209
+ "No tunnels found."
210
+ )
211
+
212
+ return
213
+ click.echo(
214
+ "\nAvailable Tunnels\n"
215
+ )
216
+ for index, tunnel in enumerate(
217
+ tunnels,
218
+ start=1,
219
+ ):
220
+
221
+ click.echo(
222
+ f"[{index}] {tunnel['subdomain']}"
223
+ )
224
+
225
+ choice = click.prompt(
226
+ "\nSelect tunnel",
227
+ type=int,
228
+ )
229
+ if not (
230
+ 1 <= choice <= len(tunnels)
231
+ ):
232
+
233
+ click.echo(
234
+ "Invalid selection."
235
+ )
236
+
237
+ return
238
+
239
+ tunnel_id = tunnels[
240
+ choice - 1
241
+ ]["id"]
242
+
243
+
244
+
245
+ #
246
+ # Connect tunnel
247
+ #
248
+
249
+ response = requests.post(
250
+ f"{API_URL}/api/tunnels/connect/",
251
+ headers={
252
+ "Authorization":
253
+ f"Token {token}"
254
+ },
255
+ json={
256
+ "tunnel_id": tunnel_id,
257
+ "environment": "prod",
258
+ },
259
+ timeout=30,
260
+ )
261
+
262
+
263
+ response.raise_for_status()
264
+
265
+ data = response.json()
266
+ tunnel = data["tunnel"]
267
+
268
+ local_port = tunnel.get(
269
+ "local_port"
270
+ )
271
+ if not local_port:
272
+ click.echo("Tunnel local_port is not configured.")
273
+ return
274
+
275
+ websocket_url = (
276
+ f"{data['websocket_url']}"
277
+ f"?token={token}"
278
+ )
279
+
280
+ click.echo(
281
+ "\n๐Ÿš€ Tunnel Connected"
282
+ )
283
+
284
+ click.echo(
285
+ f"Tunnel ID: {tunnel['id']}"
286
+ )
287
+
288
+ click.echo(
289
+ f"Subdomain: {tunnel['subdomain']}"
290
+ )
291
+
292
+
293
+ click.echo(
294
+ f"Public URL: {data['public_url']}"
295
+ )
296
+
297
+
298
+ click.echo(f"Forwarding โ†’ http://127.0.0.1:{local_port}")
299
+
300
+ click.echo("\nPress Ctrl+C to disconnect.")
301
+
302
+
303
+ async def expire_session():
304
+ await asyncio.sleep(remaining)
305
+ click.echo("\n\nโฐ Session expired.")
306
+ request_shutdown()
307
+
308
+
309
+ async def run_client():
310
+ client = TunnelClient(
311
+ ws_url=websocket_url,
312
+ local_port=local_port,
313
+ tunnel_id=tunnel["id"],
314
+ )
315
+
316
+ client_task = asyncio.create_task(
317
+ client.start(should_shutdown=lambda: shutdown_requested)
318
+ )
319
+ expire_task = asyncio.create_task(expire_session())
320
+
321
+ try:
322
+ done, pending = await asyncio.wait(
323
+ {client_task, expire_task},
324
+ return_when=asyncio.FIRST_COMPLETED,
325
+ )
326
+ except asyncio.CancelledError:
327
+ pending = {client_task, expire_task}
328
+
329
+ for task in pending:
330
+ task.cancel()
331
+ try:
332
+ await task
333
+ except asyncio.CancelledError:
334
+ pass
335
+ except Exception:
336
+ pass
337
+
338
+
339
+
340
+
341
+ asyncio.run(
342
+ run_client()
343
+ )
344
+
345
+
346
+
347
+ except requests.HTTPError as exc:
348
+
349
+ click.echo(
350
+ f"\nโŒ HTTP Error: {exc}"
351
+ )
352
+
353
+
354
+ except KeyboardInterrupt:
355
+
356
+ click.echo(
357
+ "\n๐Ÿ›‘ Stopped."
358
+ )
359
+
360
+
361
+ except Exception:
362
+ traceback.print_exc()
363
+ # click.echo(
364
+ # f"\nโŒ {exc}"
365
+ # )
366
+
367
+ finally:
368
+
369
+ click.echo(
370
+ "\n๐Ÿงน Cleanup complete."
371
+ )
372
+
373
+ click.echo(
374
+ "๐Ÿ‘‹ Bore client stopped."
375
+ )
376
+
377
+ if __name__ == "__main__":
378
+ cli()
bore/config.py ADDED
@@ -0,0 +1,132 @@
1
+ # borecli/bore/config.py
2
+ import json
3
+ import time
4
+ from pathlib import Path
5
+
6
+ CONFIG_DIR = Path.home() / ".bore"
7
+ CONFIG_FILE = CONFIG_DIR / "config.json"
8
+
9
+ # Local session lifetime (1 hour)
10
+ DEFAULT_TOKEN_LIFETIME = 3600 # 1 hour
11
+
12
+
13
+ def save_credentials(email, token, lifetime=DEFAULT_TOKEN_LIFETIME):
14
+ """
15
+ Save user credentials locally.
16
+
17
+ lifetime is in seconds.
18
+ """
19
+
20
+ CONFIG_DIR.mkdir(
21
+ parents=True,
22
+ exist_ok=True,
23
+ )
24
+
25
+ now = time.time()
26
+
27
+ with open(
28
+ CONFIG_FILE,
29
+ "w",
30
+ encoding="utf-8",
31
+ ) as file:
32
+ json.dump(
33
+ {
34
+ "email": email,
35
+ "token": token,
36
+ "saved_at": now,
37
+ "expires_at": now + lifetime,
38
+ },
39
+ file,
40
+ indent=4,
41
+ )
42
+
43
+
44
+ def load_credentials():
45
+ """
46
+ Load credentials if they have not expired.
47
+ """
48
+
49
+ if not CONFIG_FILE.exists():
50
+ return None
51
+
52
+ try:
53
+ with open(CONFIG_FILE, "r", encoding="utf-8") as file:
54
+ data = json.load(file)
55
+
56
+ expires_at = data.get("expires_at")
57
+
58
+ # Backward compatibility
59
+ if expires_at is None:
60
+ saved_at = data.get("saved_at", 0)
61
+ expires_at = saved_at + DEFAULT_TOKEN_LIFETIME
62
+
63
+ if time.time() >= expires_at:
64
+ clear_credentials()
65
+ return None
66
+
67
+ return data
68
+
69
+ except Exception:
70
+ return None
71
+
72
+
73
+ def clear_credentials():
74
+ """
75
+ Remove stored credentials.
76
+ """
77
+
78
+ try:
79
+ if CONFIG_FILE.exists():
80
+ CONFIG_FILE.unlink()
81
+
82
+ except Exception:
83
+ pass
84
+
85
+
86
+ def get_token():
87
+ """
88
+ Return saved token.
89
+ """
90
+
91
+ credentials = load_credentials()
92
+
93
+ if not credentials:
94
+ return None
95
+
96
+ return credentials.get("token")
97
+
98
+
99
+ def get_email():
100
+ """
101
+ Return saved email.
102
+ """
103
+
104
+ credentials = load_credentials()
105
+
106
+ if not credentials:
107
+ return None
108
+
109
+ return credentials.get("email")
110
+
111
+
112
+ def is_authenticated():
113
+ """
114
+ Check whether a valid session exists.
115
+ """
116
+
117
+ return load_credentials() is not None
118
+
119
+
120
+ def get_remaining_session_time():
121
+ """
122
+ Return remaining session lifetime in seconds.
123
+ Returns 0 if expired.
124
+ """
125
+ credentials = load_credentials()
126
+
127
+ if not credentials:
128
+ return 0
129
+
130
+ expires_at = credentials["expires_at"]
131
+
132
+ return max(0, int(expires_at - time.time()))