From 44f0ed5d04bb718d04526bf7ea72f358b8d985df Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Fri, 6 Mar 2026 23:53:58 +0800 Subject: [PATCH] fix: harden dashboard auth and terminal flows Tighten terminal websocket auth and proxy trust handling while making file-backed auth/RBAC writes atomic to reduce high-impact security and persistence risks. Co-Authored-By: Claude Opus 4.6 (1M context) --- dashboard/backend/auth.py | 100 +++++++++------ dashboard/backend/config.py | 78 +++++++----- dashboard/backend/main.py | 24 +--- dashboard/backend/rbac.py | 35 +++++- dashboard/backend/routers/auth.py | 41 ++++--- dashboard/backend/routers/files.py | 25 +++- dashboard/backend/routers/system.py | 13 +- dashboard/backend/routers/terminal.py | 82 ++++++++----- dashboard/frontend/src/routes/Terminal.svelte | 114 ++++++++++++++---- 9 files changed, 337 insertions(+), 175 deletions(-) diff --git a/dashboard/backend/auth.py b/dashboard/backend/auth.py index 7d0f3cf..89aa6b2 100644 --- a/dashboard/backend/auth.py +++ b/dashboard/backend/auth.py @@ -1,6 +1,8 @@ from datetime import datetime, timedelta, timezone from typing import Optional import time +import threading +import tempfile import jwt from passlib.context import CryptContext import pyotp @@ -78,6 +80,30 @@ import base64 import hashlib from cryptography.fernet import Fernet +_auth_lock = threading.RLock() + + +def _atomic_json_write(path: str, data: dict): + os.makedirs(os.path.dirname(path), exist_ok=True) + fd, temp_path = tempfile.mkstemp(dir=os.path.dirname(path), prefix=".tmp-auth-", suffix=".json") + try: + with os.fdopen(fd, "w") as f: + json.dump(data, f) + f.flush() + os.fsync(f.fileno()) + os.replace(temp_path, path) + finally: + if os.path.exists(temp_path): + os.unlink(temp_path) + + +def _update_auth_data(mutator): + with _auth_lock: + data = _load_auth_data() + result = mutator(data) + _atomic_json_write(AUTH_FILE, data) + return result + def _get_fernet(): from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives import hashes @@ -108,18 +134,17 @@ def _decrypt(ciphertext: str) -> str: return ciphertext # fallback for unencrypted legacy values def _load_auth_data(): - try: - if os.path.exists(AUTH_FILE): - with open(AUTH_FILE, "r") as f: - return json.load(f) - except Exception: - pass - return {} + with _auth_lock: + try: + if os.path.exists(AUTH_FILE): + with open(AUTH_FILE, "r") as f: + return json.load(f) + except Exception: + pass + return {} def _save_auth_data(data): - os.makedirs(os.path.dirname(AUTH_FILE), exist_ok=True) - with open(AUTH_FILE, "w") as f: - json.dump(data, f) + _atomic_json_write(AUTH_FILE, data) def load_totp_secret(): encrypted = _load_auth_data().get("totp_secret", "") @@ -128,17 +153,17 @@ def load_totp_secret(): return _decrypt(encrypted) def save_totp_secret(secret: str): - data = _load_auth_data() - data["totp_secret"] = _encrypt(secret) if secret else "" - _save_auth_data(data) + def mutator(data): + data["totp_secret"] = _encrypt(secret) if secret else "" + _update_auth_data(mutator) def load_password_hash(): return _load_auth_data().get("password_hash", "") or config.ADMIN_PASSWORD_HASH def save_password_hash(hashed: str): - data = _load_auth_data() - data["password_hash"] = hashed - _save_auth_data(data) + def mutator(data): + data["password_hash"] = hashed + _update_auth_data(mutator) def verify_totp(token: str, secret: str = None): if secret is None: @@ -148,41 +173,42 @@ def verify_totp(token: str, secret: str = None): totp = pyotp.TOTP(secret) if not totp.verify(token): return False - # Replay protection: reject reuse within same 30s window - current_ts = int(time.time()) // 30 - data = _load_auth_data() - if data.get("last_totp_ts") == current_ts: - return False - data["last_totp_ts"] = current_ts - _save_auth_data(data) - return True + + def mutator(data): + current_ts = int(time.time()) // 30 + if data.get("last_totp_ts") == current_ts: + return False + data["last_totp_ts"] = current_ts + return True + + return _update_auth_data(mutator) # Passkey credentials def load_passkey_credentials(): return _load_auth_data().get("passkey_credentials", []) def save_passkey_credential(cred): - data = _load_auth_data() - creds = data.get("passkey_credentials", []) - creds.append(cred) - data["passkey_credentials"] = creds - _save_auth_data(data) + def mutator(data): + creds = data.get("passkey_credentials", []) + creds.append(cred) + data["passkey_credentials"] = creds + _update_auth_data(mutator) def clear_passkey_credentials(): - data = _load_auth_data() - data["passkey_credentials"] = [] - _save_auth_data(data) + def mutator(data): + data["passkey_credentials"] = [] + _update_auth_data(mutator) # Token version for refresh token rotation def _load_token_version() -> int: return _load_auth_data().get("token_version", 0) def increment_token_version() -> int: - data = _load_auth_data() - v = data.get("token_version", 0) + 1 - data["token_version"] = v - _save_auth_data(data) - return v + def mutator(data): + v = data.get("token_version", 0) + 1 + data["token_version"] = v + return v + return _update_auth_data(mutator) def verify_token_version(tv: int) -> bool: return tv == _load_token_version() diff --git a/dashboard/backend/config.py b/dashboard/backend/config.py index 28a2e61..4c76cdd 100644 --- a/dashboard/backend/config.py +++ b/dashboard/backend/config.py @@ -1,4 +1,6 @@ import os +import ipaddress +from fastapi import Request # Dashboard v1.3 — Runner DNS fix applied GITEA_URL = os.environ.get("GITEA_URL", "http://gitea:3000") @@ -63,43 +65,59 @@ OPENCLAW_GATEWAY_TOKEN = os.environ.get("OPENCLAW_GATEWAY_TOKEN", "") # Networking configs LAN_IP_RANGES = os.environ.get("LAN_IP_RANGES", "192.168.31.0/24").split(",") +TRUSTED_PROXIES = os.environ.get("TRUSTED_PROXIES", "127.0.0.1,::1,172.21.0.1").split(",") + + +def _parse_ip(ip_str: str): + try: + return ipaddress.ip_address((ip_str or "").strip()) + except ValueError: + return None + + +def _ip_matches_ranges(ip_str: str, ranges: list[str]) -> bool: + ip_obj = _parse_ip(ip_str) + if ip_obj is None: + return False + + for subnet_str in ranges: + subnet_str = subnet_str.strip() + if not subnet_str: + continue + try: + if "/" in subnet_str: + if ip_obj in ipaddress.ip_network(subnet_str, strict=False): + return True + elif ip_obj == ipaddress.ip_address(subnet_str): + return True + except ValueError: + continue + return False + def is_lan_ip(ip_str: str) -> bool: """Check if IP is in local LAN (not Tailscale)""" - import ipaddress - try: - ip_obj = ipaddress.ip_address(ip_str) - for subnet_str in LAN_IP_RANGES: - if ip_obj in ipaddress.ip_network(subnet_str.strip()): - return True - return False - except ValueError: - return False + return _ip_matches_ranges(ip_str, LAN_IP_RANGES) + def is_tailscale_ip(ip_str: str) -> bool: """Check if IP is in Tailscale CGNAT range (100.64.0.0/10)""" - import ipaddress - try: - ip_obj = ipaddress.ip_address(ip_str) - return ip_obj in ipaddress.ip_network("100.64.0.0/10") - except ValueError: - return False + return _ip_matches_ranges(ip_str, ["100.64.0.0/10"]) -TRUSTED_PROXIES = os.environ.get("TRUSTED_PROXIES", "127.0.0.1,::1,172.16.0.0/12").split(",") def is_trusted_proxy(ip_str: str) -> bool: - import ipaddress - try: - ip_obj = ipaddress.ip_address(ip_str) - for subnet_str in TRUSTED_PROXIES: - subnet_str = subnet_str.strip() - if "/" in subnet_str: - if ip_obj in ipaddress.ip_network(subnet_str): - return True - else: - if ip_obj == ipaddress.ip_address(subnet_str): - return True - return False - except ValueError: - return False + return _ip_matches_ranges(ip_str, TRUSTED_PROXIES) + +def get_forwarded_client_ip(request: Request) -> str | None: + source_ip = request.client.host if request.client else "" + if not is_trusted_proxy(source_ip): + return None + + forwarded_for = request.headers.get("x-forwarded-for", "") + forwarded_ip = forwarded_for.split(",", 1)[0].strip() + return forwarded_ip or None + + +def get_client_ip(request: Request) -> str: + return get_forwarded_client_ip(request) or (request.client.host if request.client else "") diff --git a/dashboard/backend/main.py b/dashboard/backend/main.py index 7048a63..2a64a76 100644 --- a/dashboard/backend/main.py +++ b/dashboard/backend/main.py @@ -8,6 +8,7 @@ from slowapi.util import get_remote_address from slowapi.errors import RateLimitExceeded from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security, passkey, totp, litellm, cc_connect, info_engine import auth as auth_module +import config from rbac import require_page, require_write import asyncio @@ -16,7 +17,7 @@ import logging import time import jwt from datetime import datetime, timezone, timedelta -from config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, CORS_ORIGINS, SECRET_KEY, ALGORITHM, VOLUME_ROOT +from config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, CORS_ORIGINS, SECRET_KEY, ALGORITHM, VOLUME_ROOT, get_client_ip _tz_cst = timezone(timedelta(hours=8)) @@ -70,19 +71,11 @@ async def audit_log(request: Request, call_next): start = time.time() response = await call_next(request) duration = int((time.time() - start) * 1000) - user = "-" - try: - auth_header = request.headers.get("authorization", "") - if auth_header.startswith("Bearer "): - payload = jwt.decode(auth_header[7:], SECRET_KEY, algorithms=[ALGORITHM]) - user = payload.get("sub", "-") - except jwt.PyJWTError: - user = "Invalid/Malformed Token" - except Exception: - pass + user = getattr(request.state, "user", None) + username = user.username if user else "-" _audit_logger.info( "%s %s %s %s %s %d %dms", - datetime.now(_tz_cst).strftime("%Y-%m-%dT%H:%M:%S"), request.client.host, user, + datetime.now(_tz_cst).strftime("%Y-%m-%dT%H:%M:%S"), get_client_ip(request), username, request.method, request.url.path, response.status_code, duration, ) return response @@ -128,12 +121,7 @@ async def health(): @app.get("/api/client-ip") async def client_ip(request: Request): - ip = request.client.host - import config - if config.is_trusted_proxy(ip): - forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip() - if forwarded: - ip = forwarded + ip = get_client_ip(request) # LAN detection logic: # - True LAN IP (192.168.31.x) → lan=True diff --git a/dashboard/backend/rbac.py b/dashboard/backend/rbac.py index b4d60f5..4c12fa3 100644 --- a/dashboard/backend/rbac.py +++ b/dashboard/backend/rbac.py @@ -1,5 +1,7 @@ import json import os +import threading +import tempfile from typing import Optional from pydantic import BaseModel from fastapi import HTTPException, Request, status @@ -7,6 +9,7 @@ from fastapi import HTTPException, Request, status import config RBAC_FILE = os.path.join(config.VOLUME_ROOT, "docker/nas-dashboard/rbac.json") +_rbac_lock = threading.RLock() ROLE_GROUP_MAP = { "dashboard_admin": "admin", @@ -41,10 +44,30 @@ def load_rbac() -> dict: return DEFAULT_RBAC.copy() +def _atomic_json_write(path: str, data: dict): + os.makedirs(os.path.dirname(path), exist_ok=True) + fd, temp_path = tempfile.mkstemp(dir=os.path.dirname(path), prefix=".tmp-rbac-", suffix=".json") + try: + with os.fdopen(fd, "w") as f: + json.dump(data, f, indent=2) + f.flush() + os.fsync(f.fileno()) + os.replace(temp_path, path) + finally: + if os.path.exists(temp_path): + os.unlink(temp_path) + + +def update_rbac(mutator): + with _rbac_lock: + data = load_rbac() + result = mutator(data) + _atomic_json_write(RBAC_FILE, data) + return result + + def save_rbac(data: dict): - os.makedirs(os.path.dirname(RBAC_FILE), exist_ok=True) - with open(RBAC_FILE, "w") as f: - json.dump(data, f, indent=2) + _atomic_json_write(RBAC_FILE, data) def resolve_role(groups: list[str]) -> Optional[str]: @@ -77,9 +100,9 @@ def get_sidebar_order(username: str) -> dict | None: def save_sidebar_order(username: str, order: dict): - rbac = load_rbac() - rbac.setdefault("user_overrides", {}).setdefault(username, {})["sidebar_order"] = order - save_rbac(rbac) + def mutator(rbac): + rbac.setdefault("user_overrides", {}).setdefault(username, {})["sidebar_order"] = order + update_rbac(mutator) def has_page_access(user: User, page: str) -> bool: diff --git a/dashboard/backend/routers/auth.py b/dashboard/backend/routers/auth.py index 52615b3..72eebde 100644 --- a/dashboard/backend/routers/auth.py +++ b/dashboard/backend/routers/auth.py @@ -102,7 +102,6 @@ async def login(creds: LoginRequest, request: Request): async def proxy_auth(request: Request): """Auto-login when Authelia has already authenticated the user via forward-auth. Caddy sets Remote-User and Remote-Groups headers after successful Authelia verification.""" - import config from rbac import resolve_role if not config.is_trusted_proxy(request.client.host): logger.warning(f"Rejected proxy auth attempt from unauthorized IP: {request.client.host}") @@ -195,16 +194,18 @@ async def rbac_config(current_user = Depends(auth.get_current_user)): async def rbac_set_override(username: str, request: Request, current_user = Depends(auth.get_current_user)): if current_user.role != "admin": raise HTTPException(status_code=403, detail="Admin only") - from rbac import load_rbac, save_rbac + from rbac import update_rbac body = await request.json() pages = body.get("pages") if pages is None: raise HTTPException(status_code=400, detail="Missing pages field") - rbac = load_rbac() - existing = rbac.setdefault("user_overrides", {}).get(username, {}) - existing["pages"] = pages - rbac["user_overrides"][username] = existing - save_rbac(rbac) + + def mutator(rbac): + existing = rbac.setdefault("user_overrides", {}).get(username, {}) + existing["pages"] = pages + rbac["user_overrides"][username] = existing + + update_rbac(mutator) return {"ok": True} @router.get("/preferences") @@ -235,17 +236,19 @@ async def save_preferences(request: Request, current_user = Depends(auth.get_cur async def rbac_delete_override(username: str, current_user = Depends(auth.get_current_user)): if current_user.role != "admin": raise HTTPException(status_code=403, detail="Admin only") - from rbac import load_rbac, save_rbac - rbac = load_rbac() - rbac.get("user_overrides", {}).pop(username, None) - save_rbac(rbac) + from rbac import update_rbac + + def mutator(rbac): + rbac.get("user_overrides", {}).pop(username, None) + + update_rbac(mutator) return {"ok": True} @router.put("/rbac/roles/{role}") async def rbac_update_role(role: str, request: Request, current_user = Depends(auth.get_current_user)): if current_user.role != "admin": raise HTTPException(status_code=403, detail="Admin only") - from rbac import load_rbac, save_rbac + from rbac import update_rbac body = await request.json() pages = body.get("pages") sidebar_links = body.get("sidebar_links") @@ -255,10 +258,12 @@ async def rbac_update_role(role: str, request: Request, current_user = Depends(a raise HTTPException(status_code=400, detail="pages must be '*' or a list") if sidebar_links is not None and sidebar_links != "*" and not isinstance(sidebar_links, list): raise HTTPException(status_code=400, detail="sidebar_links must be '*' or a list") - rbac = load_rbac() - role_config = {"pages": pages} - if sidebar_links is not None: - role_config["sidebar_links"] = sidebar_links - rbac.setdefault("role_defaults", {})[role] = role_config - save_rbac(rbac) + + def mutator(rbac): + role_config = {"pages": pages} + if sidebar_links is not None: + role_config["sidebar_links"] = sidebar_links + rbac.setdefault("role_defaults", {})[role] = role_config + + update_rbac(mutator) return {"ok": True} diff --git a/dashboard/backend/routers/files.py b/dashboard/backend/routers/files.py index 8b8e9bd..fb57a45 100644 --- a/dashboard/backend/routers/files.py +++ b/dashboard/backend/routers/files.py @@ -100,13 +100,28 @@ async def upload(path: str, file: UploadFile = File(...)): @router.delete("/delete") -def delete(path: str): +def delete(path: str, recursive: bool = False): try: target = _safe_path(path) except ValueError: raise HTTPException(status_code=403, detail="Access denied") - if target.is_dir(): - shutil.rmtree(target) - else: - target.unlink() + + if target == BASE.resolve(): + raise HTTPException(status_code=403, detail="Cannot delete base root") + + try: + if target.is_dir(): + if not recursive: + raise HTTPException(status_code=400, detail="Directory deletion requires recursive=true") + shutil.rmtree(target) + else: + target.unlink() + except HTTPException: + raise + except FileNotFoundError: + raise HTTPException(status_code=404, detail="Path not found") + except PermissionError: + raise HTTPException(status_code=403, detail="Permission denied") + except OSError as exc: + raise HTTPException(status_code=400, detail=str(exc)) return {"ok": True} diff --git a/dashboard/backend/routers/system.py b/dashboard/backend/routers/system.py index fc6c121..dc55482 100644 --- a/dashboard/backend/routers/system.py +++ b/dashboard/backend/routers/system.py @@ -5,6 +5,7 @@ import platform import time import httpx from fastapi import APIRouter, Query, Request, HTTPException +import config from config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, VOLUME_ROOT, OPENCLAW_GATEWAY_TOKEN router = APIRouter() @@ -116,13 +117,11 @@ def _classify(method, path, status, user): @router.get("/openclaw-token") def openclaw_token(request: Request): - ip = request.client.host - import config - if config.is_trusted_proxy(ip): - forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip() - if forwarded: - ip = forwarded - import config + user = request.state.user + if user.role != "admin": + raise HTTPException(status_code=403, detail="Admin only") + + ip = config.get_client_ip(request) if not config.is_lan_ip(ip): raise HTTPException(status_code=403, detail="Access denied") return {"token": OPENCLAW_GATEWAY_TOKEN} diff --git a/dashboard/backend/routers/terminal.py b/dashboard/backend/routers/terminal.py index 784b2ae..d575237 100644 --- a/dashboard/backend/routers/terminal.py +++ b/dashboard/backend/routers/terminal.py @@ -1,16 +1,18 @@ import asyncio import struct import logging -import time -from fastapi import WebSocket, WebSocketDisconnect, Query +from collections import Counter +from fastapi import WebSocket, Query import asyncssh -import jwt import config +from auth import get_current_user_ws logger = logging.getLogger(__name__) MAX_SESSIONS = 5 -_active_sessions: set = set() +MAX_SESSIONS_PER_USER = 2 +_session_lock = asyncio.Lock() +_active_sessions: dict[int, str] = {} HOSTS = { "nas": {"host": config.SSH_HOST, "user": config.SSH_USER, "key": config.SSH_KEY_PATH}, @@ -31,43 +33,57 @@ except Exception: logger.error("SSH known_hosts file not found at %s — terminal connections will be refused", config.SSH_KNOWN_HOSTS) -async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")): - await websocket.accept() +async def _reserve_session(session_id: int, username: str) -> tuple[bool, str | None, int | None]: + async with _session_lock: + if len(_active_sessions) >= MAX_SESSIONS: + return False, "Too many sessions", 1013 + user_sessions = Counter(_active_sessions.values()) + if user_sessions[username] >= MAX_SESSIONS_PER_USER: + return False, "Too many sessions for user", 1013 + _active_sessions[session_id] = username + return True, None, session_id + + +async def _release_session(session_id: int | None): + if session_id is None: + return + async with _session_lock: + _active_sessions.pop(session_id, None) + + +async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str | None = Query(None)): + if host not in HOSTS: + await websocket.close(code=1008, reason="Unknown host") + return + + if not token: + await websocket.close(code=1008, reason="Unauthorized") + return - # Auth: expect token as first text message try: - first_msg = await asyncio.wait_for(websocket.receive_text(), timeout=5) - payload = jwt.decode(first_msg, config.SECRET_KEY, algorithms=[config.ALGORITHM]) - if payload.get("type") != "access": - await websocket.close(code=1008, reason="Unauthorized") - return - username = payload.get("sub") - role = payload.get("role", "admin") - if username is None: - await websocket.close(code=1008, reason="Unauthorized") - return - # RBAC: check terminal page access - from rbac import get_pages - pages = get_pages(username, role) - if pages != "*" and "terminal" not in pages: - await websocket.close(code=1008, reason="Access denied") - return + user = await get_current_user_ws(token) except Exception: await websocket.close(code=1008, reason="Unauthorized") return + if user.pages != "*" and "terminal" not in user.pages: + await websocket.close(code=1008, reason="Access denied") + return + if not _known_hosts_available: await websocket.close(code=1011, reason="SSH host verification unavailable") return - if len(_active_sessions) >= MAX_SESSIONS: - await websocket.close(code=1013, reason="Too many sessions") - return - session_id = id(websocket) - _active_sessions.add(session_id) + reserved, reason, session_or_code = await _reserve_session(session_id, user.username) + if not reserved: + await websocket.close(code=session_or_code, reason=reason) + return + session_id = session_or_code - h = HOSTS.get(host, HOSTS["nas"]) + await websocket.accept() + + h = HOSTS[host] try: conn = await asyncssh.connect( h["host"], username=h["user"], @@ -76,7 +92,7 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")): keepalive_count_max=3, ) except Exception: - _active_sessions.discard(session_id) + await _release_session(session_id) await websocket.close(code=1011, reason="SSH connection failed") return @@ -93,7 +109,7 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")): if not data: break await websocket.send_bytes(data) - except (asyncssh.Error, WebSocketDisconnect, asyncio.CancelledError): + except (asyncssh.Error, asyncio.CancelledError): pass read_task = asyncio.create_task(read_ssh()) @@ -113,11 +129,11 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")): if msg["text"] == "__ping__": continue process.stdin.write(msg["text"].encode()) - except WebSocketDisconnect: + except Exception: pass finally: read_task.cancel() process.close() finally: conn.close() - _active_sessions.discard(session_id) + await _release_session(session_id) diff --git a/dashboard/frontend/src/routes/Terminal.svelte b/dashboard/frontend/src/routes/Terminal.svelte index 883463b..51c295c 100644 --- a/dashboard/frontend/src/routes/Terminal.svelte +++ b/dashboard/frontend/src/routes/Terminal.svelte @@ -120,6 +120,8 @@ function closeTab(id) { const d = tabData.get(id); if (d) { + d.closedManually = true; + if (d.reconnectTimer) clearTimeout(d.reconnectTimer); d.voice?.destroy(); d.ro?.disconnect(); if (d.heartbeat) clearInterval(d.heartbeat); @@ -157,11 +159,30 @@ const proto = location.protocol === "https:" ? "wss:" : "ws:"; const token = localStorage.getItem("token") || ""; - const ws = new WebSocket( - `${proto}//${location.host}/ws/terminal?host=${tab.hostId}` - ); - ws.binaryType = "arraybuffer"; - function sendSize() { + let heartbeat = null; + let reconnectTimer = null; + let closedManually = false; + + function clearHeartbeat() { + if (heartbeat) { + clearInterval(heartbeat); + heartbeat = null; + } + } + + function scheduleReconnect(reason) { + clearHeartbeat(); + if (closedManually || reconnectTimer) return; + updateTab(tabId, { connected: false, error: `${reason} — reconnecting...` }); + term.write(`\r\n\x1b[90m[${reason} — reconnecting...]\x1b[0m`); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + if (!tabData.has(tabId) || closedManually) return; + connect(); + }, 2000); + } + + function sendSize(ws) { if (ws.readyState === 1) { const buf = new Uint8Array(5); buf[0] = 0x01; @@ -171,19 +192,62 @@ } } - ws.onopen = () => { ws.send(token); updateTab(tabId, { connected: true, error: "" }); sendSize(); }; - ws.onmessage = (e) => term.write(new Uint8Array(e.data)); - ws.onclose = (e) => { - const reason = e.reason || `Connection closed (code ${e.code})`; - updateTab(tabId, { connected: false, error: reason }); - term.write(`\r\n\x1b[90m[${reason}]\x1b[0m`); - }; - ws.onerror = () => updateTab(tabId, { connected: false, error: "Connection failed" }); - term.onData((data) => ws.readyState === 1 && ws.send(new TextEncoder().encode(data))); + function connect() { + const ws = new WebSocket( + `${proto}//${location.host}/ws/terminal?host=${encodeURIComponent(tab.hostId)}&token=${encodeURIComponent(token)}` + ); + ws.binaryType = "arraybuffer"; - const heartbeat = setInterval(() => { - if (ws.readyState === 1) ws.send("__ping__"); - }, 25000); + ws.onopen = () => { + updateTab(tabId, { connected: true, error: "" }); + sendSize(ws); + clearHeartbeat(); + heartbeat = setInterval(() => { + if (ws.readyState === 1) ws.send("__ping__"); + }, 25000); + const d = tabData.get(tabId); + if (d) { + d.ws = ws; + d.heartbeat = heartbeat; + d.reconnectTimer = reconnectTimer; + d.closedManually = closedManually; + d.voice?.attach(term, ws); + } + }; + ws.onmessage = (e) => term.write(new Uint8Array(e.data)); + ws.onclose = (e) => { + const reason = e.reason || `Connection closed (code ${e.code})`; + const d = tabData.get(tabId); + if (d) d.ws = null; + if (!closedManually) scheduleReconnect(reason); + else { + clearHeartbeat(); + updateTab(tabId, { connected: false, error: reason }); + term.write(`\r\n\x1b[90m[${reason}]\x1b[0m`); + } + }; + ws.onerror = () => { + const d = tabData.get(tabId); + if (d) d.ws = null; + if (!closedManually) scheduleReconnect("Connection failed"); + else updateTab(tabId, { connected: false, error: "Connection failed" }); + }; + + const d = tabData.get(tabId); + if (d) { + d.ws = ws; + d.heartbeat = heartbeat; + d.reconnectTimer = reconnectTimer; + d.closedManually = closedManually; + } + return ws; + } + + let ws = connect(); + term.onData((data) => { + const currentWs = tabData.get(tabId)?.ws; + if (currentWs?.readyState === 1) currentWs.send(new TextEncoder().encode(data)); + }); const handleDragOver = (e) => { e.preventDefault(); @@ -192,19 +256,21 @@ const handleDrop = async (e) => { e.preventDefault(); if (activeTab !== tabId) return; + const currentWs = tabData.get(tabId)?.ws; const files = [...(e.dataTransfer?.files || [])].filter((f) => f.type?.startsWith("image/")); for (const file of files) { - await sendDroppedImage(term, ws, file); + await sendDroppedImage(term, currentWs, file); } }; const handlePaste = async (e) => { if (activeTab !== tabId) return; + const currentWs = tabData.get(tabId)?.ws; const items = [...(e.clipboardData?.items || [])] .filter((it) => it.kind === "file" && it.type?.startsWith("image/")); for (const item of items) { const file = item.getAsFile(); - if (file) await sendDroppedImage(term, ws, file); + if (file) await sendDroppedImage(term, currentWs, file); } }; @@ -212,11 +278,15 @@ term.element?.addEventListener("drop", handleDrop); term.textarea?.addEventListener("paste", handlePaste); - const ro = new ResizeObserver(() => { fit.fit(); sendSize(); }); + const ro = new ResizeObserver(() => { + fit.fit(); + const currentWs = tabData.get(tabId)?.ws; + if (currentWs) sendSize(currentWs); + }); ro.observe(el); const voice = new VoiceSession(() => { voiceTick++; }); voice.attach(term, ws); - tabData.set(tabId, { ws, term, ro, el, voice, heartbeat, handleDragOver, handleDrop, handlePaste }); + tabData.set(tabId, { ws, term, ro, el, voice, heartbeat, reconnectTimer, closedManually, handleDragOver, handleDrop, handlePaste }); })(); } @@ -225,6 +295,8 @@ onDestroy(() => { themeObserver.disconnect(); tabData.forEach((d) => { + d.closedManually = true; + if (d.reconnectTimer) clearTimeout(d.reconnectTimer); d.voice?.destroy(); d.ro?.disconnect(); if (d.heartbeat) clearInterval(d.heartbeat); -- 2.52.0