ccoding 0.2.2__py3-none-any.whl → 0.2.4__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 +58 -46
- ccode/remote_cli.py +5 -3
- ccode/remote_client.py +7 -7
- ccode/remote_common.py +89 -32
- ccode/remote_server.py +67 -30
- ccode/ui.py +35 -23
- ccode/web/app.js +99 -30
- ccode/web/index.html +6 -4
- {ccoding-0.2.2.dist-info → ccoding-0.2.4.dist-info}/METADATA +31 -23
- {ccoding-0.2.2.dist-info → ccoding-0.2.4.dist-info}/RECORD +14 -14
- {ccoding-0.2.2.dist-info → ccoding-0.2.4.dist-info}/WHEEL +0 -0
- {ccoding-0.2.2.dist-info → ccoding-0.2.4.dist-info}/entry_points.txt +0 -0
- {ccoding-0.2.2.dist-info → ccoding-0.2.4.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:
|
|
@@ -402,11 +406,11 @@ def create_app() -> Any:
|
|
|
402
406
|
|
|
403
407
|
@app.get("/app.js")
|
|
404
408
|
async def app_js() -> Response:
|
|
405
|
-
return Response(asset_text("app.js"), media_type="application/javascript")
|
|
409
|
+
return Response(asset_text("app.js"), media_type="application/javascript", headers={"Cache-Control": "no-store"})
|
|
406
410
|
|
|
407
411
|
@app.get("/style.css")
|
|
408
412
|
async def style_css() -> Response:
|
|
409
|
-
return Response(asset_text("style.css"), media_type="text/css")
|
|
413
|
+
return Response(asset_text("style.css"), media_type="text/css", headers={"Cache-Control": "no-store"})
|
|
410
414
|
|
|
411
415
|
@app.get("/assets/{asset_name}")
|
|
412
416
|
async def web_asset(asset_name: str) -> Response:
|
|
@@ -418,20 +422,22 @@ def create_app() -> Any:
|
|
|
418
422
|
media_type = media_types.get(asset_name)
|
|
419
423
|
if media_type is None:
|
|
420
424
|
return Response(status_code=404)
|
|
421
|
-
return Response(web_asset_text(asset_name), media_type=media_type)
|
|
425
|
+
return Response(web_asset_text(asset_name), media_type=media_type, headers={"Cache-Control": "no-store"})
|
|
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]:
|
|
@@ -147,11 +158,11 @@ def create_server_app() -> Any:
|
|
|
147
158
|
|
|
148
159
|
@app.get("/app.js")
|
|
149
160
|
async def app_js() -> Response:
|
|
150
|
-
return Response(asset_text("app.js"), media_type="application/javascript")
|
|
161
|
+
return Response(asset_text("app.js"), media_type="application/javascript", headers={"Cache-Control": "no-store"})
|
|
151
162
|
|
|
152
163
|
@app.get("/style.css")
|
|
153
164
|
async def style_css() -> Response:
|
|
154
|
-
return Response(asset_text("style.css"), media_type="text/css")
|
|
165
|
+
return Response(asset_text("style.css"), media_type="text/css", headers={"Cache-Control": "no-store"})
|
|
155
166
|
|
|
156
167
|
@app.get("/assets/{asset_name}")
|
|
157
168
|
async def web_asset(asset_name: str) -> Response:
|
|
@@ -163,30 +174,47 @@ def create_server_app() -> Any:
|
|
|
163
174
|
media_type = media_types.get(asset_name)
|
|
164
175
|
if media_type is None:
|
|
165
176
|
return Response(status_code=404)
|
|
166
|
-
return Response(web_asset_text(asset_name), media_type=media_type)
|
|
177
|
+
return Response(web_asset_text(asset_name), media_type=media_type, headers={"Cache-Control": "no-store"})
|
|
167
178
|
|
|
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)
|
ccode/ui.py
CHANGED
|
@@ -42,9 +42,11 @@ _FIELD_REMOTE_ENABLED = "remote.enabled"
|
|
|
42
42
|
_FIELD_REMOTE_MODE = "remote.mode"
|
|
43
43
|
_FIELD_REMOTE_LOCAL_HOST = "remote.local.host"
|
|
44
44
|
_FIELD_REMOTE_LOCAL_PORT = "remote.local.port"
|
|
45
|
-
|
|
45
|
+
_FIELD_REMOTE_LOCAL_APP_ID = "remote.local.appId"
|
|
46
|
+
_FIELD_REMOTE_LOCAL_APP_KEY = "remote.local.appKey"
|
|
46
47
|
_FIELD_REMOTE_SERVER_URL = "remote.server.url"
|
|
47
|
-
|
|
48
|
+
_FIELD_REMOTE_SERVER_APP_ID = "remote.server.appId"
|
|
49
|
+
_FIELD_REMOTE_SERVER_APP_KEY = "remote.server.appKey"
|
|
48
50
|
_FIELD_REMOTE_SERVER_DEVICE_NAME = "remote.server.device_name"
|
|
49
51
|
_FIELD_REMOTE_SERVER_DEVICE_ID = "remote.server.device_id"
|
|
50
52
|
_FIELD_REMOTE_SERVER_AUTO_CONNECT = "remote.server.auto_connect"
|
|
@@ -93,9 +95,11 @@ class CursesApp:
|
|
|
93
95
|
_FIELD_API_KEY: len(self.config.get("api_key", "")),
|
|
94
96
|
_FIELD_REMOTE_LOCAL_HOST: len(str(local.get("host", ""))),
|
|
95
97
|
_FIELD_REMOTE_LOCAL_PORT: len(str(local.get("port", ""))),
|
|
96
|
-
|
|
98
|
+
_FIELD_REMOTE_LOCAL_APP_ID: len(str(local.get("appId", ""))),
|
|
99
|
+
_FIELD_REMOTE_LOCAL_APP_KEY: len(str(local.get("appKey", ""))),
|
|
97
100
|
_FIELD_REMOTE_SERVER_URL: len(str(server.get("url", ""))),
|
|
98
|
-
|
|
101
|
+
_FIELD_REMOTE_SERVER_APP_ID: len(str(server.get("appId", ""))),
|
|
102
|
+
_FIELD_REMOTE_SERVER_APP_KEY: len(str(server.get("appKey", ""))),
|
|
99
103
|
_FIELD_REMOTE_SERVER_DEVICE_NAME: len(str(server.get("device_name", ""))),
|
|
100
104
|
_FIELD_REMOTE_SERVER_DEVICE_ID: len(str(server.get("device_id", ""))),
|
|
101
105
|
_FIELD_REMOTE_SESSION_NAME: len(str(remote.get("session_name", ""))),
|
|
@@ -136,9 +140,11 @@ class CursesApp:
|
|
|
136
140
|
_FIELD_REMOTE_MODE,
|
|
137
141
|
_FIELD_REMOTE_LOCAL_HOST,
|
|
138
142
|
_FIELD_REMOTE_LOCAL_PORT,
|
|
139
|
-
|
|
143
|
+
_FIELD_REMOTE_LOCAL_APP_ID,
|
|
144
|
+
_FIELD_REMOTE_LOCAL_APP_KEY,
|
|
140
145
|
_FIELD_REMOTE_SERVER_URL,
|
|
141
|
-
|
|
146
|
+
_FIELD_REMOTE_SERVER_APP_ID,
|
|
147
|
+
_FIELD_REMOTE_SERVER_APP_KEY,
|
|
142
148
|
_FIELD_REMOTE_SERVER_DEVICE_NAME,
|
|
143
149
|
_FIELD_REMOTE_SERVER_DEVICE_ID,
|
|
144
150
|
_FIELD_REMOTE_SERVER_AUTO_CONNECT,
|
|
@@ -355,7 +361,7 @@ class CursesApp:
|
|
|
355
361
|
y += 1
|
|
356
362
|
|
|
357
363
|
y += 1
|
|
358
|
-
addstr_safe(stdscr, y, x, "─── Remote/Web [space/enter]=toggle [g]=generate
|
|
364
|
+
addstr_safe(stdscr, y, x, "─── Remote/Web [space/enter]=toggle [g]=generate app id/key ───────")
|
|
359
365
|
y += 1
|
|
360
366
|
remote = self.config.setdefault("remote", {})
|
|
361
367
|
local = remote.setdefault("local", {})
|
|
@@ -366,9 +372,11 @@ class CursesApp:
|
|
|
366
372
|
(_FIELD_REMOTE_MODE, "MODE:", str(remote.get("mode", "local"))),
|
|
367
373
|
(_FIELD_REMOTE_LOCAL_HOST, "LOCAL HOST:", str(local.get("host", ""))),
|
|
368
374
|
(_FIELD_REMOTE_LOCAL_PORT, "LOCAL PORT:", str(local.get("port", ""))),
|
|
369
|
-
(
|
|
375
|
+
(_FIELD_REMOTE_LOCAL_APP_ID, "LOCAL APP ID:", str(local.get("appId", ""))),
|
|
376
|
+
(_FIELD_REMOTE_LOCAL_APP_KEY, "LOCAL APP KEY:", mask_secret(str(local.get("appKey", "")))),
|
|
370
377
|
(_FIELD_REMOTE_SERVER_URL, "SERVER URL:", str(server.get("url", ""))),
|
|
371
|
-
(
|
|
378
|
+
(_FIELD_REMOTE_SERVER_APP_ID, "CLIENT APP ID:", str(server.get("appId", ""))),
|
|
379
|
+
(_FIELD_REMOTE_SERVER_APP_KEY, "CLIENT APP KEY:", mask_secret(str(server.get("appKey", "")))),
|
|
372
380
|
(_FIELD_REMOTE_SERVER_DEVICE_NAME, "DEVICE NAME:", str(server.get("device_name", ""))),
|
|
373
381
|
(_FIELD_REMOTE_SERVER_DEVICE_ID, "DEVICE ID:", str(server.get("device_id", ""))),
|
|
374
382
|
(_FIELD_REMOTE_SERVER_AUTO_CONNECT, "AUTO CONNECT:", "on" if server.get("auto_connect") else "off"),
|
|
@@ -379,10 +387,10 @@ class CursesApp:
|
|
|
379
387
|
is_focused = focused == field
|
|
380
388
|
label_x = x
|
|
381
389
|
value_x = x + 22
|
|
382
|
-
if field ==
|
|
383
|
-
display = str(local.get("
|
|
384
|
-
if field ==
|
|
385
|
-
display = str(server.get("
|
|
390
|
+
if field == _FIELD_REMOTE_LOCAL_APP_KEY and is_focused:
|
|
391
|
+
display = str(local.get("appKey", ""))
|
|
392
|
+
if field == _FIELD_REMOTE_SERVER_APP_KEY and is_focused:
|
|
393
|
+
display = str(server.get("appKey", ""))
|
|
386
394
|
addstr_safe(stdscr, y, label_x, label)
|
|
387
395
|
addstr_safe(stdscr, y, value_x, display or "<unset>", curses.A_REVERSE if is_focused else 0)
|
|
388
396
|
if field == _FIELD_REMOTE_LOCAL_HOST and str(local.get("host", "")) == "0.0.0.0":
|
|
@@ -484,9 +492,11 @@ class CursesApp:
|
|
|
484
492
|
elif focused in (
|
|
485
493
|
_FIELD_REMOTE_LOCAL_HOST,
|
|
486
494
|
_FIELD_REMOTE_LOCAL_PORT,
|
|
487
|
-
|
|
495
|
+
_FIELD_REMOTE_LOCAL_APP_ID,
|
|
496
|
+
_FIELD_REMOTE_LOCAL_APP_KEY,
|
|
488
497
|
_FIELD_REMOTE_SERVER_URL,
|
|
489
|
-
|
|
498
|
+
_FIELD_REMOTE_SERVER_APP_ID,
|
|
499
|
+
_FIELD_REMOTE_SERVER_APP_KEY,
|
|
490
500
|
_FIELD_REMOTE_SERVER_DEVICE_NAME,
|
|
491
501
|
_FIELD_REMOTE_SERVER_DEVICE_ID,
|
|
492
502
|
_FIELD_REMOTE_SESSION_NAME,
|
|
@@ -569,14 +579,14 @@ class CursesApp:
|
|
|
569
579
|
save_config(self.config)
|
|
570
580
|
return
|
|
571
581
|
|
|
572
|
-
if focused in (
|
|
573
|
-
|
|
582
|
+
if focused in (_FIELD_REMOTE_LOCAL_APP_ID, _FIELD_REMOTE_SERVER_APP_ID, _FIELD_REMOTE_LOCAL_APP_KEY, _FIELD_REMOTE_SERVER_APP_KEY) and key in (ord("g"), ord("G")):
|
|
583
|
+
value = f"app_{secrets.token_urlsafe(9)}" if focused in (_FIELD_REMOTE_LOCAL_APP_ID, _FIELD_REMOTE_SERVER_APP_ID) else secrets.token_urlsafe(32)
|
|
574
584
|
remote = self.config.setdefault("remote", {})
|
|
575
|
-
target = remote.setdefault("local" if focused
|
|
576
|
-
target["
|
|
577
|
-
self.config_cursor[focused] = len(
|
|
585
|
+
target = remote.setdefault("local" if focused in (_FIELD_REMOTE_LOCAL_APP_ID, _FIELD_REMOTE_LOCAL_APP_KEY) else "server", {})
|
|
586
|
+
target["appId" if focused in (_FIELD_REMOTE_LOCAL_APP_ID, _FIELD_REMOTE_SERVER_APP_ID) else "appKey"] = value
|
|
587
|
+
self.config_cursor[focused] = len(value)
|
|
578
588
|
save_config(self.config)
|
|
579
|
-
self.status_message = "Remote
|
|
589
|
+
self.status_message = "Remote app credential regenerated."
|
|
580
590
|
return
|
|
581
591
|
|
|
582
592
|
# 文本字段
|
|
@@ -586,9 +596,11 @@ class CursesApp:
|
|
|
586
596
|
if focused in (
|
|
587
597
|
_FIELD_REMOTE_LOCAL_HOST,
|
|
588
598
|
_FIELD_REMOTE_LOCAL_PORT,
|
|
589
|
-
|
|
599
|
+
_FIELD_REMOTE_LOCAL_APP_ID,
|
|
600
|
+
_FIELD_REMOTE_LOCAL_APP_KEY,
|
|
590
601
|
_FIELD_REMOTE_SERVER_URL,
|
|
591
|
-
|
|
602
|
+
_FIELD_REMOTE_SERVER_APP_ID,
|
|
603
|
+
_FIELD_REMOTE_SERVER_APP_KEY,
|
|
592
604
|
_FIELD_REMOTE_SERVER_DEVICE_NAME,
|
|
593
605
|
_FIELD_REMOTE_SERVER_DEVICE_ID,
|
|
594
606
|
_FIELD_REMOTE_SESSION_NAME,
|
ccode/web/app.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
const terminalEl = document.getElementById("terminal");
|
|
2
2
|
const sessionListEl = document.getElementById("session-list");
|
|
3
3
|
const tokenFormEl = document.getElementById("token-form");
|
|
4
|
-
const
|
|
4
|
+
const appIdInputEl = document.getElementById("app-id-input");
|
|
5
|
+
const appKeyInputEl = document.getElementById("app-key-input");
|
|
5
6
|
const statusEl = document.getElementById("status");
|
|
6
7
|
const actionsEl = document.getElementById("actions");
|
|
7
8
|
const actionsToggleButton = document.getElementById("actions-toggle");
|
|
@@ -26,9 +27,11 @@ const inputSequences = {
|
|
|
26
27
|
left: "\x1b[D",
|
|
27
28
|
};
|
|
28
29
|
|
|
29
|
-
const
|
|
30
|
+
const authStorageKey = "ccode.remote.authTicket";
|
|
31
|
+
const appIdStorageKey = "ccode.remote.appId";
|
|
30
32
|
const fontSizeStorageKey = "ccode.remote.terminalFontSize";
|
|
31
|
-
let
|
|
33
|
+
let authTicket = loadAuthTicket();
|
|
34
|
+
let remoteKind = "local";
|
|
32
35
|
let socket = null;
|
|
33
36
|
let term = null;
|
|
34
37
|
let fitAddon = null;
|
|
@@ -40,10 +43,66 @@ let resizeFrame = null;
|
|
|
40
43
|
let viewportHeight = "";
|
|
41
44
|
let keyboardInset = "";
|
|
42
45
|
|
|
46
|
+
function loadAuthTicket() {
|
|
47
|
+
try {
|
|
48
|
+
const ticket = JSON.parse(window.sessionStorage.getItem(authStorageKey) || "null");
|
|
49
|
+
return ticket && ticket.appId && ticket.timestamp && ticket.signs ? ticket : null;
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
43
55
|
function setStatus(message) {
|
|
44
56
|
statusEl.textContent = message;
|
|
45
57
|
}
|
|
46
58
|
|
|
59
|
+
function authMessage(appId, timestamp, scope) {
|
|
60
|
+
return `v1\nappId=${appId}\ntimestamp=${timestamp}\nscope=${scope}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function base64Url(bytes) {
|
|
64
|
+
let binary = "";
|
|
65
|
+
for (const byte of bytes) {
|
|
66
|
+
binary += String.fromCharCode(byte);
|
|
67
|
+
}
|
|
68
|
+
return window.btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function signAuth(appId, appKey, timestamp, scope) {
|
|
72
|
+
const encoder = new TextEncoder();
|
|
73
|
+
const key = await window.crypto.subtle.importKey("raw", encoder.encode(appKey), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
74
|
+
const signature = await window.crypto.subtle.sign("HMAC", key, encoder.encode(authMessage(appId, timestamp, scope)));
|
|
75
|
+
return base64Url(new Uint8Array(signature));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function createAuthTicket(appId, appKey) {
|
|
79
|
+
const timestamp = String(Math.floor(Date.now() / 1000));
|
|
80
|
+
const scopes = ["local.api", "local.ws", "server.api", "server.ws"];
|
|
81
|
+
const signs = {};
|
|
82
|
+
for (const scope of scopes) {
|
|
83
|
+
signs[scope] = await signAuth(appId, appKey, timestamp, scope);
|
|
84
|
+
}
|
|
85
|
+
return { appId, timestamp, signs };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function authHeaders(scope) {
|
|
89
|
+
return {
|
|
90
|
+
"X-CCODE-App-Id": authTicket.appId,
|
|
91
|
+
"X-CCODE-Timestamp": authTicket.timestamp,
|
|
92
|
+
"X-CCODE-Sign": authTicket.signs[scope] || "",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function authQuery(scope) {
|
|
97
|
+
return new URLSearchParams({ appId: authTicket.appId, timestamp: authTicket.timestamp, sign: authTicket.signs[scope] || "" }).toString();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function clearAuthTicket(message = "enter app credentials") {
|
|
101
|
+
window.sessionStorage.removeItem(authStorageKey);
|
|
102
|
+
authTicket = null;
|
|
103
|
+
showTokenMode(message);
|
|
104
|
+
}
|
|
105
|
+
|
|
47
106
|
function sendInput(data) {
|
|
48
107
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
49
108
|
return;
|
|
@@ -190,7 +249,7 @@ function closeSocket() {
|
|
|
190
249
|
}
|
|
191
250
|
}
|
|
192
251
|
|
|
193
|
-
function showTokenMode(message = "enter
|
|
252
|
+
function showTokenMode(message = "enter app credentials") {
|
|
194
253
|
closeSocket();
|
|
195
254
|
currentSession = null;
|
|
196
255
|
terminalEl.hidden = true;
|
|
@@ -205,8 +264,9 @@ function showTokenMode(message = "enter token") {
|
|
|
205
264
|
refreshButton.hidden = true;
|
|
206
265
|
changeTokenButton.hidden = true;
|
|
207
266
|
setStatus(message);
|
|
208
|
-
|
|
209
|
-
|
|
267
|
+
appIdInputEl.value = window.sessionStorage.getItem(appIdStorageKey) || authTicket?.appId || "";
|
|
268
|
+
appKeyInputEl.value = "";
|
|
269
|
+
appIdInputEl.focus();
|
|
210
270
|
}
|
|
211
271
|
|
|
212
272
|
function showListMode() {
|
|
@@ -297,7 +357,7 @@ function renderSessions(sessions) {
|
|
|
297
357
|
}
|
|
298
358
|
|
|
299
359
|
async function loadSessions() {
|
|
300
|
-
if (!
|
|
360
|
+
if (!authTicket) {
|
|
301
361
|
showTokenMode();
|
|
302
362
|
return;
|
|
303
363
|
}
|
|
@@ -307,12 +367,11 @@ async function loadSessions() {
|
|
|
307
367
|
try {
|
|
308
368
|
const response = await fetch("/api/sessions", {
|
|
309
369
|
cache: "no-store",
|
|
310
|
-
headers: {
|
|
370
|
+
headers: authHeaders(`${remoteKind}.api`),
|
|
311
371
|
});
|
|
312
372
|
if (response.status === 401) {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
showTokenMode("unauthorized: invalid token");
|
|
373
|
+
const payload = await response.json().catch(() => ({}));
|
|
374
|
+
clearAuthTicket(payload.error === "reauth_required" ? "authentication expired: enter app credentials" : "unauthorized: invalid app credentials");
|
|
316
375
|
return;
|
|
317
376
|
}
|
|
318
377
|
if (!response.ok) {
|
|
@@ -334,7 +393,7 @@ function sessionWsPath(session) {
|
|
|
334
393
|
}
|
|
335
394
|
|
|
336
395
|
function connect() {
|
|
337
|
-
if (!
|
|
396
|
+
if (!authTicket) {
|
|
338
397
|
showTokenMode();
|
|
339
398
|
return;
|
|
340
399
|
}
|
|
@@ -347,13 +406,9 @@ function connect() {
|
|
|
347
406
|
closeSocket();
|
|
348
407
|
term.clear();
|
|
349
408
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
350
|
-
socket = new WebSocket(`${protocol}//${window.location.host}${sessionWsPath(currentSession)}`);
|
|
409
|
+
socket = new WebSocket(`${protocol}//${window.location.host}${sessionWsPath(currentSession)}?${authQuery(`${remoteKind}.ws`)}`);
|
|
351
410
|
setStatus("connecting");
|
|
352
411
|
|
|
353
|
-
socket.addEventListener("open", () => {
|
|
354
|
-
socket.send(JSON.stringify({ type: "auth", token }));
|
|
355
|
-
});
|
|
356
|
-
|
|
357
412
|
socket.addEventListener("message", (event) => {
|
|
358
413
|
const payload = JSON.parse(event.data);
|
|
359
414
|
if (payload.type === "output") {
|
|
@@ -364,15 +419,17 @@ function connect() {
|
|
|
364
419
|
fitAndSendResize();
|
|
365
420
|
term.focus();
|
|
366
421
|
}
|
|
422
|
+
} else if (payload.type === "error") {
|
|
423
|
+
if (payload.error === "reauth_required" || payload.error === "auth_required" || payload.error === "auth_failed") {
|
|
424
|
+
clearAuthTicket(payload.message || "enter app credentials");
|
|
425
|
+
}
|
|
367
426
|
}
|
|
368
427
|
});
|
|
369
428
|
|
|
370
429
|
socket.addEventListener("close", (event) => {
|
|
371
430
|
if (event.code === 1008) {
|
|
372
|
-
if (statusEl.textContent
|
|
373
|
-
|
|
374
|
-
token = "";
|
|
375
|
-
showTokenMode("unauthorized: invalid token");
|
|
431
|
+
if (statusEl.textContent !== "connected") {
|
|
432
|
+
clearAuthTicket("enter app credentials");
|
|
376
433
|
}
|
|
377
434
|
return;
|
|
378
435
|
}
|
|
@@ -394,21 +451,22 @@ function openSession(session) {
|
|
|
394
451
|
connect();
|
|
395
452
|
}
|
|
396
453
|
|
|
397
|
-
tokenFormEl.addEventListener("submit", (event) => {
|
|
454
|
+
tokenFormEl.addEventListener("submit", async (event) => {
|
|
398
455
|
event.preventDefault();
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
456
|
+
const appId = appIdInputEl.value.trim();
|
|
457
|
+
const appKey = appKeyInputEl.value.trim();
|
|
458
|
+
if (!appId || !appKey) {
|
|
459
|
+
showTokenMode("enter app credentials");
|
|
402
460
|
return;
|
|
403
461
|
}
|
|
404
|
-
|
|
462
|
+
authTicket = await createAuthTicket(appId, appKey);
|
|
463
|
+
window.sessionStorage.setItem(authStorageKey, JSON.stringify(authTicket));
|
|
464
|
+
window.sessionStorage.setItem(appIdStorageKey, appId);
|
|
405
465
|
loadSessions();
|
|
406
466
|
});
|
|
407
467
|
|
|
408
468
|
changeTokenButton.addEventListener("click", () => {
|
|
409
|
-
|
|
410
|
-
token = "";
|
|
411
|
-
showTokenMode("enter token");
|
|
469
|
+
clearAuthTicket("enter app credentials");
|
|
412
470
|
});
|
|
413
471
|
window.addEventListener("resize", requestFitAndSendResize);
|
|
414
472
|
actionsToggleButton.addEventListener("click", () => {
|
|
@@ -451,4 +509,15 @@ if (window.visualViewport) {
|
|
|
451
509
|
}
|
|
452
510
|
updateViewportInsets();
|
|
453
511
|
|
|
454
|
-
|
|
512
|
+
async function initialize() {
|
|
513
|
+
try {
|
|
514
|
+
const response = await fetch("/health", { cache: "no-store" });
|
|
515
|
+
const payload = await response.json();
|
|
516
|
+
remoteKind = payload.remote_kind === "server" ? "server" : "local";
|
|
517
|
+
} catch (error) {
|
|
518
|
+
remoteKind = "local";
|
|
519
|
+
}
|
|
520
|
+
loadSessions();
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
initialize();
|
ccode/web/index.html
CHANGED
|
@@ -12,23 +12,25 @@
|
|
|
12
12
|
<header>
|
|
13
13
|
<div>
|
|
14
14
|
<h1>ccode remote</h1>
|
|
15
|
-
<p id="status">enter
|
|
15
|
+
<p id="status">enter app credentials</p>
|
|
16
16
|
</div>
|
|
17
17
|
<button id="actions-toggle" class="actions-toggle" type="button" hidden>Menu</button>
|
|
18
18
|
<div id="actions" class="actions">
|
|
19
19
|
<button id="back" type="button" hidden>Sessions</button>
|
|
20
20
|
<button id="refresh" type="button" hidden>Refresh</button>
|
|
21
|
-
<button id="change-token" type="button" hidden>Change
|
|
21
|
+
<button id="change-token" type="button" hidden>Change credentials</button>
|
|
22
22
|
<button id="focus" type="button" hidden>Focus terminal</button>
|
|
23
23
|
<button id="reconnect" type="button" hidden>Reconnect</button>
|
|
24
24
|
</div>
|
|
25
25
|
</header>
|
|
26
26
|
<form id="token-form" class="token-panel">
|
|
27
|
-
<label for="
|
|
27
|
+
<label for="app-id-input">Remote app credentials</label>
|
|
28
28
|
<div class="token-row">
|
|
29
|
-
<input id="
|
|
29
|
+
<input id="app-id-input" autocomplete="username" placeholder="App ID" />
|
|
30
|
+
<input id="app-key-input" type="password" autocomplete="current-password" placeholder="App Key" />
|
|
30
31
|
<button type="submit">Continue</button>
|
|
31
32
|
</div>
|
|
33
|
+
<small>App Key is only used locally to create a 24-hour signature.</small>
|
|
32
34
|
</form>
|
|
33
35
|
<section id="session-list" hidden></section>
|
|
34
36
|
<div id="terminal" tabindex="0" hidden></div>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ccoding
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Curses-based Claude Code launcher
|
|
5
5
|
Project-URL: Homepage, https://github.com/8DE4732A/ccode
|
|
6
6
|
Project-URL: Repository, https://github.com/8DE4732A/ccode
|
|
@@ -173,8 +173,8 @@ uv sync --extra remote # 同时安装 client/server 依赖
|
|
|
173
173
|
|
|
174
174
|
- `↑ / ↓`:移动字段焦点
|
|
175
175
|
- `Enter / Space`:切换开关项
|
|
176
|
-
- 文本输入:编辑 BASE_URL / API_KEY、remote local/server URL/
|
|
177
|
-
- `g`:在 Remote
|
|
176
|
+
- 文本输入:编辑 BASE_URL / API_KEY、remote local/server URL/appId/appKey/device/prefix 等字段
|
|
177
|
+
- `g`:在 Remote appId 或 appKey 字段重新生成对应值
|
|
178
178
|
- `ESC`:自动保存并返回主界面
|
|
179
179
|
|
|
180
180
|
## 配置文件
|
|
@@ -209,11 +209,13 @@ uv sync --extra remote # 同时安装 client/server 依赖
|
|
|
209
209
|
"local": {
|
|
210
210
|
"host": "127.0.0.1",
|
|
211
211
|
"port": 8765,
|
|
212
|
-
"
|
|
212
|
+
"appId": "",
|
|
213
|
+
"appKey": ""
|
|
213
214
|
},
|
|
214
215
|
"server": {
|
|
215
216
|
"url": "",
|
|
216
|
-
"
|
|
217
|
+
"appId": "",
|
|
218
|
+
"appKey": "",
|
|
217
219
|
"device_name": "",
|
|
218
220
|
"device_id": "",
|
|
219
221
|
"auto_connect": true,
|
|
@@ -232,6 +234,8 @@ Remote/Web 让 Claude Code 运行在 tmux session 中,浏览器通过 WebSocke
|
|
|
232
234
|
| `local` | 直接访问本机 local hub | 当前这台机器 | 单机使用,或把这一台机器通过 tunnel 暴露出去 |
|
|
233
235
|
| `server` | 访问公网中心 `ccode-remote-server` | 各客户端设备本机 | 多设备、NAT 后设备、统一入口管理多个 sessions |
|
|
234
236
|
|
|
237
|
+
Remote/Web 使用 appId/appKey HMAC 认证。网络请求只携带 `appId`、`timestamp` 和 `sign`,其中 `sign` 由 appKey 计算,appKey 不会被发送。浏览器端只保存 24 小时有效的签名票据,过期后需要重新输入 appKey;客户端 connector 会从本地配置读取 appKey 并在重连时自动重新签名。旧版配置中的 `token` 会在读取时迁移为 `appKey`,并自动生成 `appId`。
|
|
238
|
+
|
|
235
239
|
### local 模式
|
|
236
240
|
|
|
237
241
|
local 模式会在本机启动一个共享 FastAPI hub。浏览器直接访问这个 hub,hub 再 attach 到本机 tmux session。
|
|
@@ -262,23 +266,23 @@ claude
|
|
|
262
266
|
- `MODE`:`local`
|
|
263
267
|
- `LOCAL HOST`:默认 `127.0.0.1`
|
|
264
268
|
- `LOCAL PORT`:默认 `8765`
|
|
265
|
-
- `LOCAL
|
|
269
|
+
- `LOCAL APP ID` / `LOCAL APP KEY`:手动输入,或在对应字段按 `g` 生成
|
|
266
270
|
|
|
267
271
|
3. 回到主界面,按 `r` 开启 Remote/Web,再按 `Enter` 启动 remote session。
|
|
268
272
|
|
|
269
|
-
4. 浏览器打开终端输出的本地地址,输入 `LOCAL
|
|
273
|
+
4. 浏览器打开终端输出的本地地址,输入 `LOCAL APP ID` / `LOCAL APP KEY`,选择 session 后进入 terminal。
|
|
270
274
|
|
|
271
|
-
local 模式默认只监听 `127.0.0.1
|
|
275
|
+
local 模式默认只监听 `127.0.0.1`。终端只输出不带认证信息的本地访问地址。浏览器打开页面后需要手动输入 appId/appKey,前端只保存由 appKey 生成的 24 小时 HMAC 签名票据,不保存 appKey;票据过期或关闭浏览器会话后需要重新输入。所有 session list、session API 和 WebSocket 连接仍然需要签名认证。
|
|
272
276
|
|
|
273
|
-
每个启用 Remote/Web 的 `ccode` 进程都会创建一个唯一 tmux session,session 名由配置里的 `session_name` 前缀、时间戳和进程号组成,例如 `ccode-claude-20260509-153022-12345`。本机只启动一个共享 remote hub,多个本地 `ccode` 进程会复用同一个 host/port/
|
|
277
|
+
每个启用 Remote/Web 的 `ccode` 进程都会创建一个唯一 tmux session,session 名由配置里的 `session_name` 前缀、时间戳和进程号组成,例如 `ccode-claude-20260509-153022-12345`。本机只启动一个共享 remote hub,多个本地 `ccode` 进程会复用同一个 host/port/appId/appKey。浏览器认证后会先显示 session 列表,选择某个 session 后进入 terminal,也可以返回列表重新选择。
|
|
274
278
|
|
|
275
|
-
如果通过 Cloudflare Tunnel、Nginx、Caddy 等反向代理暴露 local hub,建议额外叠加 Cloudflare Access、Basic Auth 或 OAuth2 proxy。监听 `0.0.0.0` 会把所有 remote sessions 暴露给网络内可达主机,请保护好访问入口和
|
|
279
|
+
如果通过 Cloudflare Tunnel、Nginx、Caddy 等反向代理暴露 local hub,建议额外叠加 Cloudflare Access、Basic Auth 或 OAuth2 proxy。监听 `0.0.0.0` 会把所有 remote sessions 暴露给网络内可达主机,请保护好访问入口和 app credentials。
|
|
276
280
|
|
|
277
281
|
```bash
|
|
278
282
|
cloudflared tunnel --url http://127.0.0.1:8765
|
|
279
283
|
```
|
|
280
284
|
|
|
281
|
-
local hub 日志写入 `~/.ccode/remote_server.log`,记录 hub 启停、session 注册、session API 和 WebSocket 连接状态,不记录
|
|
285
|
+
local hub 日志写入 `~/.ccode/remote_server.log`,记录 hub 启停、session 注册、session API 和 WebSocket 连接状态,不记录 appKey。启动 hub 前如果日志超过 5 MiB 会自动轮转,默认保留 3 个历史文件(`.1`、`.2`、`.3`)。
|
|
282
286
|
|
|
283
287
|
### server 模式和 `ccode-remote-server`
|
|
284
288
|
|
|
@@ -289,7 +293,7 @@ local hub 日志写入 `~/.ccode/remote_server.log`,记录 hub 启停、sessio
|
|
|
289
293
|
```text
|
|
290
294
|
Browser
|
|
291
295
|
│
|
|
292
|
-
│ HTTPS/WSS + admin
|
|
296
|
+
│ HTTPS/WSS + admin HMAC
|
|
293
297
|
▼
|
|
294
298
|
┌───────────────────────┐
|
|
295
299
|
│ ccode-remote-server │
|
|
@@ -314,31 +318,35 @@ local hub 日志写入 `~/.ccode/remote_server.log`,记录 hub 启停、sessio
|
|
|
314
318
|
|
|
315
319
|
server 不反连客户端,所以客户端可以在 NAT、家庭网络或公司内网后面。只要客户端能主动访问 server,就可以注册设备和暴露自己的 remote sessions。
|
|
316
320
|
|
|
317
|
-
#### server
|
|
321
|
+
#### server app credentials
|
|
318
322
|
|
|
319
|
-
server
|
|
323
|
+
server app credentials 不会自动生成,必须通过环境变量显式设置。认证时网络上传输 appId、timestamp 和 HMAC sign,不传输 appKey:
|
|
320
324
|
|
|
321
|
-
- `
|
|
322
|
-
- `
|
|
325
|
+
- `CCODE_REMOTE_SERVER_ADMIN_APP_ID` / `CCODE_REMOTE_SERVER_ADMIN_APP_KEY`:浏览器 Web UI / `/api/*` / browser WebSocket 使用的管理凭据。
|
|
326
|
+
- `CCODE_REMOTE_SERVER_CLIENT_APP_ID` / `CCODE_REMOTE_SERVER_CLIENT_APP_KEY`:各设备客户端 connector 注册和心跳使用的客户端凭据。
|
|
323
327
|
- `CCODE_REMOTE_SERVER_HOST` / `CCODE_REMOTE_SERVER_PORT`:可选,默认监听 `127.0.0.1:8765`。
|
|
324
328
|
|
|
325
329
|
启动中心 server:
|
|
326
330
|
|
|
327
331
|
```bash
|
|
328
|
-
|
|
329
|
-
|
|
332
|
+
CCODE_REMOTE_SERVER_ADMIN_APP_ID="admin-app" \
|
|
333
|
+
CCODE_REMOTE_SERVER_ADMIN_APP_KEY="admin-secret" \
|
|
334
|
+
CCODE_REMOTE_SERVER_CLIENT_APP_ID="client-app" \
|
|
335
|
+
CCODE_REMOTE_SERVER_CLIENT_APP_KEY="client-secret" \
|
|
330
336
|
ccode-remote-server --host 127.0.0.1 --port 8765
|
|
331
337
|
```
|
|
332
338
|
|
|
333
339
|
如果使用源码开发环境运行:
|
|
334
340
|
|
|
335
341
|
```bash
|
|
336
|
-
|
|
337
|
-
|
|
342
|
+
CCODE_REMOTE_SERVER_ADMIN_APP_ID="admin-app" \
|
|
343
|
+
CCODE_REMOTE_SERVER_ADMIN_APP_KEY="admin-secret" \
|
|
344
|
+
CCODE_REMOTE_SERVER_CLIENT_APP_ID="client-app" \
|
|
345
|
+
CCODE_REMOTE_SERVER_CLIENT_APP_KEY="client-secret" \
|
|
338
346
|
uv run --extra remote-server ccode-remote-server --host 127.0.0.1 --port 8765
|
|
339
347
|
```
|
|
340
348
|
|
|
341
|
-
浏览器访问 server 页面时输入
|
|
349
|
+
浏览器访问 server 页面时输入 admin appId/appKey。前端只保存 24 小时有效的 HMAC 签名票据,不保存 appKey。如果未设置 `CCODE_REMOTE_SERVER_ADMIN_APP_ID` / `CCODE_REMOTE_SERVER_ADMIN_APP_KEY`,管理 API 会返回 401。
|
|
342
350
|
|
|
343
351
|
#### 客户端设备配置
|
|
344
352
|
|
|
@@ -352,7 +360,7 @@ uv tool install 'ccoding[remote-client]'
|
|
|
352
360
|
|
|
353
361
|
- `MODE`:`server`
|
|
354
362
|
- `SERVER URL`:中心 server 地址,例如 `http://127.0.0.1:8765`;如果只填 `127.0.0.1:8765`,会自动按 `http://127.0.0.1:8765` 处理
|
|
355
|
-
- `CLIENT
|
|
363
|
+
- `CLIENT APP ID` / `CLIENT APP KEY`:与 server 的 `CCODE_REMOTE_SERVER_CLIENT_APP_ID` / `CCODE_REMOTE_SERVER_CLIENT_APP_KEY` 一致
|
|
356
364
|
- `DEVICE NAME`:设备显示名,可留空后由配置迁移填充 hostname
|
|
357
365
|
- `DEVICE ID`:稳定设备 ID,可留空后自动生成
|
|
358
366
|
- `AUTO CONNECT`:是否默认自动连接中心 server
|
|
@@ -384,7 +392,7 @@ server 只做 registry/router/relay,不保存 terminal frame data,不保存
|
|
|
384
392
|
|
|
385
393
|
#### Cloudflare Tunnel / 反向代理
|
|
386
394
|
|
|
387
|
-
如果用 Cloudflare Tunnel 暴露中心 server,只需要 tunnel server 这一处入口,多台客户端设备都配置同一个 `SERVER URL` 和 `CLIENT
|
|
395
|
+
如果用 Cloudflare Tunnel 暴露中心 server,只需要 tunnel server 这一处入口,多台客户端设备都配置同一个 `SERVER URL` 和 `CLIENT APP ID` / `CLIENT APP KEY`:
|
|
388
396
|
|
|
389
397
|
```bash
|
|
390
398
|
cloudflared tunnel --url http://127.0.0.1:8765
|
|
@@ -2,26 +2,26 @@ ccode/__init__.py,sha256=bbPH1IiDNbRuQvhS_fNfXhPDKeMQ5R6oS4ngv_7zHZ8,170
|
|
|
2
2
|
ccode/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
|
|
3
3
|
ccode/api.py,sha256=ZohlOtb64zU9f7A-CGhPcSluT8D30Be9OG6yAvucBnE,5126
|
|
4
4
|
ccode/cli.py,sha256=SovAIOMH75HW2Ci8Kl6EKnJ2_lwl7Fx-l7WVWDSiAxY,247
|
|
5
|
-
ccode/config.py,sha256=
|
|
5
|
+
ccode/config.py,sha256=B14xX1e34YEUA4gqhl5dJt-55McnaJcXCpPU6Zakhss,8522
|
|
6
6
|
ccode/env_schema.json,sha256=4_VpMYRwzxMbvO-i9loPvW6spKiHSWBVE-xRUuWYu8c,35490
|
|
7
7
|
ccode/logo.py,sha256=L8nkF8Zx4YlrFGnVONpEc7sWF2aAB3cyoQdtISF-SVM,5514
|
|
8
|
-
ccode/remote.py,sha256=
|
|
9
|
-
ccode/remote_cli.py,sha256=
|
|
10
|
-
ccode/remote_client.py,sha256=
|
|
11
|
-
ccode/remote_common.py,sha256=
|
|
12
|
-
ccode/remote_server.py,sha256=
|
|
13
|
-
ccode/ui.py,sha256=
|
|
8
|
+
ccode/remote.py,sha256=X7coJG7JQapFvDWMoOegno7TSbNkq4zuV5MtB4l4b_I,24971
|
|
9
|
+
ccode/remote_cli.py,sha256=nblydZh4XiksnZitLp_9RTmuJuGBVCYK_o1vzyA2PeM,8569
|
|
10
|
+
ccode/remote_client.py,sha256=5UjWK-bTns4jSCON7hTu-S3U5kf4X0ZLH4ql9in3z8Q,9597
|
|
11
|
+
ccode/remote_common.py,sha256=9eJeUJTaQQojTLcF7gz8X0dFM5nO3vM5q7bC-YSd8bE,8244
|
|
12
|
+
ccode/remote_server.py,sha256=CqKKiE55yAfGkYOcXIOqELROCkY5rtr-Z3GnyBq3taQ,13692
|
|
13
|
+
ccode/ui.py,sha256=eoCjHkUAVadsGSAisQ7_BI4DaPdjYnX9cAeh2zzkI58,38274
|
|
14
14
|
ccode/web/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
-
ccode/web/app.js,sha256=
|
|
16
|
-
ccode/web/index.html,sha256=
|
|
15
|
+
ccode/web/app.js,sha256=ErtM9fmAHvYp1QAks3kObqCkpghA0_nFNXSC2kbxvwo,15823
|
|
16
|
+
ccode/web/index.html,sha256=sBGKxXidj6X4e8LqcITDHsrefZadwZy9JIBLRW5aeoQ,2644
|
|
17
17
|
ccode/web/style.css,sha256=3RClaSS48-Ne2PQOwG5rhwbPwZKxyp-gG_OmKYRQAzo,4270
|
|
18
18
|
ccode/web/assets/addon-fit.js,sha256=uj6iVs4GIKCZKhl9bJuupkgj_JPY2gep42bKmUPBhSc,1521
|
|
19
19
|
ccode/web/assets/xterm-LICENSE,sha256=tWn2KdAPJiaoEA3yoXmCEFNWIeQhZN_UJqb-Wqx7DM0,1261
|
|
20
20
|
ccode/web/assets/xterm-addon-fit-LICENSE,sha256=4lbwEYivUn5NBtIdBvv3ha6cUNSzKL8Dy-C6fwqkIo8,1103
|
|
21
21
|
ccode/web/assets/xterm.css,sha256=hUp8D7cOixoIPBZ5ergnKZ-xh0T1rTTyJ7SDN-Myk8Y,7112
|
|
22
22
|
ccode/web/assets/xterm.js,sha256=FJA1ef9UZkzXL46GmeaWGmJywhhj7Bw7EYzcivXUqXI,488663
|
|
23
|
-
ccoding-0.2.
|
|
24
|
-
ccoding-0.2.
|
|
25
|
-
ccoding-0.2.
|
|
26
|
-
ccoding-0.2.
|
|
27
|
-
ccoding-0.2.
|
|
23
|
+
ccoding-0.2.4.dist-info/METADATA,sha256=l1dPEwJjue2Bv5sYIHhmRuCNCgc7DBL5y0fUtm4HH3k,19551
|
|
24
|
+
ccoding-0.2.4.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
25
|
+
ccoding-0.2.4.dist-info/entry_points.txt,sha256=uxtngaM0Eazhs4dCpcQvAIVdQBRLJ_6u-BpN3pYaGrQ,125
|
|
26
|
+
ccoding-0.2.4.dist-info/licenses/LICENSE,sha256=ghzeEUUCG5lu2DDgFe37bgLJk_0_Y6v-ulXwmYWjQ20,1065
|
|
27
|
+
ccoding-0.2.4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|