From c7a0a53fe81d040e7b4b04d5e85ac559e6d2e472 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Thu, 26 Feb 2026 17:22:25 +0800 Subject: [PATCH] security: harden dashboard (SSH keys, auth, uploads, CORS, non-root) Remove SSH private keys from git, add SECRET_KEY validation, move WS auth from query string to first message, add session limits/idle timeout, PBKDF2 Fernet key, refresh token rotation, TOTP replay protection, file upload size limit + filename sanitization, symlink safety check, restrict CORS methods, IP-gate OpenClaw token, run container as non-root, rate-limit refresh/passkey endpoints, sanitize Gitea path params. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 + dashboard/Dockerfile | 3 + dashboard/backend/auth.py | 46 ++++++++++++++-- dashboard/backend/config.py | 3 + dashboard/backend/main.py | 6 +- .../__pycache__/__init__.cpython-313.pyc | Bin 164 -> 0 bytes .../__pycache__/terminal.cpython-313.pyc | Bin 4606 -> 0 bytes dashboard/backend/routers/auth.py | 16 ++++-- dashboard/backend/routers/docker_router.py | 4 +- dashboard/backend/routers/files.py | 52 ++++++++++++++++-- dashboard/backend/routers/gitea.py | 6 +- dashboard/backend/routers/system.py | 7 ++- dashboard/backend/routers/terminal.py | 46 ++++++++++++++-- dashboard/docker-compose.yml | 1 + dashboard/frontend/src/routes/Terminal.svelte | 4 +- dashboard/ssh/dashboard_terminal | 7 --- dashboard/ssh/dashboard_terminal.pub | 1 - dashboard/ssh/vps_terminal | 7 --- dashboard/ssh/vps_terminal.pub | 1 - 19 files changed, 164 insertions(+), 47 deletions(-) delete mode 100644 dashboard/backend/routers/__pycache__/__init__.cpython-313.pyc delete mode 100644 dashboard/backend/routers/__pycache__/terminal.cpython-313.pyc delete mode 100644 dashboard/ssh/dashboard_terminal delete mode 100644 dashboard/ssh/dashboard_terminal.pub delete mode 100644 dashboard/ssh/vps_terminal delete mode 100644 dashboard/ssh/vps_terminal.pub diff --git a/.gitignore b/.gitignore index d328f57..a65dffe 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ *.tar.gz __pycache__/ dashboard/nas-dashboard.tar +dashboard/ssh/ diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile index 3b3a74d..bafe546 100644 --- a/dashboard/Dockerfile +++ b/dashboard/Dockerfile @@ -8,10 +8,13 @@ RUN npm install --registry=https://registry.npmmirror.com && npm run build # Stage 2: Python runtime FROM python:3.12-slim RUN apt-get update && apt-get install -y --no-install-recommends openssh-client && rm -rf /var/lib/apt/lists/* +RUN adduser --disabled-password --no-create-home --gecos "" app WORKDIR /app COPY backend/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY backend/ ./ COPY --from=frontend /build/dist /app/static +RUN chown -R app:app /app +USER app EXPOSE 4000 CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "4000"] diff --git a/dashboard/backend/auth.py b/dashboard/backend/auth.py index 5005a18..2cb9354 100644 --- a/dashboard/backend/auth.py +++ b/dashboard/backend/auth.py @@ -1,5 +1,6 @@ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Optional +import time import jwt from passlib.context import CryptContext import pyotp @@ -19,14 +20,14 @@ def get_password_hash(password): def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): to_encode = data.copy() - expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15)) + expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=15)) to_encode.update({"exp": expire, "type": "access"}) return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM) def create_refresh_token(data: dict): to_encode = data.copy() - expire = datetime.utcnow() + timedelta(minutes=config.REFRESH_TOKEN_EXPIRE_MINUTES) - to_encode.update({"exp": expire, "type": "refresh"}) + expire = datetime.now(timezone.utc) + timedelta(minutes=config.REFRESH_TOKEN_EXPIRE_MINUTES) + to_encode.update({"exp": expire, "type": "refresh", "tv": _load_token_version()}) return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM) async def get_current_user(token: str = Depends(oauth2_scheme)): @@ -70,6 +71,13 @@ import hashlib from cryptography.fernet import Fernet def _get_fernet(): + from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + from cryptography.hazmat.primitives import hashes + kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=b"nas-dashboard-fernet", iterations=480000) + key = base64.urlsafe_b64encode(kdf.derive(config.SECRET_KEY.encode())) + return Fernet(key) + +def _get_legacy_fernet(): key = base64.urlsafe_b64encode(hashlib.sha256(config.SECRET_KEY.encode()).digest()) return Fernet(key) @@ -83,6 +91,11 @@ def _decrypt(ciphertext: str) -> str: return "" try: return _get_fernet().decrypt(ciphertext.encode()).decode() + except Exception: + pass + # Fallback: try legacy SHA256-based key + try: + return _get_legacy_fernet().decrypt(ciphertext.encode()).decode() except Exception: return ciphertext # fallback for unencrypted legacy values @@ -125,7 +138,16 @@ def verify_totp(token: str, secret: str = None): if not secret: return True # 2FA not enabled totp = pyotp.TOTP(secret) - return totp.verify(token) + 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 # Passkey credentials def load_passkey_credentials(): @@ -142,3 +164,17 @@ def clear_passkey_credentials(): data = _load_auth_data() data["passkey_credentials"] = [] _save_auth_data(data) + +# 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 verify_token_version(tv: int) -> bool: + return tv == _load_token_version() diff --git a/dashboard/backend/config.py b/dashboard/backend/config.py index 2cd8cc3..96dacda 100644 --- a/dashboard/backend/config.py +++ b/dashboard/backend/config.py @@ -8,6 +8,8 @@ VOLUME_ROOT = os.environ.get("VOLUME_ROOT", "/volume1") # Auth SECRET_KEY = os.environ.get("SECRET_KEY", "") +if not SECRET_KEY or len(SECRET_KEY) < 32: + raise RuntimeError("SECRET_KEY must be set and at least 32 characters") ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 30 REFRESH_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days @@ -19,6 +21,7 @@ TOTP_SECRET = os.environ.get("TOTP_SECRET", "") SSH_HOST = os.environ.get("SSH_HOST", "host.docker.internal") SSH_USER = os.environ.get("SSH_USER", "zjgump") SSH_KEY_PATH = os.environ.get("SSH_KEY_PATH", "/app/ssh/id_ed25519") +SSH_KNOWN_HOSTS = os.environ.get("SSH_KNOWN_HOSTS", "/app/ssh/known_hosts") VPS_SSH_HOST = os.environ.get("VPS_SSH_HOST", "158.101.140.85") VPS_SSH_USER = os.environ.get("VPS_SSH_USER", "jimmyg") diff --git a/dashboard/backend/main.py b/dashboard/backend/main.py index 91afddb..f57bd43 100644 --- a/dashboard/backend/main.py +++ b/dashboard/backend/main.py @@ -26,8 +26,8 @@ app.add_middleware( CORSMiddleware, allow_origins=CORS_ORIGINS, allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=["GET", "POST", "DELETE", "OPTIONS"], + allow_headers=["Authorization", "Content-Type"], ) @app.exception_handler(RateLimitExceeded) @@ -118,7 +118,7 @@ async def health(): async def client_ip(request: Request): ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip() or request.client.host lan = ip.startswith("192.168.31.") or (ip.startswith("100.") and _router_reachable()) - return {"ip": ip, "lan": lan} + return {"lan": lan} def _router_reachable(): import socket diff --git a/dashboard/backend/routers/__pycache__/__init__.cpython-313.pyc b/dashboard/backend/routers/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index b98b1a8c2ce803ecbb0c398491eb506d0b4fb178..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 164 zcmey&%ge<81gm~d%>>bpK?DpiLK&Y~fQ+dO=?t2Tek&P@n1H;`AgNo<`k}?CMaBAA znYp=@>H0;f1^LDLd5OikCHeU|#ri3U#TiNYiA5>;Nr}nXsd*{-Mfs&AAPw>HnR%Hd l@$q^EmA5!-a`RJ4b5iY!Sb=7O>?{T`J~A^hG8QodSpcjHDjom; diff --git a/dashboard/backend/routers/__pycache__/terminal.cpython-313.pyc b/dashboard/backend/routers/__pycache__/terminal.cpython-313.pyc deleted file mode 100644 index 4758811f5a1235355115167763031163dc7b615c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4606 zcmdrQTWlN0agTRA-jNhViL#znte5LBHcdH_ABvxf6j+a1YI$a3SBBLSd6F4Zcf{UF zvYfUMngo&U0#=-+RuZ6Q(2uA{TKKOuk5<>r^1J>MY$jTHTuOS5~TsOk2ki(ok zc43zddCc2Tz=93k*lj}*ivV5So-PSXT{4!tyx3c_D#M3;C%kp&gfCXkmUQ4wZWV9l z6#j&y2qy}f1;t&7o)ezqaA1fFi+3qOPnak2v)X`SBu;A<2|YmDQ)a@@buD2LPrH`U z^rT7TF>SzHB@{+X4JTaAddLU(3-GKw;X&x6|71Zus)19-Q7n&$p^vX2YAN~}ideqv zHR@hY;dEEr6=RxZ;s7cxc48(wSz*hD-8|Y9UIAUSxH>_Pr+9=$x!3p}w;%R9g!;gW zt1U)P^hn#eHvTGSpP!M&0t|Ct9b%j#aIRs$&i4h`Y^<1J4s_^sAucH5Q^ycee}hmS zm*`M4tPUOZGcDBQ)0Qv_2RbV}L<#nIwAG?D4IrJ^vH6v@Z3!=Zo{}o^Kuh zuS1Hg$WiV)%C2Nuh;3w;10CJz?zlf*(CkvY5LuEgD!xiIT+rumKjv^T4D=~F64-fk z&tvGZeDAAe^bTY`^r-jglH!j`dx22>BltXwXn?RdP=D{d)8)9Vh>Ax*N`dWDFN06j zvlZep+l>;iXV1WF1DlO~k6{i3Ef<1Bj}jCR)3hSe)phT;;tVQ*uWc{K?rD52g@Y$6<+hRC8QUqzvLV)0&nc zF2f`OC_YJCV@7#kSVlS2_DBvnf#@~~=J8txxVl0s8UL-o{8 zxz&NRX3`{6GciHhqH(C5%;{){zFs>jTJpvo92-P*n{ZGh*P2ez~6dRWvPgYN!aG=Y{~ zvRt%ST)I?ReR=rOaJGHEG@J{DXM^Eep}K5KE)xJ~?^TaZSaW0lmwMHFBQ7($2)W6q!bp#BeyJ`^B zV$?Kti$AC$8&KHQV+R{Vptx#FA`F0u!UAy(CUG?|5DpWr-=qR-pkb&u5vIimX;{}P zkGMn>C{239O`zM-hQ;FY%aKUsow35TL`#})So;C6#IbSB3s;`flt%(TV^T=PCk-(l8D+FEiszGg_IyLq*?vi zC@j*SHjE7MCBXG8t>043)5L?dgq9lB2%pw;kYeEx_?{3)^bEK*aStYRD^1)fBLTo; zyEnv}_h@HtoAPXDkMd(8tP+mllm!`3(`hEOBMwfc>Ip4Pgwv^X8bqgtATo$Y={$V& znb;BH8BdLuO#Q&H0Rrt6?48VbG|K$fwGInIV0OV+jwZwm<_b%q}Exf zbx{g@E+SvS#qhcCg{?VZ!>q7jsj~XY);G4k8JQExCORk2EP4GGx18T{Aw1D`$K#z; zUpTWQc`r)mrKySqsfyzAdHI(gQ2KD9OLoBr8?6!}yT zU+(xt$K)Ytob%P*@YT-y>L=R25WMc9d*!^l>~k+FD$fP$Zv^Y#>iBKfd@wrE zxh#~ps}=*rOXZbUysvxT^yi8jCfgPZOQ-8E{5)Hq9eR7)Vp-+%?pJ;~CEN*>O>@(= zQ!QD~d}zzNZC8bNIE(_~9n;5WgqtOi z#iFw5rdO-8P1(`6_bvsCFH4uC>56RIjQCb}u3^_)!>;*;Jqy8>dljfKbnk~8lAq@8 zNyuG#uZu&W&G$bqNB&KaBHabIBtN7`cfr4xMI;yJr1Ck5DK_6m)HM9BesuZdD<>DE z2C8=NdGA!yG~yua-EuB>008Su#$TSGk|?s^&5)hJvq-$?OhuB%D7zO#KZ3_hqT z?`lCGG;v+Kgb$kA_QBx8Mh+%EY_j`Z9DUQ`?H2iuc-Qy0p^w}g#YLXta%(-1{%u=p zLr;+Zdyt1gxX?}-c51;i`{8!l(ZUqd&lkKOI^r3GOH|FIFimK7v}2m%h@g%@;tJ7( zlNiC6rX2b&L_gfjJfs#o(td|{aAm`MAEjpRsLJAmUbq}kG>>w(%i?lubCM)Gas zy^RX&pC8_QjP}l%!De$VmG&_S*$Td_>R)%q_euvhsHv(9Fa diff --git a/dashboard/backend/routers/auth.py b/dashboard/backend/routers/auth.py index ac0a639..467eeb5 100644 --- a/dashboard/backend/routers/auth.py +++ b/dashboard/backend/routers/auth.py @@ -74,10 +74,10 @@ async def login(creds: LoginRequest, request: Request): asyncio.create_task(send_failed_login_alert(client_ip, creds.username, "Incorrect username or password")) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect username or password", + detail="Invalid credentials", headers={"WWW-Authenticate": "Bearer"}, ) - + # Check 2FA totp_secret = auth.load_totp_secret() if totp_secret: @@ -92,7 +92,7 @@ async def login(creds: LoginRequest, request: Request): asyncio.create_task(send_failed_login_alert(client_ip, creds.username, "Invalid 2FA Code")) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid 2FA code", + detail="Invalid credentials", headers={"WWW-Authenticate": "Bearer"}, ) @@ -115,7 +115,8 @@ async def read_users_me(current_user: str = Depends(auth.get_current_user)): return {"username": current_user, "has_2fa": bool(totp_secret)} @router.post("/refresh") -async def refresh_token(req: RefreshRequest): +@limiter.limit("10/minute") +async def refresh_token(req: RefreshRequest, request: Request): try: payload = jwt.decode(req.refresh_token, config.SECRET_KEY, algorithms=[config.ALGORITHM]) if payload.get("type") != "refresh": @@ -123,8 +124,11 @@ async def refresh_token(req: RefreshRequest): username = payload.get("sub") if username != config.ADMIN_USER: raise HTTPException(status_code=401, detail="Invalid user") + if not auth.verify_token_version(payload.get("tv", -1)): + raise HTTPException(status_code=401, detail="Token revoked") except jwt.PyJWTError: raise HTTPException(status_code=401, detail="Invalid refresh token") + auth.increment_token_version() access_token = auth.create_access_token( data={"sub": username}, expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES), @@ -217,7 +221,8 @@ async def passkey_register_verify(request: Request, current_user: str = Depends( return {"ok": True} @router.post("/passkey/login/options") -async def passkey_login_options(): +@limiter.limit("10/minute") +async def passkey_login_options(request: Request): creds = auth.load_passkey_credentials() if not creds: raise HTTPException(status_code=404, detail="No passkeys registered") @@ -231,6 +236,7 @@ async def passkey_login_options(): return JSONResponse(content=json.loads(options_to_json(options))) @router.post("/passkey/login/verify") +@limiter.limit("10/minute") async def passkey_login_verify(request: Request): body = await request.json() challenge = _get_challenge() diff --git a/dashboard/backend/routers/docker_router.py b/dashboard/backend/routers/docker_router.py index f06050b..8ad35ef 100644 --- a/dashboard/backend/routers/docker_router.py +++ b/dashboard/backend/routers/docker_router.py @@ -1,5 +1,5 @@ import docker -from fastapi import APIRouter +from fastapi import APIRouter, Query from config import DOCKER_HOST router = APIRouter() @@ -31,6 +31,6 @@ def container_action(container_id: str, action: str): @router.get("/containers/{container_id}/logs") -def container_logs(container_id: str, tail: int = 200): +def container_logs(container_id: str, tail: int = Query(200, le=10000)): c = client.containers.get(container_id) return {"logs": c.logs(tail=tail, timestamps=True).decode(errors="replace")} diff --git a/dashboard/backend/routers/files.py b/dashboard/backend/routers/files.py index 81fe857..8b8e9bd 100644 --- a/dashboard/backend/routers/files.py +++ b/dashboard/backend/routers/files.py @@ -1,4 +1,5 @@ import os +import re import shutil from pathlib import Path from fastapi import APIRouter, UploadFile, File, HTTPException @@ -8,12 +9,16 @@ from config import VOLUME_ROOT router = APIRouter() BASE = Path(VOLUME_ROOT) -BLOCKED_DIRS = {"@appstore", "@docker", "@eaDir", "@S2S", "@sharesnap", "@tmp", "docker"} +MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100 MB +BLOCKED_DIRS = {"@appstore", "@docker", "@eaDir", "@S2S", "@sharesnap", "@tmp", "docker", "homes", "backups"} + + +_BASE_RESOLVED = str(BASE.resolve()) def _safe_path(path: str) -> Path: target = (BASE / path).resolve() - if not str(target).startswith(str(BASE.resolve())): + if not str(target).startswith(_BASE_RESOLVED): raise ValueError("forbidden") # Block access to sensitive directories rel = target.relative_to(BASE.resolve()) @@ -22,6 +27,32 @@ def _safe_path(path: str) -> Path: return target +def _is_safe_symlink(item: Path) -> bool: + """Check if a symlink resolves to a path within BASE and not in blocked dirs.""" + if not item.is_symlink(): + return True + try: + resolved = item.resolve() + if not str(resolved).startswith(_BASE_RESOLVED): + return False + rel = resolved.relative_to(BASE.resolve()) + if rel.parts and rel.parts[0] in BLOCKED_DIRS: + return False + return True + except (OSError, ValueError): + return False + + +def _sanitize_filename(name: str) -> str: + name = os.path.basename(name) + name = name.replace("\x00", "").replace("/", "").replace("\\", "") + name = re.sub(r'\.\.+', '.', name) + name = name.strip(". ") + if not name: + raise ValueError("invalid filename") + return name + + @router.get("/browse") def browse(path: str = ""): try: @@ -31,9 +62,11 @@ def browse(path: str = ""): entries = [] for item in sorted(target.iterdir()): try: + if not _is_safe_symlink(item): + continue st = item.stat() entries.append({"name": item.name, "is_dir": item.is_dir(), "size": st.st_size if item.is_file() else 0}) - except PermissionError: + except (PermissionError, OSError): continue return {"path": str(target.relative_to(BASE)), "entries": entries} @@ -49,11 +82,20 @@ def download(path: str): @router.post("/upload") async def upload(path: str, file: UploadFile = File(...)): try: - target = _safe_path(path) / file.filename + safe_name = _sanitize_filename(file.filename) + target = _safe_path(path) / safe_name except ValueError: raise HTTPException(status_code=403, detail="Access denied") + # Stream with size check + total = 0 with open(target, "wb") as f: - shutil.copyfileobj(file.file, f) + while chunk := await file.read(1024 * 1024): + total += len(chunk) + if total > MAX_UPLOAD_SIZE: + f.close() + target.unlink(missing_ok=True) + raise HTTPException(status_code=413, detail="File too large (max 100MB)") + f.write(chunk) return {"ok": True} diff --git a/dashboard/backend/routers/gitea.py b/dashboard/backend/routers/gitea.py index 15895b8..20528b4 100644 --- a/dashboard/backend/routers/gitea.py +++ b/dashboard/backend/routers/gitea.py @@ -1,9 +1,11 @@ +import re import httpx -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException from config import GITEA_URL, GITEA_TOKEN router = APIRouter() HEADERS = {"Authorization": f"token {GITEA_TOKEN}"} if GITEA_TOKEN else {} +_SAFE_NAME = re.compile(r'^[a-zA-Z0-9._-]+$') @router.get("/repos") @@ -15,6 +17,8 @@ async def list_repos(): @router.get("/repos/{owner}/{repo}/commits") async def list_commits(owner: str, repo: str, page: int = 1): + if not _SAFE_NAME.match(owner) or not _SAFE_NAME.match(repo): + raise HTTPException(status_code=400, detail="Invalid owner or repo name") async with httpx.AsyncClient() as c: r = await c.get(f"{GITEA_URL}/api/v1/repos/{owner}/{repo}/commits", headers=HEADERS, params={"page": page}) return r.json() diff --git a/dashboard/backend/routers/system.py b/dashboard/backend/routers/system.py index ee7d947..01b3dde 100644 --- a/dashboard/backend/routers/system.py +++ b/dashboard/backend/routers/system.py @@ -4,7 +4,7 @@ import psutil import platform import time import httpx -from fastapi import APIRouter, Query +from fastapi import APIRouter, Query, Request, HTTPException from config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, VOLUME_ROOT, OPENCLAW_GATEWAY_TOKEN router = APIRouter() @@ -115,5 +115,8 @@ def _classify(method, path, status, user): @router.get("/openclaw-token") -def openclaw_token(): +def openclaw_token(request: Request): + ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip() or request.client.host + if not (ip.startswith("192.168.31.") or ip.startswith("100.")): + 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 5447914..31b4248 100644 --- a/dashboard/backend/routers/terminal.py +++ b/dashboard/backend/routers/terminal.py @@ -1,10 +1,18 @@ import asyncio import struct +import logging +import time from fastapi import WebSocket, WebSocketDisconnect, Query import asyncssh import jwt import config +logger = logging.getLogger(__name__) + +MAX_SESSIONS = 5 +IDLE_TIMEOUT = 30 * 60 # 30 minutes +_active_sessions: set = set() + HOSTS = { "nas": {"host": config.SSH_HOST, "user": config.SSH_USER, "key": config.SSH_KEY_PATH}, "vps": {"host": config.VPS_SSH_HOST, "user": config.VPS_SSH_USER, "key": config.VPS_SSH_KEY_PATH}, @@ -12,26 +20,43 @@ HOSTS = { "command": "/volume1/@appstore/ContainerManager/usr/bin/docker exec -it claude-dev bash"}, } +# Load known_hosts once at import time +_known_hosts = None +try: + _known_hosts = asyncssh.read_known_hosts(config.SSH_KNOWN_HOSTS) +except Exception: + logger.warning("SSH known_hosts file not found at %s — host key verification disabled", config.SSH_KNOWN_HOSTS) -async def ws_endpoint(websocket: WebSocket, token: str = Query(""), host: str = Query("nas")): + +async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")): + await websocket.accept() + + # Auth: expect token as first text message try: - payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM]) - if payload.get("sub") != config.ADMIN_USER: + 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" or payload.get("sub") != config.ADMIN_USER: await websocket.close(code=1008, reason="Unauthorized") return except Exception: await websocket.close(code=1008, reason="Unauthorized") return - await websocket.accept() + 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) h = HOSTS.get(host, HOSTS["nas"]) try: conn = await asyncssh.connect( h["host"], username=h["user"], - client_keys=[h["key"]], known_hosts=None, + client_keys=[h["key"]], known_hosts=_known_hosts, ) except Exception: + _active_sessions.discard(session_id) await websocket.close(code=1011, reason="SSH connection failed") return @@ -52,10 +77,18 @@ async def ws_endpoint(websocket: WebSocket, token: str = Query(""), host: str = pass read_task = asyncio.create_task(read_ssh()) + last_activity = time.monotonic() try: while True: - msg = await websocket.receive() + try: + msg = await asyncio.wait_for(websocket.receive(), timeout=60) + except asyncio.TimeoutError: + if time.monotonic() - last_activity > IDLE_TIMEOUT: + await websocket.close(code=1000, reason="Idle timeout") + break + continue + last_activity = time.monotonic() if "bytes" in msg and msg["bytes"]: data = msg["bytes"] if data[0:1] == b"\x01" and len(data) == 5: @@ -73,3 +106,4 @@ async def ws_endpoint(websocket: WebSocket, token: str = Query(""), host: str = process.close() finally: conn.close() + _active_sessions.discard(session_id) diff --git a/dashboard/docker-compose.yml b/dashboard/docker-compose.yml index 7b7ab70..b7aa405 100644 --- a/dashboard/docker-compose.yml +++ b/dashboard/docker-compose.yml @@ -44,6 +44,7 @@ services: - /volume1:/volume1 - /volume1/docker/nas-dashboard/ssh/dashboard_terminal:/app/ssh/id_ed25519:ro - /volume1/docker/nas-dashboard/ssh/vps_terminal:/app/ssh/vps_id_ed25519:ro + - /volume1/docker/nas-dashboard/ssh/known_hosts:/app/ssh/known_hosts:ro healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4000/api/health')"] interval: 30s diff --git a/dashboard/frontend/src/routes/Terminal.svelte b/dashboard/frontend/src/routes/Terminal.svelte index 03c92ea..3dbfecd 100644 --- a/dashboard/frontend/src/routes/Terminal.svelte +++ b/dashboard/frontend/src/routes/Terminal.svelte @@ -66,7 +66,7 @@ const proto = location.protocol === "https:" ? "wss:" : "ws:"; const token = localStorage.getItem("token") || ""; const ws = new WebSocket( - `${proto}//${location.host}/ws/terminal?token=${encodeURIComponent(token)}&host=${tab.hostId}` + `${proto}//${location.host}/ws/terminal?host=${tab.hostId}` ); ws.binaryType = "arraybuffer"; function sendSize() { @@ -79,7 +79,7 @@ } } - ws.onopen = () => { updateTab(tabId, { connected: true }); sendSize(); }; + ws.onopen = () => { ws.send(token); updateTab(tabId, { connected: true }); sendSize(); }; ws.onmessage = (e) => term.write(new Uint8Array(e.data)); ws.onclose = () => { updateTab(tabId, { connected: false }); diff --git a/dashboard/ssh/dashboard_terminal b/dashboard/ssh/dashboard_terminal deleted file mode 100644 index b0df4fe..0000000 --- a/dashboard/ssh/dashboard_terminal +++ /dev/null @@ -1,7 +0,0 @@ ------BEGIN OPENSSH PRIVATE KEY----- -b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW -QyNTUxOQAAACAA1B82gNubHN9YSbouzZBx5ZcBAfSXrTEf6j2+k9p/MwAAAJi8dYFQvHWB -UAAAAAtzc2gtZWQyNTUxOQAAACAA1B82gNubHN9YSbouzZBx5ZcBAfSXrTEf6j2+k9p/Mw -AAAEANwPXx/fEISyOhps72XCb5Okusk9wHUTW6TjHuqfKAEQDUHzaA25sc31hJui7NkHHl -lwEB9JetMR/qPb6T2n8zAAAAEmRhc2hib2FyZC10ZXJtaW5hbAECAw== ------END OPENSSH PRIVATE KEY----- diff --git a/dashboard/ssh/dashboard_terminal.pub b/dashboard/ssh/dashboard_terminal.pub deleted file mode 100644 index 750bc26..0000000 --- a/dashboard/ssh/dashboard_terminal.pub +++ /dev/null @@ -1 +0,0 @@ -ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIADUHzaA25sc31hJui7NkHHllwEB9JetMR/qPb6T2n8z dashboard-terminal diff --git a/dashboard/ssh/vps_terminal b/dashboard/ssh/vps_terminal deleted file mode 100644 index 4b664ea..0000000 --- a/dashboard/ssh/vps_terminal +++ /dev/null @@ -1,7 +0,0 @@ ------BEGIN OPENSSH PRIVATE KEY----- -b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW -QyNTUxOQAAACC6rZoMd8T2DBFC2hdfgWHhwnusiHtbZ753dLibmZDyNQAAAJiY03+ImNN/ -iAAAAAtzc2gtZWQyNTUxOQAAACC6rZoMd8T2DBFC2hdfgWHhwnusiHtbZ753dLibmZDyNQ -AAAEBiLX9L0ASvdqTqmD/kOs/cuLpyUBgQl+j8+uHuVFrkN7qtmgx3xPYMEULaF1+BYeHC -e6yIe1tnvnd0uJuZkPI1AAAAEW5hcy1kYXNoYm9hcmQtdnBzAQIDBA== ------END OPENSSH PRIVATE KEY----- diff --git a/dashboard/ssh/vps_terminal.pub b/dashboard/ssh/vps_terminal.pub deleted file mode 100644 index 44597dc..0000000 --- a/dashboard/ssh/vps_terminal.pub +++ /dev/null @@ -1 +0,0 @@ -ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILqtmgx3xPYMEULaF1+BYeHCe6yIe1tnvnd0uJuZkPI1 nas-dashboard-vps