ccoding 0.2.1__py3-none-any.whl → 0.2.3__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.
- ccode/config.py +28 -8
- ccode/remote.py +55 -43
- ccode/remote_cli.py +5 -3
- ccode/remote_client.py +7 -7
- ccode/remote_common.py +89 -32
- ccode/remote_server.py +64 -27
- ccode/ui.py +35 -23
- ccode/web/app.js +275 -40
- ccode/web/index.html +23 -5
- ccode/web/style.css +102 -0
- {ccoding-0.2.1.dist-info → ccoding-0.2.3.dist-info}/METADATA +45 -23
- ccoding-0.2.3.dist-info/RECORD +27 -0
- ccoding-0.2.1.dist-info/RECORD +0 -27
- {ccoding-0.2.1.dist-info → ccoding-0.2.3.dist-info}/WHEEL +0 -0
- {ccoding-0.2.1.dist-info → ccoding-0.2.3.dist-info}/entry_points.txt +0 -0
- {ccoding-0.2.1.dist-info → ccoding-0.2.3.dist-info}/licenses/LICENSE +0 -0
ccode/config.py
CHANGED
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import copy
|
|
4
4
|
import json
|
|
5
|
+
import secrets
|
|
5
6
|
import socket
|
|
6
7
|
import uuid
|
|
7
8
|
from pathlib import Path
|
|
@@ -32,11 +33,13 @@ DEFAULT_REMOTE: dict[str, Any] = {
|
|
|
32
33
|
"local": {
|
|
33
34
|
"host": "127.0.0.1",
|
|
34
35
|
"port": 8765,
|
|
35
|
-
"
|
|
36
|
+
"appId": "",
|
|
37
|
+
"appKey": "",
|
|
36
38
|
},
|
|
37
39
|
"server": {
|
|
38
40
|
"url": "",
|
|
39
|
-
"
|
|
41
|
+
"appId": "",
|
|
42
|
+
"appKey": "",
|
|
40
43
|
"device_name": "",
|
|
41
44
|
"device_id": "",
|
|
42
45
|
"auto_connect": True,
|
|
@@ -103,6 +106,10 @@ def _new_device_id() -> str:
|
|
|
103
106
|
return f"ccode-{uuid.uuid4().hex}"
|
|
104
107
|
|
|
105
108
|
|
|
109
|
+
def _new_app_id() -> str:
|
|
110
|
+
return f"app_{secrets.token_urlsafe(9)}"
|
|
111
|
+
|
|
112
|
+
|
|
106
113
|
def normalize_remote(remote: Any) -> dict[str, Any]:
|
|
107
114
|
normalized = copy.deepcopy(DEFAULT_REMOTE)
|
|
108
115
|
if not isinstance(remote, dict):
|
|
@@ -132,18 +139,31 @@ def normalize_remote(remote: Any) -> dict[str, Any]:
|
|
|
132
139
|
port = remote.get("port")
|
|
133
140
|
if isinstance(port, int):
|
|
134
141
|
normalized["local"]["port"] = port
|
|
135
|
-
|
|
136
|
-
if
|
|
137
|
-
|
|
138
|
-
if isinstance(
|
|
139
|
-
|
|
142
|
+
local_app_id = local.get("appId") if isinstance(local, dict) else None
|
|
143
|
+
local_app_key = local.get("appKey") if isinstance(local, dict) else None
|
|
144
|
+
local_token = local.get("token") if isinstance(local, dict) else None
|
|
145
|
+
if not isinstance(local_token, str):
|
|
146
|
+
local_token = remote.get("token")
|
|
147
|
+
if isinstance(local_app_id, str):
|
|
148
|
+
normalized["local"]["appId"] = local_app_id
|
|
149
|
+
elif isinstance(local_token, str) and local_token:
|
|
150
|
+
normalized["local"]["appId"] = _new_app_id()
|
|
151
|
+
if isinstance(local_app_key, str):
|
|
152
|
+
normalized["local"]["appKey"] = local_app_key
|
|
153
|
+
elif isinstance(local_token, str):
|
|
154
|
+
normalized["local"]["appKey"] = local_token
|
|
140
155
|
|
|
141
156
|
server = remote.get("server") if isinstance(remote.get("server"), dict) else {}
|
|
142
157
|
if isinstance(server, dict):
|
|
143
|
-
|
|
158
|
+
server_token = server.get("token")
|
|
159
|
+
for key in ("url", "appId", "appKey", "device_name", "device_id"):
|
|
144
160
|
value = server.get(key)
|
|
145
161
|
if isinstance(value, str):
|
|
146
162
|
normalized["server"][key] = value
|
|
163
|
+
if not normalized["server"].get("appId") and isinstance(server_token, str) and server_token:
|
|
164
|
+
normalized["server"]["appId"] = _new_app_id()
|
|
165
|
+
if not normalized["server"].get("appKey") and isinstance(server_token, str):
|
|
166
|
+
normalized["server"]["appKey"] = server_token
|
|
147
167
|
for key in ("auto_connect", "verify_tls"):
|
|
148
168
|
value = server.get(key)
|
|
149
169
|
if isinstance(value, bool):
|
ccode/remote.py
CHANGED
|
@@ -15,20 +15,20 @@ from .remote_common import (
|
|
|
15
15
|
_new_session_id,
|
|
16
16
|
_sanitize_session_name,
|
|
17
17
|
_utc_now,
|
|
18
|
+
AuthResult,
|
|
18
19
|
asset_text,
|
|
19
|
-
auth_token_matches,
|
|
20
20
|
file_lock,
|
|
21
21
|
missing_extra_message,
|
|
22
|
+
remote_local_app_id,
|
|
23
|
+
remote_local_app_key,
|
|
22
24
|
remote_local_host_port,
|
|
23
|
-
remote_local_token,
|
|
24
25
|
remote_local_url,
|
|
25
26
|
remote_mode,
|
|
26
27
|
remote_server_config,
|
|
27
28
|
remote_server_url,
|
|
28
29
|
rotate_log,
|
|
29
|
-
|
|
30
|
+
verify_auth_payload,
|
|
30
31
|
web_asset_text,
|
|
31
|
-
websocket_auth_token,
|
|
32
32
|
)
|
|
33
33
|
|
|
34
34
|
REMOTE_SESSIONS_PATH = CONFIG_DIR / "remote_sessions.json"
|
|
@@ -321,29 +321,33 @@ def ensure_remote_hub(config: dict[str, Any]) -> str | None:
|
|
|
321
321
|
return f"Failed to start remote hub. See {REMOTE_SERVER_LOG_PATH} for details."
|
|
322
322
|
|
|
323
323
|
|
|
324
|
-
|
|
324
|
+
_AUTH_CACHE: tuple[float | None, str, str] = (None, "", "")
|
|
325
325
|
|
|
326
326
|
|
|
327
|
-
def
|
|
328
|
-
global
|
|
327
|
+
def _current_remote_credentials() -> tuple[str, str]:
|
|
328
|
+
global _AUTH_CACHE
|
|
329
329
|
try:
|
|
330
330
|
mtime = CONFIG_PATH.stat().st_mtime
|
|
331
331
|
except FileNotFoundError:
|
|
332
332
|
mtime = None
|
|
333
|
-
cached_mtime,
|
|
333
|
+
cached_mtime, app_id, app_key = _AUTH_CACHE
|
|
334
334
|
if cached_mtime == mtime:
|
|
335
|
-
return
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
335
|
+
return app_id, app_key
|
|
336
|
+
config = load_config()
|
|
337
|
+
app_id = remote_local_app_id(config).strip()
|
|
338
|
+
app_key = remote_local_app_key(config).strip()
|
|
339
|
+
_AUTH_CACHE = (mtime, app_id, app_key)
|
|
340
|
+
return app_id, app_key
|
|
339
341
|
|
|
340
342
|
|
|
341
|
-
def
|
|
342
|
-
|
|
343
|
+
def _valid_auth_payload(payload: dict[str, Any], scope: str) -> AuthResult:
|
|
344
|
+
app_id, app_key = _current_remote_credentials()
|
|
345
|
+
return verify_auth_payload(app_id, app_key, payload, scope)
|
|
343
346
|
|
|
344
347
|
|
|
345
|
-
def
|
|
346
|
-
|
|
348
|
+
def _auth_response(result: AuthResult, response_class: Any) -> Any:
|
|
349
|
+
status_code = 401 if result.error in {"auth_required", "reauth_required"} else 403
|
|
350
|
+
return response_class({"error": result.error or "auth_failed", "message": result.message}, status_code=status_code)
|
|
347
351
|
|
|
348
352
|
|
|
349
353
|
async def _attach_websocket_to_session(websocket: Any, session: dict[str, Any]) -> None:
|
|
@@ -422,16 +426,18 @@ def create_app() -> Any:
|
|
|
422
426
|
|
|
423
427
|
@app.get("/health")
|
|
424
428
|
async def health() -> dict[str, Any]:
|
|
425
|
-
return {"ok": True, "name": REMOTE_HUB_NAME, "multi_session": True}
|
|
429
|
+
return {"ok": True, "name": REMOTE_HUB_NAME, "multi_session": True, "remote_kind": "local"}
|
|
426
430
|
|
|
427
431
|
@app.get("/api/sessions")
|
|
428
432
|
async def sessions(
|
|
429
|
-
|
|
430
|
-
|
|
433
|
+
x_ccode_app_id: str | None = Header(default=None),
|
|
434
|
+
x_ccode_timestamp: str | None = Header(default=None),
|
|
435
|
+
x_ccode_sign: str | None = Header(default=None),
|
|
431
436
|
) -> Response:
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
437
|
+
auth_result = _valid_auth_payload({"appId": x_ccode_app_id, "timestamp": x_ccode_timestamp, "sign": x_ccode_sign}, "local.api")
|
|
438
|
+
if not auth_result.ok:
|
|
439
|
+
_log_event(f"api sessions unauthorized error={auth_result.error}")
|
|
440
|
+
return _auth_response(auth_result, JSONResponse)
|
|
435
441
|
sessions = list_remote_sessions()
|
|
436
442
|
_log_event(f"api sessions returned count={len(sessions)}")
|
|
437
443
|
return JSONResponse({"sessions": sessions})
|
|
@@ -439,12 +445,14 @@ def create_app() -> Any:
|
|
|
439
445
|
@app.get("/api/sessions/{session_id}")
|
|
440
446
|
async def session_detail(
|
|
441
447
|
session_id: str,
|
|
442
|
-
|
|
443
|
-
|
|
448
|
+
x_ccode_app_id: str | None = Header(default=None),
|
|
449
|
+
x_ccode_timestamp: str | None = Header(default=None),
|
|
450
|
+
x_ccode_sign: str | None = Header(default=None),
|
|
444
451
|
) -> Response:
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
452
|
+
auth_result = _valid_auth_payload({"appId": x_ccode_app_id, "timestamp": x_ccode_timestamp, "sign": x_ccode_sign}, "local.api")
|
|
453
|
+
if not auth_result.ok:
|
|
454
|
+
_log_event(f"api session detail unauthorized session_id={session_id} error={auth_result.error}")
|
|
455
|
+
return _auth_response(auth_result, JSONResponse)
|
|
448
456
|
session = get_remote_session(session_id)
|
|
449
457
|
if session is None or not _session_is_running(session):
|
|
450
458
|
_log_event(f"api session detail not_found session_id={session_id}")
|
|
@@ -454,12 +462,14 @@ def create_app() -> Any:
|
|
|
454
462
|
|
|
455
463
|
@app.get("/api/session")
|
|
456
464
|
async def legacy_session(
|
|
457
|
-
|
|
458
|
-
|
|
465
|
+
x_ccode_app_id: str | None = Header(default=None),
|
|
466
|
+
x_ccode_timestamp: str | None = Header(default=None),
|
|
467
|
+
x_ccode_sign: str | None = Header(default=None),
|
|
459
468
|
) -> Response:
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
469
|
+
auth_result = _valid_auth_payload({"appId": x_ccode_app_id, "timestamp": x_ccode_timestamp, "sign": x_ccode_sign}, "local.api")
|
|
470
|
+
if not auth_result.ok:
|
|
471
|
+
_log_event(f"legacy api session unauthorized error={auth_result.error}")
|
|
472
|
+
return _auth_response(auth_result, JSONResponse)
|
|
463
473
|
sessions = list_remote_sessions()
|
|
464
474
|
_log_event(f"legacy api session requested running_count={len(sessions)}")
|
|
465
475
|
if len(sessions) == 1:
|
|
@@ -471,9 +481,10 @@ def create_app() -> Any:
|
|
|
471
481
|
async def websocket_session(websocket: FastAPIWebSocket, session_id: str) -> None:
|
|
472
482
|
await websocket.accept()
|
|
473
483
|
_log_event(f"websocket accepted session_id={session_id}")
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
484
|
+
auth_result = _valid_auth_payload(dict(websocket.query_params), "local.ws")
|
|
485
|
+
if not auth_result.ok:
|
|
486
|
+
_log_event(f"websocket unauthorized session_id={session_id} error={auth_result.error}")
|
|
487
|
+
await websocket.send_json({"type": "error", "error": auth_result.error, "message": auth_result.message})
|
|
477
488
|
await websocket.close(code=1008)
|
|
478
489
|
return
|
|
479
490
|
session = get_remote_session(session_id)
|
|
@@ -488,9 +499,10 @@ def create_app() -> Any:
|
|
|
488
499
|
async def legacy_websocket(websocket: FastAPIWebSocket) -> None:
|
|
489
500
|
await websocket.accept()
|
|
490
501
|
_log_event("legacy websocket accepted")
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
502
|
+
auth_result = _valid_auth_payload(dict(websocket.query_params), "local.ws")
|
|
503
|
+
if not auth_result.ok:
|
|
504
|
+
_log_event(f"legacy websocket unauthorized error={auth_result.error}")
|
|
505
|
+
await websocket.send_json({"type": "error", "error": auth_result.error, "message": auth_result.message})
|
|
494
506
|
await websocket.close(code=1008)
|
|
495
507
|
return
|
|
496
508
|
sessions = list_remote_sessions()
|
|
@@ -530,11 +542,11 @@ def run_remote_hub_forever(config: dict[str, Any]) -> str | None:
|
|
|
530
542
|
|
|
531
543
|
def run_remote_session(config: dict[str, Any], args: list[str]) -> str | None:
|
|
532
544
|
mode = remote_mode(config)
|
|
533
|
-
if mode == "local" and not
|
|
534
|
-
return "Remote
|
|
545
|
+
if mode == "local" and (not remote_local_app_id(config).strip() or not remote_local_app_key(config).strip()):
|
|
546
|
+
return "Remote app ID and app key are required. Set them in the config screen or press g on the APP ID / APP KEY fields to generate them."
|
|
535
547
|
server = remote_server_config(config)
|
|
536
|
-
if mode == "server" and (not remote_server_url(config) or not str(server.get("
|
|
537
|
-
return "Remote server URL and client
|
|
548
|
+
if mode == "server" and (not remote_server_url(config) or not str(server.get("appId") or "").strip() or not str(server.get("appKey") or "").strip()):
|
|
549
|
+
return "Remote server URL, client app ID, and client app key are required in server mode."
|
|
538
550
|
error = _check_runtime()
|
|
539
551
|
if error is not None:
|
|
540
552
|
return error
|
|
@@ -604,7 +616,7 @@ def run_remote_session(config: dict[str, Any], args: list[str]) -> str | None:
|
|
|
604
616
|
print(f" {session_name}\n")
|
|
605
617
|
if host == "0.0.0.0":
|
|
606
618
|
print("Security warning:")
|
|
607
|
-
print(" Listening on 0.0.0.0 exposes all remote sessions to your network. Protect the access URL and
|
|
619
|
+
print(" Listening on 0.0.0.0 exposes all remote sessions to your network. Protect the access URL and app credentials.\n")
|
|
608
620
|
print("This terminal will attach to this tmux session. The web hub stays running independently.")
|
|
609
621
|
print("Detach with Ctrl+B then D. Browser users can choose this session from the session list.\n")
|
|
610
622
|
|
ccode/remote_cli.py
CHANGED
|
@@ -8,7 +8,7 @@ from pathlib import Path
|
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
10
|
from .config import load_config, mask_secret
|
|
11
|
-
from .remote_common import pid_from_path, remote_display_url,
|
|
11
|
+
from .remote_common import pid_from_path, remote_display_url, remote_local_app_id, remote_local_app_key, remote_local_host_port, remote_mode, remote_server_config, remote_server_url
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
def _format_bytes(value: int) -> str:
|
|
@@ -90,12 +90,14 @@ def print_status(kind: str | None = None) -> int:
|
|
|
90
90
|
server = remote_server_config(config)
|
|
91
91
|
print(f" device name: {server.get('device_name') or '<unset>'}")
|
|
92
92
|
print(f" device id: {server.get('device_id') or '<unset>'}")
|
|
93
|
-
print(f" client
|
|
93
|
+
print(f" client app id: {server.get('appId') or '<unset>'}")
|
|
94
|
+
print(f" client app key: {mask_secret(str(server.get('appKey') or ''))}")
|
|
94
95
|
else:
|
|
95
96
|
host, port = remote_local_host_port(config)
|
|
96
97
|
print(f" host: {host}")
|
|
97
98
|
print(f" port: {port}")
|
|
98
|
-
print(f"
|
|
99
|
+
print(f" app id: {remote_local_app_id(config) or '<unset>'}")
|
|
100
|
+
print(f" app key: {mask_secret(remote_local_app_key(config))}")
|
|
99
101
|
print()
|
|
100
102
|
|
|
101
103
|
if effective == "client":
|
ccode/remote_client.py
CHANGED
|
@@ -10,7 +10,7 @@ import time
|
|
|
10
10
|
from typing import Any
|
|
11
11
|
|
|
12
12
|
from .config import CONFIG_DIR, load_config
|
|
13
|
-
from .remote_common import file_lock, http_to_ws_url, missing_extra_message, pid_from_path, remote_server_config, remote_server_url, rotate_log
|
|
13
|
+
from .remote_common import build_auth_payload, file_lock, http_to_ws_url, missing_extra_message, pid_from_path, remote_server_app_id, remote_server_app_key, remote_server_config, remote_server_url, rotate_log
|
|
14
14
|
|
|
15
15
|
REMOTE_CLIENT_LOCK_PATH = CONFIG_DIR / "remote_client.lock"
|
|
16
16
|
REMOTE_CLIENT_PID_PATH = CONFIG_DIR / "remote_client.pid"
|
|
@@ -179,9 +179,9 @@ async def open_attach_socket(attach_id: str, session_id: str, config: dict[str,
|
|
|
179
179
|
except ImportError:
|
|
180
180
|
_log_event(missing_extra_message("remote-client", "websockets"))
|
|
181
181
|
return
|
|
182
|
-
|
|
182
|
+
auth_payload = build_auth_payload(remote_server_app_id(config), remote_server_app_key(config), "client.attach")
|
|
183
183
|
async with websockets.connect(_attach_url(config, attach_id), **_connect_kwargs(config)) as ws:
|
|
184
|
-
await ws.send(json.dumps({"type": "auth",
|
|
184
|
+
await ws.send(json.dumps({"type": "auth", **auth_payload, "attach_id": attach_id}))
|
|
185
185
|
await attach_local_session_to_websocket(ws, session_id)
|
|
186
186
|
|
|
187
187
|
|
|
@@ -217,10 +217,11 @@ async def connect_control_loop(config: dict[str, Any]) -> None:
|
|
|
217
217
|
from .remote import scan_remote_sessions
|
|
218
218
|
|
|
219
219
|
running, _dead = scan_remote_sessions(prune=True)
|
|
220
|
+
auth_payload = build_auth_payload(remote_server_app_id(config), remote_server_app_key(config), "client.control")
|
|
220
221
|
async with websockets.connect(_control_url(config), **_connect_kwargs(config)) as ws:
|
|
221
222
|
await ws.send(json.dumps({
|
|
222
223
|
"type": "hello",
|
|
223
|
-
|
|
224
|
+
**auth_payload,
|
|
224
225
|
"device_id": server.get("device_id") or "",
|
|
225
226
|
"device_name": server.get("device_name") or "",
|
|
226
227
|
"version": "0.2.0",
|
|
@@ -246,9 +247,8 @@ async def _run_loop(config: dict[str, Any]) -> None:
|
|
|
246
247
|
def run_remote_client_forever(config: dict[str, Any]) -> str | None:
|
|
247
248
|
if not remote_server_url(config):
|
|
248
249
|
return "Remote server URL is required."
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
return "Remote server client token is required."
|
|
250
|
+
if not remote_server_app_id(config).strip() or not remote_server_app_key(config).strip():
|
|
251
|
+
return "Remote server client app ID and app key are required."
|
|
252
252
|
if remote_server_url(config).startswith("http://"):
|
|
253
253
|
_log_event("warning: remote server URL uses plain HTTP/WS")
|
|
254
254
|
try:
|
ccode/remote_common.py
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import base64
|
|
4
4
|
import contextlib
|
|
5
5
|
import datetime as dt
|
|
6
6
|
import fcntl
|
|
7
|
-
import
|
|
7
|
+
import hashlib
|
|
8
|
+
import hmac
|
|
8
9
|
import os
|
|
9
10
|
import re
|
|
10
11
|
import secrets
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
11
14
|
from importlib import resources
|
|
12
15
|
from typing import Any, Iterator
|
|
13
|
-
from urllib.parse import
|
|
16
|
+
from urllib.parse import urlencode, urlparse
|
|
14
17
|
|
|
15
18
|
from .config import CONFIG_DIR
|
|
16
19
|
|
|
@@ -42,17 +45,25 @@ def remote_local_host_port(config: dict[str, Any]) -> tuple[str, int]:
|
|
|
42
45
|
return host, port
|
|
43
46
|
|
|
44
47
|
|
|
45
|
-
def
|
|
46
|
-
return str(remote_local_config(config).get("
|
|
48
|
+
def remote_local_app_id(config: dict[str, Any]) -> str:
|
|
49
|
+
return str(remote_local_config(config).get("appId") or "")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def remote_local_app_key(config: dict[str, Any]) -> str:
|
|
53
|
+
return str(remote_local_config(config).get("appKey") or "")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def remote_server_app_id(config: dict[str, Any]) -> str:
|
|
57
|
+
return str(remote_server_config(config).get("appId") or "")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def remote_server_app_key(config: dict[str, Any]) -> str:
|
|
61
|
+
return str(remote_server_config(config).get("appKey") or "")
|
|
47
62
|
|
|
48
63
|
|
|
49
64
|
def remote_local_url(config: dict[str, Any], include_token: bool = False) -> str:
|
|
50
65
|
host, port = remote_local_host_port(config)
|
|
51
|
-
|
|
52
|
-
token = remote_local_token(config)
|
|
53
|
-
if include_token and token:
|
|
54
|
-
return f"{url}?token={quote(token)}"
|
|
55
|
-
return url
|
|
66
|
+
return f"http://{host}:{port}/"
|
|
56
67
|
|
|
57
68
|
|
|
58
69
|
def remote_server_url(config: dict[str, Any]) -> str:
|
|
@@ -98,35 +109,81 @@ def _utc_now() -> str:
|
|
|
98
109
|
return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
99
110
|
|
|
100
111
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
expected = (expected or "").strip()
|
|
104
|
-
return bool(value) and bool(expected) and secrets.compare_digest(value, expected)
|
|
112
|
+
AUTH_MAX_AGE_SECONDS = 24 * 60 * 60
|
|
113
|
+
AUTH_FUTURE_SKEW_SECONDS = 5 * 60
|
|
105
114
|
|
|
106
115
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class AuthResult:
|
|
118
|
+
ok: bool
|
|
119
|
+
error: str = ""
|
|
120
|
+
message: str = ""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
AUTH_OK = AuthResult(True)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def new_app_id() -> str:
|
|
127
|
+
return f"app_{secrets.token_urlsafe(9)}"
|
|
114
128
|
|
|
115
129
|
|
|
116
|
-
def
|
|
117
|
-
return
|
|
130
|
+
def new_app_key() -> str:
|
|
131
|
+
return secrets.token_urlsafe(32)
|
|
118
132
|
|
|
119
133
|
|
|
120
|
-
|
|
134
|
+
def _auth_message(app_id: str, timestamp: str, scope: str) -> bytes:
|
|
135
|
+
return f"v1\nappId={app_id}\ntimestamp={timestamp}\nscope={scope}".encode()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def make_auth_signature(app_key: str, app_id: str, timestamp: str, scope: str) -> str:
|
|
139
|
+
digest = hmac.new(app_key.encode(), _auth_message(app_id, timestamp, scope), hashlib.sha256).digest()
|
|
140
|
+
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def build_auth_payload(app_id: str, app_key: str, scope: str, timestamp: int | None = None) -> dict[str, str]:
|
|
144
|
+
timestamp_value = str(int(time.time() if timestamp is None else timestamp))
|
|
145
|
+
return {
|
|
146
|
+
"appId": app_id,
|
|
147
|
+
"timestamp": timestamp_value,
|
|
148
|
+
"sign": make_auth_signature(app_key, app_id, timestamp_value, scope),
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def auth_headers(payload: dict[str, str]) -> dict[str, str]:
|
|
153
|
+
return {
|
|
154
|
+
"X-CCODE-App-Id": payload.get("appId", ""),
|
|
155
|
+
"X-CCODE-Timestamp": payload.get("timestamp", ""),
|
|
156
|
+
"X-CCODE-Sign": payload.get("sign", ""),
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def auth_query(payload: dict[str, str]) -> str:
|
|
161
|
+
return urlencode({"appId": payload.get("appId", ""), "timestamp": payload.get("timestamp", ""), "sign": payload.get("sign", "")})
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def verify_auth_payload(expected_app_id: str, expected_app_key: str, payload: dict[str, Any], scope: str) -> AuthResult:
|
|
165
|
+
app_id = str(payload.get("appId") or "").strip()
|
|
166
|
+
timestamp = str(payload.get("timestamp") or "").strip()
|
|
167
|
+
sign = str(payload.get("sign") or "").strip()
|
|
168
|
+
expected_app_id = (expected_app_id or "").strip()
|
|
169
|
+
expected_app_key = (expected_app_key or "").strip()
|
|
170
|
+
if not expected_app_id or not expected_app_key or not app_id or not timestamp or not sign:
|
|
171
|
+
return AuthResult(False, "auth_required", "Authentication required.")
|
|
172
|
+
if not hmac.compare_digest(expected_app_id, app_id):
|
|
173
|
+
return AuthResult(False, "auth_failed", "Authentication failed.")
|
|
121
174
|
try:
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if
|
|
127
|
-
return
|
|
128
|
-
|
|
129
|
-
|
|
175
|
+
timestamp_int = int(timestamp)
|
|
176
|
+
except ValueError:
|
|
177
|
+
return AuthResult(False, "auth_required", "Invalid authentication timestamp.")
|
|
178
|
+
age = int(time.time()) - timestamp_int
|
|
179
|
+
if age > AUTH_MAX_AGE_SECONDS:
|
|
180
|
+
return AuthResult(False, "reauth_required", "Authentication expired, please re-authenticate.")
|
|
181
|
+
if age < -AUTH_FUTURE_SKEW_SECONDS:
|
|
182
|
+
return AuthResult(False, "auth_failed", "Authentication timestamp is invalid.")
|
|
183
|
+
expected_sign = make_auth_signature(expected_app_key, app_id, timestamp, scope)
|
|
184
|
+
if not hmac.compare_digest(expected_sign, sign):
|
|
185
|
+
return AuthResult(False, "auth_failed", "Authentication failed.")
|
|
186
|
+
return AUTH_OK
|
|
130
187
|
|
|
131
188
|
|
|
132
189
|
_ASSET_TEXT_CACHE: dict[str, str] = {}
|
ccode/remote_server.py
CHANGED
|
@@ -8,7 +8,7 @@ import time
|
|
|
8
8
|
from dataclasses import dataclass, field
|
|
9
9
|
from typing import Any
|
|
10
10
|
|
|
11
|
-
from .remote_common import
|
|
11
|
+
from .remote_common import AuthResult, asset_text, missing_extra_message, verify_auth_payload, web_asset_text
|
|
12
12
|
|
|
13
13
|
REMOTE_SERVER_NAME = "ccode-remote-server"
|
|
14
14
|
ATTACH_TTL_SECONDS = 30
|
|
@@ -41,22 +41,33 @@ _PENDING_ATTACHES: dict[str, PendingAttach] = {}
|
|
|
41
41
|
DEVICE_OFFLINE_TTL_SECONDS = 24 * 60 * 60
|
|
42
42
|
|
|
43
43
|
|
|
44
|
-
def
|
|
45
|
-
return os.environ.get("
|
|
44
|
+
def _admin_app_id() -> str:
|
|
45
|
+
return os.environ.get("CCODE_REMOTE_SERVER_ADMIN_APP_ID", "").strip()
|
|
46
46
|
|
|
47
47
|
|
|
48
|
-
def
|
|
49
|
-
return os.environ.get("
|
|
48
|
+
def _admin_app_key() -> str:
|
|
49
|
+
return os.environ.get("CCODE_REMOTE_SERVER_ADMIN_APP_KEY", "").strip()
|
|
50
50
|
|
|
51
51
|
|
|
52
|
-
def
|
|
53
|
-
|
|
54
|
-
return bool(expected) and auth_token_matches(expected, token, authorization)
|
|
52
|
+
def _client_app_id() -> str:
|
|
53
|
+
return os.environ.get("CCODE_REMOTE_SERVER_CLIENT_APP_ID", "").strip()
|
|
55
54
|
|
|
56
55
|
|
|
57
|
-
def
|
|
58
|
-
|
|
59
|
-
|
|
56
|
+
def _client_app_key() -> str:
|
|
57
|
+
return os.environ.get("CCODE_REMOTE_SERVER_CLIENT_APP_KEY", "").strip()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _valid_admin(payload: dict[str, Any], scope: str) -> AuthResult:
|
|
61
|
+
return verify_auth_payload(_admin_app_id(), _admin_app_key(), payload, scope)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _valid_client(payload: dict[str, Any], scope: str) -> AuthResult:
|
|
65
|
+
return verify_auth_payload(_client_app_id(), _client_app_key(), payload, scope)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _auth_response(result: AuthResult, response_class: Any) -> Any:
|
|
69
|
+
status_code = 401 if result.error in {"auth_required", "reauth_required"} else 403
|
|
70
|
+
return response_class({"error": result.error or "auth_failed", "message": result.message}, status_code=status_code)
|
|
60
71
|
|
|
61
72
|
|
|
62
73
|
def _device_payload(device: DeviceConnection) -> dict[str, Any]:
|
|
@@ -168,25 +179,42 @@ def create_server_app() -> Any:
|
|
|
168
179
|
@app.get("/health")
|
|
169
180
|
async def health() -> dict[str, Any]:
|
|
170
181
|
_prune_offline_devices()
|
|
171
|
-
return {"ok": True, "name": REMOTE_SERVER_NAME, "devices": len(_DEVICES)}
|
|
182
|
+
return {"ok": True, "name": REMOTE_SERVER_NAME, "devices": len(_DEVICES), "remote_kind": "server"}
|
|
172
183
|
|
|
173
184
|
@app.get("/api/devices")
|
|
174
|
-
async def devices(
|
|
175
|
-
|
|
176
|
-
|
|
185
|
+
async def devices(
|
|
186
|
+
x_ccode_app_id: str | None = Header(default=None),
|
|
187
|
+
x_ccode_timestamp: str | None = Header(default=None),
|
|
188
|
+
x_ccode_sign: str | None = Header(default=None),
|
|
189
|
+
) -> Response:
|
|
190
|
+
auth_result = _valid_admin({"appId": x_ccode_app_id, "timestamp": x_ccode_timestamp, "sign": x_ccode_sign}, "server.api")
|
|
191
|
+
if not auth_result.ok:
|
|
192
|
+
return _auth_response(auth_result, JSONResponse)
|
|
177
193
|
_prune_offline_devices()
|
|
178
194
|
return JSONResponse({"devices": [_device_payload(device) for device in _DEVICES.values()]})
|
|
179
195
|
|
|
180
196
|
@app.get("/api/sessions")
|
|
181
|
-
async def sessions(
|
|
182
|
-
|
|
183
|
-
|
|
197
|
+
async def sessions(
|
|
198
|
+
x_ccode_app_id: str | None = Header(default=None),
|
|
199
|
+
x_ccode_timestamp: str | None = Header(default=None),
|
|
200
|
+
x_ccode_sign: str | None = Header(default=None),
|
|
201
|
+
) -> Response:
|
|
202
|
+
auth_result = _valid_admin({"appId": x_ccode_app_id, "timestamp": x_ccode_timestamp, "sign": x_ccode_sign}, "server.api")
|
|
203
|
+
if not auth_result.ok:
|
|
204
|
+
return _auth_response(auth_result, JSONResponse)
|
|
184
205
|
return JSONResponse({"sessions": _flatten_sessions()})
|
|
185
206
|
|
|
186
207
|
@app.get("/api/devices/{device_id}/sessions/{session_id}")
|
|
187
|
-
async def session_detail(
|
|
188
|
-
|
|
189
|
-
|
|
208
|
+
async def session_detail(
|
|
209
|
+
device_id: str,
|
|
210
|
+
session_id: str,
|
|
211
|
+
x_ccode_app_id: str | None = Header(default=None),
|
|
212
|
+
x_ccode_timestamp: str | None = Header(default=None),
|
|
213
|
+
x_ccode_sign: str | None = Header(default=None),
|
|
214
|
+
) -> Response:
|
|
215
|
+
auth_result = _valid_admin({"appId": x_ccode_app_id, "timestamp": x_ccode_timestamp, "sign": x_ccode_sign}, "server.api")
|
|
216
|
+
if not auth_result.ok:
|
|
217
|
+
return _auth_response(auth_result, JSONResponse)
|
|
190
218
|
device, session = _get_device_session(device_id, session_id)
|
|
191
219
|
if device is None or session is None:
|
|
192
220
|
return JSONResponse({"error": "not_found"}, status_code=404)
|
|
@@ -201,7 +229,9 @@ def create_server_app() -> Any:
|
|
|
201
229
|
except (asyncio.TimeoutError, json.JSONDecodeError):
|
|
202
230
|
await websocket.close(code=1008)
|
|
203
231
|
return
|
|
204
|
-
|
|
232
|
+
auth_result = _valid_client(hello, "client.control")
|
|
233
|
+
if hello.get("type") != "hello" or not auth_result.ok:
|
|
234
|
+
await websocket.send_json({"type": "error", "error": auth_result.error or "auth_failed", "message": auth_result.message})
|
|
205
235
|
await websocket.close(code=1008)
|
|
206
236
|
return
|
|
207
237
|
device_id = str(hello.get("device_id") or secrets.token_urlsafe(16))
|
|
@@ -237,8 +267,15 @@ def create_server_app() -> Any:
|
|
|
237
267
|
await websocket.accept()
|
|
238
268
|
_prune_pending()
|
|
239
269
|
pending = _PENDING_ATTACHES.get(attach_id)
|
|
240
|
-
|
|
241
|
-
|
|
270
|
+
try:
|
|
271
|
+
raw = await asyncio.wait_for(websocket.receive_text(), timeout=10)
|
|
272
|
+
payload = json.loads(raw)
|
|
273
|
+
except (asyncio.TimeoutError, json.JSONDecodeError):
|
|
274
|
+
await websocket.close(code=1008)
|
|
275
|
+
return
|
|
276
|
+
auth_result = _valid_client(payload, "client.attach")
|
|
277
|
+
if pending is None or payload.get("type") != "auth" or not auth_result.ok:
|
|
278
|
+
await websocket.send_json({"type": "error", "error": auth_result.error or "auth_failed", "message": auth_result.message})
|
|
242
279
|
await websocket.close(code=1008)
|
|
243
280
|
return
|
|
244
281
|
pending.client_ws = websocket
|
|
@@ -249,9 +286,9 @@ def create_server_app() -> Any:
|
|
|
249
286
|
@app.websocket("/ws/devices/{device_id}/sessions/{session_id}")
|
|
250
287
|
async def browser_attach(websocket: FastAPIWebSocket, device_id: str, session_id: str) -> None:
|
|
251
288
|
await websocket.accept()
|
|
252
|
-
|
|
253
|
-
if not
|
|
254
|
-
await websocket.send_json({"type": "
|
|
289
|
+
auth_result = _valid_admin(dict(websocket.query_params), "server.ws")
|
|
290
|
+
if not auth_result.ok:
|
|
291
|
+
await websocket.send_json({"type": "error", "error": auth_result.error, "message": auth_result.message})
|
|
255
292
|
await websocket.close(code=1008)
|
|
256
293
|
return
|
|
257
294
|
device, session = _get_device_session(device_id, session_id)
|