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/__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()))
|