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) <noreply@anthropic.com>
This commit is contained in:
@@ -4,3 +4,4 @@ dist/
|
|||||||
*.tar.gz
|
*.tar.gz
|
||||||
__pycache__/
|
__pycache__/
|
||||||
dashboard/nas-dashboard.tar
|
dashboard/nas-dashboard.tar
|
||||||
|
dashboard/ssh/
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ RUN npm install --registry=https://registry.npmmirror.com && npm run build
|
|||||||
# Stage 2: Python runtime
|
# Stage 2: Python runtime
|
||||||
FROM python:3.12-slim
|
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 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
|
WORKDIR /app
|
||||||
COPY backend/requirements.txt .
|
COPY backend/requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY backend/ ./
|
COPY backend/ ./
|
||||||
COPY --from=frontend /build/dist /app/static
|
COPY --from=frontend /build/dist /app/static
|
||||||
|
RUN chown -R app:app /app
|
||||||
|
USER app
|
||||||
EXPOSE 4000
|
EXPOSE 4000
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "4000"]
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "4000"]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
import time
|
||||||
import jwt
|
import jwt
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
import pyotp
|
import pyotp
|
||||||
@@ -19,14 +20,14 @@ def get_password_hash(password):
|
|||||||
|
|
||||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||||
to_encode = data.copy()
|
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"})
|
to_encode.update({"exp": expire, "type": "access"})
|
||||||
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
|
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
|
||||||
|
|
||||||
def create_refresh_token(data: dict):
|
def create_refresh_token(data: dict):
|
||||||
to_encode = data.copy()
|
to_encode = data.copy()
|
||||||
expire = datetime.utcnow() + timedelta(minutes=config.REFRESH_TOKEN_EXPIRE_MINUTES)
|
expire = datetime.now(timezone.utc) + timedelta(minutes=config.REFRESH_TOKEN_EXPIRE_MINUTES)
|
||||||
to_encode.update({"exp": expire, "type": "refresh"})
|
to_encode.update({"exp": expire, "type": "refresh", "tv": _load_token_version()})
|
||||||
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
|
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
|
||||||
|
|
||||||
async def get_current_user(token: str = Depends(oauth2_scheme)):
|
async def get_current_user(token: str = Depends(oauth2_scheme)):
|
||||||
@@ -70,6 +71,13 @@ import hashlib
|
|||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
def _get_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())
|
key = base64.urlsafe_b64encode(hashlib.sha256(config.SECRET_KEY.encode()).digest())
|
||||||
return Fernet(key)
|
return Fernet(key)
|
||||||
|
|
||||||
@@ -83,6 +91,11 @@ def _decrypt(ciphertext: str) -> str:
|
|||||||
return ""
|
return ""
|
||||||
try:
|
try:
|
||||||
return _get_fernet().decrypt(ciphertext.encode()).decode()
|
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:
|
except Exception:
|
||||||
return ciphertext # fallback for unencrypted legacy values
|
return ciphertext # fallback for unencrypted legacy values
|
||||||
|
|
||||||
@@ -125,7 +138,16 @@ def verify_totp(token: str, secret: str = None):
|
|||||||
if not secret:
|
if not secret:
|
||||||
return True # 2FA not enabled
|
return True # 2FA not enabled
|
||||||
totp = pyotp.TOTP(secret)
|
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
|
# Passkey credentials
|
||||||
def load_passkey_credentials():
|
def load_passkey_credentials():
|
||||||
@@ -142,3 +164,17 @@ def clear_passkey_credentials():
|
|||||||
data = _load_auth_data()
|
data = _load_auth_data()
|
||||||
data["passkey_credentials"] = []
|
data["passkey_credentials"] = []
|
||||||
_save_auth_data(data)
|
_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()
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ VOLUME_ROOT = os.environ.get("VOLUME_ROOT", "/volume1")
|
|||||||
|
|
||||||
# Auth
|
# Auth
|
||||||
SECRET_KEY = os.environ.get("SECRET_KEY", "")
|
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"
|
ALGORITHM = "HS256"
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||||
REFRESH_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
|
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_HOST = os.environ.get("SSH_HOST", "host.docker.internal")
|
||||||
SSH_USER = os.environ.get("SSH_USER", "zjgump")
|
SSH_USER = os.environ.get("SSH_USER", "zjgump")
|
||||||
SSH_KEY_PATH = os.environ.get("SSH_KEY_PATH", "/app/ssh/id_ed25519")
|
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_HOST = os.environ.get("VPS_SSH_HOST", "158.101.140.85")
|
||||||
VPS_SSH_USER = os.environ.get("VPS_SSH_USER", "jimmyg")
|
VPS_SSH_USER = os.environ.get("VPS_SSH_USER", "jimmyg")
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ app.add_middleware(
|
|||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=CORS_ORIGINS,
|
allow_origins=CORS_ORIGINS,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
|
||||||
allow_headers=["*"],
|
allow_headers=["Authorization", "Content-Type"],
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.exception_handler(RateLimitExceeded)
|
@app.exception_handler(RateLimitExceeded)
|
||||||
@@ -118,7 +118,7 @@ async def health():
|
|||||||
async def client_ip(request: Request):
|
async def client_ip(request: Request):
|
||||||
ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip() or request.client.host
|
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())
|
lan = ip.startswith("192.168.31.") or (ip.startswith("100.") and _router_reachable())
|
||||||
return {"ip": ip, "lan": lan}
|
return {"lan": lan}
|
||||||
|
|
||||||
def _router_reachable():
|
def _router_reachable():
|
||||||
import socket
|
import socket
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -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"))
|
asyncio.create_task(send_failed_login_alert(client_ip, creds.username, "Incorrect username or password"))
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Incorrect username or password",
|
detail="Invalid credentials",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check 2FA
|
# Check 2FA
|
||||||
totp_secret = auth.load_totp_secret()
|
totp_secret = auth.load_totp_secret()
|
||||||
if 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"))
|
asyncio.create_task(send_failed_login_alert(client_ip, creds.username, "Invalid 2FA Code"))
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Invalid 2FA code",
|
detail="Invalid credentials",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
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)}
|
return {"username": current_user, "has_2fa": bool(totp_secret)}
|
||||||
|
|
||||||
@router.post("/refresh")
|
@router.post("/refresh")
|
||||||
async def refresh_token(req: RefreshRequest):
|
@limiter.limit("10/minute")
|
||||||
|
async def refresh_token(req: RefreshRequest, request: Request):
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(req.refresh_token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
payload = jwt.decode(req.refresh_token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
||||||
if payload.get("type") != "refresh":
|
if payload.get("type") != "refresh":
|
||||||
@@ -123,8 +124,11 @@ async def refresh_token(req: RefreshRequest):
|
|||||||
username = payload.get("sub")
|
username = payload.get("sub")
|
||||||
if username != config.ADMIN_USER:
|
if username != config.ADMIN_USER:
|
||||||
raise HTTPException(status_code=401, detail="Invalid 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:
|
except jwt.PyJWTError:
|
||||||
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
||||||
|
auth.increment_token_version()
|
||||||
access_token = auth.create_access_token(
|
access_token = auth.create_access_token(
|
||||||
data={"sub": username},
|
data={"sub": username},
|
||||||
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
|
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}
|
return {"ok": True}
|
||||||
|
|
||||||
@router.post("/passkey/login/options")
|
@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()
|
creds = auth.load_passkey_credentials()
|
||||||
if not creds:
|
if not creds:
|
||||||
raise HTTPException(status_code=404, detail="No passkeys registered")
|
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)))
|
return JSONResponse(content=json.loads(options_to_json(options)))
|
||||||
|
|
||||||
@router.post("/passkey/login/verify")
|
@router.post("/passkey/login/verify")
|
||||||
|
@limiter.limit("10/minute")
|
||||||
async def passkey_login_verify(request: Request):
|
async def passkey_login_verify(request: Request):
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
challenge = _get_challenge()
|
challenge = _get_challenge()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import docker
|
import docker
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, Query
|
||||||
from config import DOCKER_HOST
|
from config import DOCKER_HOST
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -31,6 +31,6 @@ def container_action(container_id: str, action: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/containers/{container_id}/logs")
|
@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)
|
c = client.containers.get(container_id)
|
||||||
return {"logs": c.logs(tail=tail, timestamps=True).decode(errors="replace")}
|
return {"logs": c.logs(tail=tail, timestamps=True).decode(errors="replace")}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import APIRouter, UploadFile, File, HTTPException
|
from fastapi import APIRouter, UploadFile, File, HTTPException
|
||||||
@@ -8,12 +9,16 @@ from config import VOLUME_ROOT
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
BASE = Path(VOLUME_ROOT)
|
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:
|
def _safe_path(path: str) -> Path:
|
||||||
target = (BASE / path).resolve()
|
target = (BASE / path).resolve()
|
||||||
if not str(target).startswith(str(BASE.resolve())):
|
if not str(target).startswith(_BASE_RESOLVED):
|
||||||
raise ValueError("forbidden")
|
raise ValueError("forbidden")
|
||||||
# Block access to sensitive directories
|
# Block access to sensitive directories
|
||||||
rel = target.relative_to(BASE.resolve())
|
rel = target.relative_to(BASE.resolve())
|
||||||
@@ -22,6 +27,32 @@ def _safe_path(path: str) -> Path:
|
|||||||
return target
|
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")
|
@router.get("/browse")
|
||||||
def browse(path: str = ""):
|
def browse(path: str = ""):
|
||||||
try:
|
try:
|
||||||
@@ -31,9 +62,11 @@ def browse(path: str = ""):
|
|||||||
entries = []
|
entries = []
|
||||||
for item in sorted(target.iterdir()):
|
for item in sorted(target.iterdir()):
|
||||||
try:
|
try:
|
||||||
|
if not _is_safe_symlink(item):
|
||||||
|
continue
|
||||||
st = item.stat()
|
st = item.stat()
|
||||||
entries.append({"name": item.name, "is_dir": item.is_dir(), "size": st.st_size if item.is_file() else 0})
|
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
|
continue
|
||||||
return {"path": str(target.relative_to(BASE)), "entries": entries}
|
return {"path": str(target.relative_to(BASE)), "entries": entries}
|
||||||
|
|
||||||
@@ -49,11 +82,20 @@ def download(path: str):
|
|||||||
@router.post("/upload")
|
@router.post("/upload")
|
||||||
async def upload(path: str, file: UploadFile = File(...)):
|
async def upload(path: str, file: UploadFile = File(...)):
|
||||||
try:
|
try:
|
||||||
target = _safe_path(path) / file.filename
|
safe_name = _sanitize_filename(file.filename)
|
||||||
|
target = _safe_path(path) / safe_name
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
# Stream with size check
|
||||||
|
total = 0
|
||||||
with open(target, "wb") as f:
|
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}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import re
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, HTTPException
|
||||||
from config import GITEA_URL, GITEA_TOKEN
|
from config import GITEA_URL, GITEA_TOKEN
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
HEADERS = {"Authorization": f"token {GITEA_TOKEN}"} if GITEA_TOKEN else {}
|
HEADERS = {"Authorization": f"token {GITEA_TOKEN}"} if GITEA_TOKEN else {}
|
||||||
|
_SAFE_NAME = re.compile(r'^[a-zA-Z0-9._-]+$')
|
||||||
|
|
||||||
|
|
||||||
@router.get("/repos")
|
@router.get("/repos")
|
||||||
@@ -15,6 +17,8 @@ async def list_repos():
|
|||||||
|
|
||||||
@router.get("/repos/{owner}/{repo}/commits")
|
@router.get("/repos/{owner}/{repo}/commits")
|
||||||
async def list_commits(owner: str, repo: str, page: int = 1):
|
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:
|
async with httpx.AsyncClient() as c:
|
||||||
r = await c.get(f"{GITEA_URL}/api/v1/repos/{owner}/{repo}/commits", headers=HEADERS, params={"page": page})
|
r = await c.get(f"{GITEA_URL}/api/v1/repos/{owner}/{repo}/commits", headers=HEADERS, params={"page": page})
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import psutil
|
|||||||
import platform
|
import platform
|
||||||
import time
|
import time
|
||||||
import httpx
|
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
|
from config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, VOLUME_ROOT, OPENCLAW_GATEWAY_TOKEN
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -115,5 +115,8 @@ def _classify(method, path, status, user):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/openclaw-token")
|
@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}
|
return {"token": OPENCLAW_GATEWAY_TOKEN}
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import struct
|
import struct
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
from fastapi import WebSocket, WebSocketDisconnect, Query
|
from fastapi import WebSocket, WebSocketDisconnect, Query
|
||||||
import asyncssh
|
import asyncssh
|
||||||
import jwt
|
import jwt
|
||||||
import config
|
import config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MAX_SESSIONS = 5
|
||||||
|
IDLE_TIMEOUT = 30 * 60 # 30 minutes
|
||||||
|
_active_sessions: set = set()
|
||||||
|
|
||||||
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},
|
||||||
"vps": {"host": config.VPS_SSH_HOST, "user": config.VPS_SSH_USER, "key": config.VPS_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"},
|
"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:
|
try:
|
||||||
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
|
first_msg = await asyncio.wait_for(websocket.receive_text(), timeout=5)
|
||||||
if payload.get("sub") != config.ADMIN_USER:
|
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")
|
await websocket.close(code=1008, reason="Unauthorized")
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
await websocket.close(code=1008, reason="Unauthorized")
|
await websocket.close(code=1008, reason="Unauthorized")
|
||||||
return
|
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"])
|
h = HOSTS.get(host, HOSTS["nas"])
|
||||||
try:
|
try:
|
||||||
conn = await asyncssh.connect(
|
conn = await asyncssh.connect(
|
||||||
h["host"], username=h["user"],
|
h["host"], username=h["user"],
|
||||||
client_keys=[h["key"]], known_hosts=None,
|
client_keys=[h["key"]], known_hosts=_known_hosts,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
_active_sessions.discard(session_id)
|
||||||
await websocket.close(code=1011, reason="SSH connection failed")
|
await websocket.close(code=1011, reason="SSH connection failed")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -52,10 +77,18 @@ async def ws_endpoint(websocket: WebSocket, token: str = Query(""), host: str =
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
read_task = asyncio.create_task(read_ssh())
|
read_task = asyncio.create_task(read_ssh())
|
||||||
|
last_activity = time.monotonic()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
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"]:
|
if "bytes" in msg and msg["bytes"]:
|
||||||
data = msg["bytes"]
|
data = msg["bytes"]
|
||||||
if data[0:1] == b"\x01" and len(data) == 5:
|
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()
|
process.close()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
_active_sessions.discard(session_id)
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ services:
|
|||||||
- /volume1:/volume1
|
- /volume1:/volume1
|
||||||
- /volume1/docker/nas-dashboard/ssh/dashboard_terminal:/app/ssh/id_ed25519:ro
|
- /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/vps_terminal:/app/ssh/vps_id_ed25519:ro
|
||||||
|
- /volume1/docker/nas-dashboard/ssh/known_hosts:/app/ssh/known_hosts:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4000/api/health')"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4000/api/health')"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
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(
|
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";
|
ws.binaryType = "arraybuffer";
|
||||||
function sendSize() {
|
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.onmessage = (e) => term.write(new Uint8Array(e.data));
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
updateTab(tabId, { connected: false });
|
updateTab(tabId, { connected: false });
|
||||||
|
|||||||
Reference in New Issue
Block a user