fix: harden dashboard auth and terminal flows #26
+42
-16
@@ -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,6 +134,7 @@ def _decrypt(ciphertext: str) -> str:
|
||||
return ciphertext # fallback for unencrypted legacy values
|
||||
|
||||
def _load_auth_data():
|
||||
with _auth_lock:
|
||||
try:
|
||||
if os.path.exists(AUTH_FILE):
|
||||
with open(AUTH_FILE, "r") as f:
|
||||
@@ -117,9 +144,7 @@ def _load_auth_data():
|
||||
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()
|
||||
def mutator(data):
|
||||
data["totp_secret"] = _encrypt(secret) if secret else ""
|
||||
_save_auth_data(data)
|
||||
_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()
|
||||
def mutator(data):
|
||||
data["password_hash"] = hashed
|
||||
_save_auth_data(data)
|
||||
_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
|
||||
|
||||
def mutator(data):
|
||||
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
|
||||
|
||||
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()
|
||||
def mutator(data):
|
||||
creds = data.get("passkey_credentials", [])
|
||||
creds.append(cred)
|
||||
data["passkey_credentials"] = creds
|
||||
_save_auth_data(data)
|
||||
_update_auth_data(mutator)
|
||||
|
||||
def clear_passkey_credentials():
|
||||
data = _load_auth_data()
|
||||
def mutator(data):
|
||||
data["passkey_credentials"] = []
|
||||
_save_auth_data(data)
|
||||
_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()
|
||||
def mutator(data):
|
||||
v = data.get("token_version", 0) + 1
|
||||
data["token_version"] = v
|
||||
_save_auth_data(data)
|
||||
return v
|
||||
return _update_auth_data(mutator)
|
||||
|
||||
def verify_token_version(tv: int) -> bool:
|
||||
return tv == _load_token_version()
|
||||
|
||||
+48
-30
@@ -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 "")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 save_rbac(data: dict):
|
||||
os.makedirs(os.path.dirname(RBAC_FILE), exist_ok=True)
|
||||
with open(RBAC_FILE, "w") as f:
|
||||
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):
|
||||
_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()
|
||||
def mutator(rbac):
|
||||
rbac.setdefault("user_overrides", {}).setdefault(username, {})["sidebar_order"] = order
|
||||
save_rbac(rbac)
|
||||
update_rbac(mutator)
|
||||
|
||||
|
||||
def has_page_access(user: User, page: str) -> bool:
|
||||
|
||||
@@ -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()
|
||||
|
||||
def mutator(rbac):
|
||||
existing = rbac.setdefault("user_overrides", {}).get(username, {})
|
||||
existing["pages"] = pages
|
||||
rbac["user_overrides"][username] = existing
|
||||
save_rbac(rbac)
|
||||
|
||||
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()
|
||||
from rbac import update_rbac
|
||||
|
||||
def mutator(rbac):
|
||||
rbac.get("user_overrides", {}).pop(username, None)
|
||||
save_rbac(rbac)
|
||||
|
||||
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()
|
||||
|
||||
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
|
||||
save_rbac(rbac)
|
||||
|
||||
update_rbac(mutator)
|
||||
return {"ok": True}
|
||||
|
||||
@@ -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 == 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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(); };
|
||||
function connect() {
|
||||
const ws = new WebSocket(
|
||||
`${proto}//${location.host}/ws/terminal?host=${encodeURIComponent(tab.hostId)}&token=${encodeURIComponent(token)}`
|
||||
);
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
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" });
|
||||
};
|
||||
ws.onerror = () => updateTab(tabId, { connected: false, error: "Connection failed" });
|
||||
term.onData((data) => ws.readyState === 1 && ws.send(new TextEncoder().encode(data)));
|
||||
|
||||
const 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;
|
||||
}
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user