fix: harden dashboard auth and terminal flows #26

Merged
jimmy merged 1 commits from dev into main 2026-03-07 09:52:54 +08:00
9 changed files with 337 additions and 175 deletions
+63 -37
View File
@@ -1,6 +1,8 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
import time import time
import threading
import tempfile
import jwt import jwt
from passlib.context import CryptContext from passlib.context import CryptContext
import pyotp import pyotp
@@ -78,6 +80,30 @@ import base64
import hashlib import hashlib
from cryptography.fernet import Fernet 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(): def _get_fernet():
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives import hashes
@@ -108,18 +134,17 @@ def _decrypt(ciphertext: str) -> str:
return ciphertext # fallback for unencrypted legacy values return ciphertext # fallback for unencrypted legacy values
def _load_auth_data(): def _load_auth_data():
try: with _auth_lock:
if os.path.exists(AUTH_FILE): try:
with open(AUTH_FILE, "r") as f: if os.path.exists(AUTH_FILE):
return json.load(f) with open(AUTH_FILE, "r") as f:
except Exception: return json.load(f)
pass except Exception:
return {} pass
return {}
def _save_auth_data(data): def _save_auth_data(data):
os.makedirs(os.path.dirname(AUTH_FILE), exist_ok=True) _atomic_json_write(AUTH_FILE, data)
with open(AUTH_FILE, "w") as f:
json.dump(data, f)
def load_totp_secret(): def load_totp_secret():
encrypted = _load_auth_data().get("totp_secret", "") encrypted = _load_auth_data().get("totp_secret", "")
@@ -128,17 +153,17 @@ def load_totp_secret():
return _decrypt(encrypted) return _decrypt(encrypted)
def save_totp_secret(secret: str): def save_totp_secret(secret: str):
data = _load_auth_data() def mutator(data):
data["totp_secret"] = _encrypt(secret) if secret else "" data["totp_secret"] = _encrypt(secret) if secret else ""
_save_auth_data(data) _update_auth_data(mutator)
def load_password_hash(): def load_password_hash():
return _load_auth_data().get("password_hash", "") or config.ADMIN_PASSWORD_HASH return _load_auth_data().get("password_hash", "") or config.ADMIN_PASSWORD_HASH
def save_password_hash(hashed: str): def save_password_hash(hashed: str):
data = _load_auth_data() def mutator(data):
data["password_hash"] = hashed data["password_hash"] = hashed
_save_auth_data(data) _update_auth_data(mutator)
def verify_totp(token: str, secret: str = None): def verify_totp(token: str, secret: str = None):
if secret is None: if secret is None:
@@ -148,41 +173,42 @@ def verify_totp(token: str, secret: str = None):
totp = pyotp.TOTP(secret) totp = pyotp.TOTP(secret)
if not totp.verify(token): if not totp.verify(token):
return False return False
# Replay protection: reject reuse within same 30s window
current_ts = int(time.time()) // 30 def mutator(data):
data = _load_auth_data() current_ts = int(time.time()) // 30
if data.get("last_totp_ts") == current_ts: if data.get("last_totp_ts") == current_ts:
return False return False
data["last_totp_ts"] = current_ts data["last_totp_ts"] = current_ts
_save_auth_data(data) return True
return True
return _update_auth_data(mutator)
# Passkey credentials # Passkey credentials
def load_passkey_credentials(): def load_passkey_credentials():
return _load_auth_data().get("passkey_credentials", []) return _load_auth_data().get("passkey_credentials", [])
def save_passkey_credential(cred): def save_passkey_credential(cred):
data = _load_auth_data() def mutator(data):
creds = data.get("passkey_credentials", []) creds = data.get("passkey_credentials", [])
creds.append(cred) creds.append(cred)
data["passkey_credentials"] = creds data["passkey_credentials"] = creds
_save_auth_data(data) _update_auth_data(mutator)
def clear_passkey_credentials(): def clear_passkey_credentials():
data = _load_auth_data() def mutator(data):
data["passkey_credentials"] = [] data["passkey_credentials"] = []
_save_auth_data(data) _update_auth_data(mutator)
# Token version for refresh token rotation # Token version for refresh token rotation
def _load_token_version() -> int: def _load_token_version() -> int:
return _load_auth_data().get("token_version", 0) return _load_auth_data().get("token_version", 0)
def increment_token_version() -> int: def increment_token_version() -> int:
data = _load_auth_data() def mutator(data):
v = data.get("token_version", 0) + 1 v = data.get("token_version", 0) + 1
data["token_version"] = v data["token_version"] = v
_save_auth_data(data) return v
return v return _update_auth_data(mutator)
def verify_token_version(tv: int) -> bool: def verify_token_version(tv: int) -> bool:
return tv == _load_token_version() return tv == _load_token_version()
+48 -30
View File
@@ -1,4 +1,6 @@
import os import os
import ipaddress
from fastapi import Request
# Dashboard v1.3 — Runner DNS fix applied # Dashboard v1.3 — Runner DNS fix applied
GITEA_URL = os.environ.get("GITEA_URL", "http://gitea:3000") 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 # Networking configs
LAN_IP_RANGES = os.environ.get("LAN_IP_RANGES", "192.168.31.0/24").split(",") 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: def is_lan_ip(ip_str: str) -> bool:
"""Check if IP is in local LAN (not Tailscale)""" """Check if IP is in local LAN (not Tailscale)"""
import ipaddress return _ip_matches_ranges(ip_str, LAN_IP_RANGES)
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
def is_tailscale_ip(ip_str: str) -> bool: def is_tailscale_ip(ip_str: str) -> bool:
"""Check if IP is in Tailscale CGNAT range (100.64.0.0/10)""" """Check if IP is in Tailscale CGNAT range (100.64.0.0/10)"""
import ipaddress return _ip_matches_ranges(ip_str, ["100.64.0.0/10"])
try:
ip_obj = ipaddress.ip_address(ip_str)
return ip_obj in ipaddress.ip_network("100.64.0.0/10")
except ValueError:
return False
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: def is_trusted_proxy(ip_str: str) -> bool:
import ipaddress return _ip_matches_ranges(ip_str, TRUSTED_PROXIES)
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
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 "")
+6 -18
View File
@@ -8,6 +8,7 @@ from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded 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 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 auth as auth_module
import config
from rbac import require_page, require_write from rbac import require_page, require_write
import asyncio import asyncio
@@ -16,7 +17,7 @@ import logging
import time import time
import jwt import jwt
from datetime import datetime, timezone, timedelta 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)) _tz_cst = timezone(timedelta(hours=8))
@@ -70,19 +71,11 @@ async def audit_log(request: Request, call_next):
start = time.time() start = time.time()
response = await call_next(request) response = await call_next(request)
duration = int((time.time() - start) * 1000) duration = int((time.time() - start) * 1000)
user = "-" user = getattr(request.state, "user", None)
try: username = user.username if user else "-"
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
_audit_logger.info( _audit_logger.info(
"%s %s %s %s %s %d %dms", "%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, request.method, request.url.path, response.status_code, duration,
) )
return response return response
@@ -128,12 +121,7 @@ async def health():
@app.get("/api/client-ip") @app.get("/api/client-ip")
async def client_ip(request: Request): async def client_ip(request: Request):
ip = request.client.host ip = get_client_ip(request)
import config
if config.is_trusted_proxy(ip):
forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
if forwarded:
ip = forwarded
# LAN detection logic: # LAN detection logic:
# - True LAN IP (192.168.31.x) → lan=True # - True LAN IP (192.168.31.x) → lan=True
+29 -6
View File
@@ -1,5 +1,7 @@
import json import json
import os import os
import threading
import tempfile
from typing import Optional from typing import Optional
from pydantic import BaseModel from pydantic import BaseModel
from fastapi import HTTPException, Request, status from fastapi import HTTPException, Request, status
@@ -7,6 +9,7 @@ from fastapi import HTTPException, Request, status
import config import config
RBAC_FILE = os.path.join(config.VOLUME_ROOT, "docker/nas-dashboard/rbac.json") RBAC_FILE = os.path.join(config.VOLUME_ROOT, "docker/nas-dashboard/rbac.json")
_rbac_lock = threading.RLock()
ROLE_GROUP_MAP = { ROLE_GROUP_MAP = {
"dashboard_admin": "admin", "dashboard_admin": "admin",
@@ -41,10 +44,30 @@ def load_rbac() -> dict:
return DEFAULT_RBAC.copy() 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): def save_rbac(data: dict):
os.makedirs(os.path.dirname(RBAC_FILE), exist_ok=True) _atomic_json_write(RBAC_FILE, data)
with open(RBAC_FILE, "w") as f:
json.dump(data, f, indent=2)
def resolve_role(groups: list[str]) -> Optional[str]: 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): def save_sidebar_order(username: str, order: dict):
rbac = load_rbac() def mutator(rbac):
rbac.setdefault("user_overrides", {}).setdefault(username, {})["sidebar_order"] = order rbac.setdefault("user_overrides", {}).setdefault(username, {})["sidebar_order"] = order
save_rbac(rbac) update_rbac(mutator)
def has_page_access(user: User, page: str) -> bool: def has_page_access(user: User, page: str) -> bool:
+23 -18
View File
@@ -102,7 +102,6 @@ async def login(creds: LoginRequest, request: Request):
async def proxy_auth(request: Request): async def proxy_auth(request: Request):
"""Auto-login when Authelia has already authenticated the user via forward-auth. """Auto-login when Authelia has already authenticated the user via forward-auth.
Caddy sets Remote-User and Remote-Groups headers after successful Authelia verification.""" Caddy sets Remote-User and Remote-Groups headers after successful Authelia verification."""
import config
from rbac import resolve_role from rbac import resolve_role
if not config.is_trusted_proxy(request.client.host): if not config.is_trusted_proxy(request.client.host):
logger.warning(f"Rejected proxy auth attempt from unauthorized IP: {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)): async def rbac_set_override(username: str, request: Request, current_user = Depends(auth.get_current_user)):
if current_user.role != "admin": if current_user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only") raise HTTPException(status_code=403, detail="Admin only")
from rbac import load_rbac, save_rbac from rbac import update_rbac
body = await request.json() body = await request.json()
pages = body.get("pages") pages = body.get("pages")
if pages is None: if pages is None:
raise HTTPException(status_code=400, detail="Missing pages field") raise HTTPException(status_code=400, detail="Missing pages field")
rbac = load_rbac()
existing = rbac.setdefault("user_overrides", {}).get(username, {}) def mutator(rbac):
existing["pages"] = pages existing = rbac.setdefault("user_overrides", {}).get(username, {})
rbac["user_overrides"][username] = existing existing["pages"] = pages
save_rbac(rbac) rbac["user_overrides"][username] = existing
update_rbac(mutator)
return {"ok": True} return {"ok": True}
@router.get("/preferences") @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)): async def rbac_delete_override(username: str, current_user = Depends(auth.get_current_user)):
if current_user.role != "admin": if current_user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only") raise HTTPException(status_code=403, detail="Admin only")
from rbac import load_rbac, save_rbac from rbac import update_rbac
rbac = load_rbac()
rbac.get("user_overrides", {}).pop(username, None) def mutator(rbac):
save_rbac(rbac) rbac.get("user_overrides", {}).pop(username, None)
update_rbac(mutator)
return {"ok": True} return {"ok": True}
@router.put("/rbac/roles/{role}") @router.put("/rbac/roles/{role}")
async def rbac_update_role(role: str, request: Request, current_user = Depends(auth.get_current_user)): async def rbac_update_role(role: str, request: Request, current_user = Depends(auth.get_current_user)):
if current_user.role != "admin": if current_user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only") raise HTTPException(status_code=403, detail="Admin only")
from rbac import load_rbac, save_rbac from rbac import update_rbac
body = await request.json() body = await request.json()
pages = body.get("pages") pages = body.get("pages")
sidebar_links = body.get("sidebar_links") 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") 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): 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") raise HTTPException(status_code=400, detail="sidebar_links must be '*' or a list")
rbac = load_rbac()
role_config = {"pages": pages} def mutator(rbac):
if sidebar_links is not None: role_config = {"pages": pages}
role_config["sidebar_links"] = sidebar_links if sidebar_links is not None:
rbac.setdefault("role_defaults", {})[role] = role_config role_config["sidebar_links"] = sidebar_links
save_rbac(rbac) rbac.setdefault("role_defaults", {})[role] = role_config
update_rbac(mutator)
return {"ok": True} return {"ok": True}
+20 -5
View File
@@ -100,13 +100,28 @@ async def upload(path: str, file: UploadFile = File(...)):
@router.delete("/delete") @router.delete("/delete")
def delete(path: str): def delete(path: str, recursive: bool = False):
try: try:
target = _safe_path(path) target = _safe_path(path)
except ValueError: except ValueError:
raise HTTPException(status_code=403, detail="Access denied") raise HTTPException(status_code=403, detail="Access denied")
if target.is_dir():
shutil.rmtree(target) if target == BASE.resolve():
else: raise HTTPException(status_code=403, detail="Cannot delete base root")
target.unlink()
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} return {"ok": True}
+6 -7
View File
@@ -5,6 +5,7 @@ import platform
import time import time
import httpx import httpx
from fastapi import APIRouter, Query, Request, HTTPException from fastapi import APIRouter, Query, Request, HTTPException
import config
from config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, VOLUME_ROOT, OPENCLAW_GATEWAY_TOKEN from config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, VOLUME_ROOT, OPENCLAW_GATEWAY_TOKEN
router = APIRouter() router = APIRouter()
@@ -116,13 +117,11 @@ def _classify(method, path, status, user):
@router.get("/openclaw-token") @router.get("/openclaw-token")
def openclaw_token(request: Request): def openclaw_token(request: Request):
ip = request.client.host user = request.state.user
import config if user.role != "admin":
if config.is_trusted_proxy(ip): raise HTTPException(status_code=403, detail="Admin only")
forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
if forwarded: ip = config.get_client_ip(request)
ip = forwarded
import config
if not config.is_lan_ip(ip): if not config.is_lan_ip(ip):
raise HTTPException(status_code=403, detail="Access denied") raise HTTPException(status_code=403, detail="Access denied")
return {"token": OPENCLAW_GATEWAY_TOKEN} return {"token": OPENCLAW_GATEWAY_TOKEN}
+49 -33
View File
@@ -1,16 +1,18 @@
import asyncio import asyncio
import struct import struct
import logging import logging
import time from collections import Counter
from fastapi import WebSocket, WebSocketDisconnect, Query from fastapi import WebSocket, Query
import asyncssh import asyncssh
import jwt
import config import config
from auth import get_current_user_ws
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MAX_SESSIONS = 5 MAX_SESSIONS = 5
_active_sessions: set = set() MAX_SESSIONS_PER_USER = 2
_session_lock = asyncio.Lock()
_active_sessions: dict[int, str] = {}
HOSTS = { HOSTS = {
"nas": {"host": config.SSH_HOST, "user": config.SSH_USER, "key": config.SSH_KEY_PATH}, "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) 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")): async def _reserve_session(session_id: int, username: str) -> tuple[bool, str | None, int | None]:
await websocket.accept() 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: try:
first_msg = await asyncio.wait_for(websocket.receive_text(), timeout=5) user = await get_current_user_ws(token)
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
except Exception: except Exception:
await websocket.close(code=1008, reason="Unauthorized") await websocket.close(code=1008, reason="Unauthorized")
return return
if user.pages != "*" and "terminal" not in user.pages:
await websocket.close(code=1008, reason="Access denied")
return
if not _known_hosts_available: if not _known_hosts_available:
await websocket.close(code=1011, reason="SSH host verification unavailable") await websocket.close(code=1011, reason="SSH host verification unavailable")
return return
if len(_active_sessions) >= MAX_SESSIONS:
await websocket.close(code=1013, reason="Too many sessions")
return
session_id = id(websocket) 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: try:
conn = await asyncssh.connect( conn = await asyncssh.connect(
h["host"], username=h["user"], h["host"], username=h["user"],
@@ -76,7 +92,7 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")):
keepalive_count_max=3, keepalive_count_max=3,
) )
except Exception: except Exception:
_active_sessions.discard(session_id) await _release_session(session_id)
await websocket.close(code=1011, reason="SSH connection failed") await websocket.close(code=1011, reason="SSH connection failed")
return return
@@ -93,7 +109,7 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")):
if not data: if not data:
break break
await websocket.send_bytes(data) await websocket.send_bytes(data)
except (asyncssh.Error, WebSocketDisconnect, asyncio.CancelledError): except (asyncssh.Error, asyncio.CancelledError):
pass pass
read_task = asyncio.create_task(read_ssh()) 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__": if msg["text"] == "__ping__":
continue continue
process.stdin.write(msg["text"].encode()) process.stdin.write(msg["text"].encode())
except WebSocketDisconnect: except Exception:
pass pass
finally: finally:
read_task.cancel() read_task.cancel()
process.close() process.close()
finally: finally:
conn.close() conn.close()
_active_sessions.discard(session_id) await _release_session(session_id)
+93 -21
View File
@@ -120,6 +120,8 @@
function closeTab(id) { function closeTab(id) {
const d = tabData.get(id); const d = tabData.get(id);
if (d) { if (d) {
d.closedManually = true;
if (d.reconnectTimer) clearTimeout(d.reconnectTimer);
d.voice?.destroy(); d.voice?.destroy();
d.ro?.disconnect(); d.ro?.disconnect();
if (d.heartbeat) clearInterval(d.heartbeat); if (d.heartbeat) clearInterval(d.heartbeat);
@@ -157,11 +159,30 @@
const proto = location.protocol === "https:" ? "wss:" : "ws:"; const proto = location.protocol === "https:" ? "wss:" : "ws:";
const token = localStorage.getItem("token") || ""; const token = localStorage.getItem("token") || "";
const ws = new WebSocket( let heartbeat = null;
`${proto}//${location.host}/ws/terminal?host=${tab.hostId}` let reconnectTimer = null;
); let closedManually = false;
ws.binaryType = "arraybuffer";
function sendSize() { 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) { if (ws.readyState === 1) {
const buf = new Uint8Array(5); const buf = new Uint8Array(5);
buf[0] = 0x01; buf[0] = 0x01;
@@ -171,19 +192,62 @@
} }
} }
ws.onopen = () => { ws.send(token); updateTab(tabId, { connected: true, error: "" }); sendSize(); }; function connect() {
ws.onmessage = (e) => term.write(new Uint8Array(e.data)); const ws = new WebSocket(
ws.onclose = (e) => { `${proto}//${location.host}/ws/terminal?host=${encodeURIComponent(tab.hostId)}&token=${encodeURIComponent(token)}`
const reason = e.reason || `Connection closed (code ${e.code})`; );
updateTab(tabId, { connected: false, error: reason }); ws.binaryType = "arraybuffer";
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)));
const heartbeat = setInterval(() => { ws.onopen = () => {
if (ws.readyState === 1) ws.send("__ping__"); updateTab(tabId, { connected: true, error: "" });
}, 25000); 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) => { const handleDragOver = (e) => {
e.preventDefault(); e.preventDefault();
@@ -192,19 +256,21 @@
const handleDrop = async (e) => { const handleDrop = async (e) => {
e.preventDefault(); e.preventDefault();
if (activeTab !== tabId) return; if (activeTab !== tabId) return;
const currentWs = tabData.get(tabId)?.ws;
const files = [...(e.dataTransfer?.files || [])].filter((f) => f.type?.startsWith("image/")); const files = [...(e.dataTransfer?.files || [])].filter((f) => f.type?.startsWith("image/"));
for (const file of files) { for (const file of files) {
await sendDroppedImage(term, ws, file); await sendDroppedImage(term, currentWs, file);
} }
}; };
const handlePaste = async (e) => { const handlePaste = async (e) => {
if (activeTab !== tabId) return; if (activeTab !== tabId) return;
const currentWs = tabData.get(tabId)?.ws;
const items = [...(e.clipboardData?.items || [])] const items = [...(e.clipboardData?.items || [])]
.filter((it) => it.kind === "file" && it.type?.startsWith("image/")); .filter((it) => it.kind === "file" && it.type?.startsWith("image/"));
for (const item of items) { for (const item of items) {
const file = item.getAsFile(); 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.element?.addEventListener("drop", handleDrop);
term.textarea?.addEventListener("paste", handlePaste); 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); ro.observe(el);
const voice = new VoiceSession(() => { voiceTick++; }); const voice = new VoiceSession(() => { voiceTick++; });
voice.attach(term, ws); 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(() => { onDestroy(() => {
themeObserver.disconnect(); themeObserver.disconnect();
tabData.forEach((d) => { tabData.forEach((d) => {
d.closedManually = true;
if (d.reconnectTimer) clearTimeout(d.reconnectTimer);
d.voice?.destroy(); d.voice?.destroy();
d.ro?.disconnect(); d.ro?.disconnect();
if (d.heartbeat) clearInterval(d.heartbeat); if (d.heartbeat) clearInterval(d.heartbeat);