Merge pull request 'fix: harden dashboard auth and terminal flows' (#26) from dev into main
Deploy Dashboard / deploy (push) Successful in 3m38s
Deploy Dashboard / deploy (push) Successful in 3m38s
This commit was merged in pull request #26.
This commit is contained in:
+42
-16
@@ -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,6 +134,7 @@ 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():
|
||||||
|
with _auth_lock:
|
||||||
try:
|
try:
|
||||||
if os.path.exists(AUTH_FILE):
|
if os.path.exists(AUTH_FILE):
|
||||||
with open(AUTH_FILE, "r") as f:
|
with open(AUTH_FILE, "r") as f:
|
||||||
@@ -117,9 +144,7 @@ def _load_auth_data():
|
|||||||
return {}
|
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
|
|
||||||
|
def mutator(data):
|
||||||
current_ts = int(time.time()) // 30
|
current_ts = int(time.time()) // 30
|
||||||
data = _load_auth_data()
|
|
||||||
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
@@ -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 "")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 save_rbac(data: dict):
|
def _atomic_json_write(path: str, data: dict):
|
||||||
os.makedirs(os.path.dirname(RBAC_FILE), exist_ok=True)
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
with open(RBAC_FILE, "w") as f:
|
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)
|
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]:
|
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:
|
||||||
|
|||||||
@@ -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()
|
|
||||||
|
def mutator(rbac):
|
||||||
existing = rbac.setdefault("user_overrides", {}).get(username, {})
|
existing = rbac.setdefault("user_overrides", {}).get(username, {})
|
||||||
existing["pages"] = pages
|
existing["pages"] = pages
|
||||||
rbac["user_overrides"][username] = existing
|
rbac["user_overrides"][username] = existing
|
||||||
save_rbac(rbac)
|
|
||||||
|
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()
|
|
||||||
|
def mutator(rbac):
|
||||||
rbac.get("user_overrides", {}).pop(username, None)
|
rbac.get("user_overrides", {}).pop(username, None)
|
||||||
save_rbac(rbac)
|
|
||||||
|
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()
|
|
||||||
|
def mutator(rbac):
|
||||||
role_config = {"pages": pages}
|
role_config = {"pages": pages}
|
||||||
if sidebar_links is not None:
|
if sidebar_links is not None:
|
||||||
role_config["sidebar_links"] = sidebar_links
|
role_config["sidebar_links"] = sidebar_links
|
||||||
rbac.setdefault("role_defaults", {})[role] = role_config
|
rbac.setdefault("role_defaults", {})[role] = role_config
|
||||||
save_rbac(rbac)
|
|
||||||
|
update_rbac(mutator)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|||||||
@@ -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 == BASE.resolve():
|
||||||
|
raise HTTPException(status_code=403, detail="Cannot delete base root")
|
||||||
|
|
||||||
|
try:
|
||||||
if target.is_dir():
|
if target.is_dir():
|
||||||
|
if not recursive:
|
||||||
|
raise HTTPException(status_code=400, detail="Directory deletion requires recursive=true")
|
||||||
shutil.rmtree(target)
|
shutil.rmtree(target)
|
||||||
else:
|
else:
|
||||||
target.unlink()
|
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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
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.onmessage = (e) => term.write(new Uint8Array(e.data));
|
||||||
ws.onclose = (e) => {
|
ws.onclose = (e) => {
|
||||||
const reason = e.reason || `Connection closed (code ${e.code})`;
|
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 });
|
updateTab(tabId, { connected: false, error: reason });
|
||||||
term.write(`\r\n\x1b[90m[${reason}]\x1b[0m`);
|
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(() => {
|
const d = tabData.get(tabId);
|
||||||
if (ws.readyState === 1) ws.send("__ping__");
|
if (d) {
|
||||||
}, 25000);
|
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user