24 Commits

Author SHA1 Message Date
Gan, Jimmy 10df54f2fe fix: add tmux to claude-dev tooling
Add tmux to the claude-dev runtime contract so persistent terminal sessions can rely on it, and make the Claude preflight patch resilient to current CLI builds so image builds keep passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-07 11:05:32 +08:00
jimmy bab7fddb90 Merge pull request 'fix: raise terminal per-user session cap' (#28) from dev into main
Deploy Dashboard / deploy (push) Successful in 58s
2026-03-07 10:45:38 +08:00
jimmy 6e751ef92f Merge pull request 'fix: add security log investigation filters' (#27) from dev into main
Deploy Dashboard / deploy (push) Successful in 1m18s
2026-03-07 10:14:41 +08:00
jimmy 23598a5af1 Merge pull request 'fix: harden dashboard auth and terminal flows' (#26) from dev into main
Deploy Dashboard / deploy (push) Successful in 3m38s
2026-03-07 09:52:53 +08:00
jimmy 9252f5d28a Merge pull request 'fix: keep terminal sessions alive' (#25) from dev into main
Deploy Dashboard / deploy (push) Successful in 1m41s
2026-03-06 16:49:53 +08:00
jimmy 77c676c566 Merge pull request 'Merge dev into main' (#24) from dev into main
Deploy Dashboard / deploy (push) Successful in 57s
2026-03-06 15:21:10 +08:00
jimmy 161f43528a Merge pull request 'Merge dev into main' (#23) from dev into main
Deploy Dashboard / deploy (push) Successful in 42s
2026-03-06 09:21:19 +08:00
jimmy 0de936b920 Merge pull request 'Merge dev into main' (#22) from dev into main
Deploy Dashboard / deploy (push) Failing after 2m4s
2026-03-06 08:54:32 +08:00
jimmy a496e7f3bc Merge pull request 'Merge dev into main' (#21) from dev into main
Deploy Dashboard / deploy (push) Successful in 1m55s
2026-03-03 09:32:51 +08:00
jimmy b4d79918be Merge pull request 'Merge dev into main' (#20) from dev into main
Deploy Dashboard / deploy (push) Successful in 1m12s
2026-03-03 02:30:11 +08:00
jimmy fa8f12f67a Merge pull request 'Merge dev into main' (#19) from dev into main
Deploy Dashboard / deploy (push) Successful in 2m46s
2026-03-03 00:18:14 +08:00
jimmy 2a5d8c77e8 Merge pull request 'Merge dev into main (docs sync)' (#18) from dev into main 2026-03-02 04:01:39 +08:00
jimmy d8b8944234 Merge pull request 'Merge dev into main' (#17) from dev into main
Deploy Dashboard / deploy (push) Successful in 1m1s
2026-03-02 03:56:49 +08:00
jimmy aac297ab3e Merge pull request 'merge dev into main: sidebar network link fixes' (#16) from dev into main
Deploy Dashboard / deploy (push) Successful in 41s
2026-03-01 22:57:15 +08:00
jimmy b0b54b2ebb Merge pull request 'Refactor auth router and add performance optimizations' (#15) from dev into main
Deploy Dashboard / deploy (push) Successful in 45s
2026-03-01 22:25:14 +08:00
jimmy 12ca6c7af4 Merge pull request 'feat: add role-based sidebar link visibility control' (#14) from dev into main
Deploy Dashboard / deploy (push) Successful in 31s
2026-03-01 18:19:10 +08:00
jimmy c5a3702496 Merge pull request 'perf: add Docker layer caching to dev CI workflow' (#13) from dev into main
Deploy Dashboard / deploy (push) Successful in 16s
2026-03-01 18:10:08 +08:00
jimmy 677997fb75 Merge pull request 'fix: add missing settings page to role access control' (#12) from dev into main
Deploy Dashboard / deploy (push) Successful in 40s
2026-03-01 18:06:22 +08:00
jimmy fbf5f6908e Merge pull request 'feat: add UI to edit role default page permissions' (#11) from dev into main
Deploy Dashboard / deploy (push) Successful in 46s
2026-03-01 17:58:36 +08:00
jimmy 01394d709a Merge pull request 'fix: sidebar drag-and-drop persistence + CSP warning' (#10) from dev into main
Deploy Dashboard / deploy (push) Successful in 1m2s
2026-03-01 17:52:08 +08:00
jimmy e0d5e59ce9 Merge pull request 'fix: sidebar improvements' (#9) from dev into main
Deploy Dashboard / deploy (push) Successful in 59s
2026-03-01 16:51:34 +08:00
jimmy 22e35698f3 Merge pull request 'fix: add drop zone placeholder for empty sidebar categories' (#8) from dev into main
Deploy Dashboard / deploy (push) Successful in 53s
2026-03-01 16:22:10 +08:00
jimmy a3bac56275 Merge pull request 'feat: enable Immich ML, dark mode fixes, sidebar improvements' (#6) from dev into main
Deploy Dashboard / deploy (push) Successful in 39s
2026-03-01 16:14:35 +08:00
jimmy 3306c8ce51 Merge pull request 'feat: RBAC multi-user role-based access control' (#5) from dev into main
Deploy Dashboard / deploy (push) Successful in 26s
2026-03-01 14:20:49 +08:00
16 changed files with 127 additions and 369 deletions
+21 -47
View File
@@ -6,7 +6,7 @@ import tempfile
import jwt import jwt
from passlib.context import CryptContext from passlib.context import CryptContext
import pyotp import pyotp
from fastapi import Depends, HTTPException, Response, status from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
import config import config
@@ -14,12 +14,6 @@ import config
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login")
ACCESS_COOKIE_NAME = "nas_access_token"
REFRESH_COOKIE_NAME = "nas_refresh_token"
COOKIE_SECURE = True
COOKIE_SAMESITE = "lax"
COOKIE_PATH = "/"
def verify_password(plain_password, hashed_password): def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password) return pwd_context.verify(plain_password, hashed_password)
@@ -32,49 +26,18 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
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.now(timezone.utc) + 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", "tv": _load_token_version()}) 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)):
def _cookie_max_age(minutes: int) -> int:
return max(60, int(minutes * 60))
def set_auth_cookies(response: Response, access_token: str, refresh_token: str):
response.set_cookie(
key=ACCESS_COOKIE_NAME,
value=access_token,
httponly=True,
secure=COOKIE_SECURE,
samesite=COOKIE_SAMESITE,
path=COOKIE_PATH,
max_age=_cookie_max_age(config.ACCESS_TOKEN_EXPIRE_MINUTES),
)
response.set_cookie(
key=REFRESH_COOKIE_NAME,
value=refresh_token,
httponly=True,
secure=COOKIE_SECURE,
samesite=COOKIE_SAMESITE,
path=COOKIE_PATH,
max_age=_cookie_max_age(config.REFRESH_TOKEN_EXPIRE_MINUTES),
)
def clear_auth_cookies(response: Response):
response.delete_cookie(ACCESS_COOKIE_NAME, path=COOKIE_PATH)
response.delete_cookie(REFRESH_COOKIE_NAME, path=COOKIE_PATH)
def user_from_access_token(token: str, include_auth_header: bool = True):
from rbac import User, get_pages, get_sidebar_links from rbac import User, get_pages, get_sidebar_links
credentials_exception = HTTPException( credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials", detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"} if include_auth_header else None, headers={"WWW-Authenticate": "Bearer"},
) )
try: try:
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM]) payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
@@ -86,18 +49,29 @@ def user_from_access_token(token: str, include_auth_header: bool = True):
raise credentials_exception raise credentials_exception
except jwt.PyJWTError: except jwt.PyJWTError:
raise credentials_exception raise credentials_exception
pages = get_pages(username, role) pages = get_pages(username, role)
sidebar_links = get_sidebar_links(username, role) sidebar_links = get_sidebar_links(username, role)
return User(username=username, role=role, pages=pages, sidebar_links=sidebar_links) return User(username=username, role=role, pages=pages, sidebar_links=sidebar_links)
async def get_current_user(token: str = Depends(oauth2_scheme)):
return user_from_access_token(token)
async def get_current_user_ws(token: str): async def get_current_user_ws(token: str):
return user_from_access_token(token, include_auth_header=False) from rbac import User, get_pages, get_sidebar_links
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
)
try:
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
if payload.get("type") != "access":
raise credentials_exception
username: str = payload.get("sub")
role: str = payload.get("role", "admin")
if username is None:
raise credentials_exception
except jwt.PyJWTError:
raise credentials_exception
pages = get_pages(username, role)
sidebar_links = get_sidebar_links(username, role)
return User(username=username, role=role, pages=pages, sidebar_links=sidebar_links)
# TOTP Persistence # TOTP Persistence
AUTH_FILE = config.VOLUME_ROOT + "/docker/nas-dashboard/auth.json" AUTH_FILE = config.VOLUME_ROOT + "/docker/nas-dashboard/auth.json"
+2 -2
View File
@@ -41,8 +41,8 @@ TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "") TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
TELEGRAM_PROXY = os.environ.get("TELEGRAM_PROXY", "") TELEGRAM_PROXY = os.environ.get("TELEGRAM_PROXY", "")
WEBAUTHN_RP_ID = os.environ.get("WEBAUTHN_RP_ID", "jimmygan.com") WEBAUTHN_RP_ID = os.environ.get("WEBAUTHN_RP_ID", "nas.jimmygan.com")
WEBAUTHN_ORIGINS = os.environ.get("WEBAUTHN_ORIGINS", "https://nas.jimmygan.com,https://nas.jimmygan.com:8443").split(",") WEBAUTHN_ORIGINS = os.environ.get("WEBAUTHN_ORIGIN", "https://nas.jimmygan.com,https://nas.jimmygan.com:8443").split(",")
# CORS # CORS
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "https://nas.jimmygan.com,https://nas.jimmygan.com:8443").split(",") CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "https://nas.jimmygan.com,https://nas.jimmygan.com:8443").split(",")
+4 -4
View File
@@ -9,7 +9,7 @@ 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 import config
from rbac import require_page from rbac import require_page, require_write
import asyncio import asyncio
import httpx import httpx
@@ -160,11 +160,11 @@ app.include_router(totp.router, prefix="/api/auth", tags=["totp"])
# Protected endpoints with page-level RBAC # Protected endpoints with page-level RBAC
app.include_router(docker_router.router, prefix="/api/docker", app.include_router(docker_router.router, prefix="/api/docker",
dependencies=[Depends(_inject_user), Depends(require_page("docker"))]) dependencies=[Depends(_inject_user), Depends(require_page("docker")), Depends(require_write())])
app.include_router(gitea.router, prefix="/api/gitea", app.include_router(gitea.router, prefix="/api/gitea",
dependencies=[Depends(_inject_user), Depends(require_page("gitea"))]) dependencies=[Depends(_inject_user), Depends(require_page("gitea"))])
app.include_router(files.router, prefix="/api/files", app.include_router(files.router, prefix="/api/files",
dependencies=[Depends(_inject_user), Depends(require_page("files"))]) dependencies=[Depends(_inject_user), Depends(require_page("files")), Depends(require_write())])
app.include_router(system.router, prefix="/api/system", app.include_router(system.router, prefix="/api/system",
dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))]) dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))])
app.include_router(litellm.router, prefix="/api/litellm", app.include_router(litellm.router, prefix="/api/litellm",
@@ -178,7 +178,7 @@ app.include_router(info_engine.router, prefix="/api/info-engine",
app.include_router(security.router, prefix="/api/security", app.include_router(security.router, prefix="/api/security",
dependencies=[Depends(_inject_user), Depends(require_page("security"))]) dependencies=[Depends(_inject_user), Depends(require_page("security"))])
# WebSocket endpoint (auth handled inside handler via auth cookie; query token fallback temporary) # WebSocket endpoint (auth handled inside handler via Query param)
app.add_api_websocket_route("/ws/terminal", terminal.ws_endpoint) app.add_api_websocket_route("/ws/terminal", terminal.ws_endpoint)
app.mount("/", StaticFiles(directory="/app/static", html=True), name="static") app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")
+31 -78
View File
@@ -2,8 +2,7 @@
from datetime import timedelta from datetime import timedelta
import asyncio import asyncio
import httpx import httpx
from typing import Optional from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi import APIRouter, Body, Depends, HTTPException, status, Request, Response
from pydantic import BaseModel from pydantic import BaseModel
from slowapi import Limiter from slowapi import Limiter
from slowapi.util import get_remote_address from slowapi.util import get_remote_address
@@ -16,13 +15,11 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
limiter = Limiter(key_func=get_remote_address) limiter = Limiter(key_func=get_remote_address)
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
username: str username: str
password: str password: str
totp_code: str = "" totp_code: str = ""
class Token(BaseModel): class Token(BaseModel):
access_token: str access_token: str
refresh_token: str refresh_token: str
@@ -31,46 +28,8 @@ class Token(BaseModel):
has_2fa: bool has_2fa: bool
role: str = "admin" role: str = "admin"
class RefreshRequest(BaseModel): class RefreshRequest(BaseModel):
refresh_token: str = "" refresh_token: str
def _set_auth_response_tokens(response: Response, access_token: str, refresh_token: str):
auth.set_auth_cookies(response, access_token, refresh_token)
def _extract_refresh_token(request: Request, req: Optional[RefreshRequest] = None) -> str:
cookie_token = request.cookies.get(auth.REFRESH_COOKIE_NAME, "")
if cookie_token:
return cookie_token
if req and req.refresh_token:
return req.refresh_token
raise HTTPException(status_code=401, detail="Missing refresh token")
async def _refresh_tokens_from_value(refresh_token_value: str):
try:
payload = jwt.decode(refresh_token_value, config.SECRET_KEY, algorithms=[config.ALGORITHM])
if payload.get("type") != "refresh":
raise HTTPException(status_code=401, detail="Invalid token type")
username = payload.get("sub")
role = payload.get("role", "admin")
if username is None:
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, "role": role},
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
)
refresh_token = auth.create_refresh_token(data={"sub": username, "role": role})
return access_token, refresh_token
async def send_failed_login_alert(ip_address: str, username: str, reason: str): async def send_failed_login_alert(ip_address: str, username: str, reason: str):
logger.info("Triggering Telegram alert for %s from %s: %s", username, ip_address, reason) logger.info("Triggering Telegram alert for %s from %s: %s", username, ip_address, reason)
@@ -93,10 +52,9 @@ async def send_failed_login_alert(ip_address: str, username: str, reason: str):
except Exception as e: except Exception as e:
logger.warning("Failed to send Telegram alert: %s", e) logger.warning("Failed to send Telegram alert: %s", e)
@router.post("/login", response_model=Token) @router.post("/login", response_model=Token)
@limiter.limit("5/minute") @limiter.limit("5/minute")
async def login(creds: LoginRequest, request: Request, response: Response): async def login(creds: LoginRequest, request: Request):
client_ip = request.client.host client_ip = request.client.host
# Verify username/password # Verify username/password
if creds.username != config.ADMIN_USER or not auth.verify_password(creds.password, auth.load_password_hash()): if creds.username != config.ADMIN_USER or not auth.verify_password(creds.password, auth.load_password_hash()):
@@ -131,7 +89,6 @@ async def login(creds: LoginRequest, request: Request, response: Response):
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES), expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
) )
refresh_token = auth.create_refresh_token(data={"sub": creds.username, "role": "admin"}) refresh_token = auth.create_refresh_token(data={"sub": creds.username, "role": "admin"})
_set_auth_response_tokens(response, access_token, refresh_token)
return { return {
"access_token": access_token, "access_token": access_token,
"refresh_token": refresh_token, "refresh_token": refresh_token,
@@ -141,9 +98,8 @@ async def login(creds: LoginRequest, request: Request, response: Response):
"role": "admin", "role": "admin",
} }
@router.get("/proxy") @router.get("/proxy")
async def proxy_auth(request: Request, response: Response): 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."""
from rbac import resolve_role from rbac import resolve_role
@@ -169,7 +125,6 @@ async def proxy_auth(request: Request, response: Response):
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES), expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
) )
refresh_token = auth.create_refresh_token(data={"sub": remote_user, "role": role}) refresh_token = auth.create_refresh_token(data={"sub": remote_user, "role": role})
_set_auth_response_tokens(response, access_token, refresh_token)
return { return {
"access_token": access_token, "access_token": access_token,
"refresh_token": refresh_token, "refresh_token": refresh_token,
@@ -179,9 +134,8 @@ async def proxy_auth(request: Request, response: Response):
"role": role, "role": role,
} }
@router.get("/me") @router.get("/me")
async def read_users_me(current_user=Depends(auth.get_current_user)): async def read_users_me(current_user = Depends(auth.get_current_user)):
totp_secret = auth.load_totp_secret() totp_secret = auth.load_totp_secret()
return { return {
"username": current_user.username, "username": current_user.username,
@@ -191,31 +145,36 @@ async def read_users_me(current_user=Depends(auth.get_current_user)):
"has_2fa": bool(totp_secret), "has_2fa": bool(totp_secret),
} }
@router.post("/refresh") @router.post("/refresh")
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def refresh_token(request: Request, response: Response, req: Optional[RefreshRequest] = Body(default=None)): async def refresh_token(req: RefreshRequest, request: Request):
refresh_token_value = _extract_refresh_token(request, req) try:
access_token, refresh_token = await _refresh_tokens_from_value(refresh_token_value) payload = jwt.decode(req.refresh_token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
_set_auth_response_tokens(response, access_token, refresh_token) if payload.get("type") != "refresh":
return {"access_token": access_token, "refresh_token": refresh_token} raise HTTPException(status_code=401, detail="Invalid token type")
username = payload.get("sub")
role = payload.get("role", "admin")
@router.post("/logout") if username is None:
async def logout(response: Response): 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() auth.increment_token_version()
auth.clear_auth_cookies(response) access_token = auth.create_access_token(
return {"ok": True} data={"sub": username, "role": role},
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
)
refresh_token = auth.create_refresh_token(data={"sub": username, "role": role})
return {"access_token": access_token, "refresh_token": refresh_token}
class ChangePasswordRequest(BaseModel): class ChangePasswordRequest(BaseModel):
current_password: str current_password: str
new_password: str new_password: str
@router.post("/change-password") @router.post("/change-password")
@limiter.limit("5/minute") @limiter.limit("5/minute")
async def change_password(req: ChangePasswordRequest, request: Request, current_user=Depends(auth.get_current_user)): async def change_password(req: ChangePasswordRequest, request: Request, current_user = Depends(auth.get_current_user)):
if not auth.verify_password(req.current_password, auth.load_password_hash()): if not auth.verify_password(req.current_password, auth.load_password_hash()):
raise HTTPException(status_code=400, detail="Current password is incorrect") raise HTTPException(status_code=400, detail="Current password is incorrect")
if len(req.new_password) < 8: if len(req.new_password) < 8:
@@ -223,18 +182,16 @@ async def change_password(req: ChangePasswordRequest, request: Request, current_
auth.save_password_hash(auth.get_password_hash(req.new_password)) auth.save_password_hash(auth.get_password_hash(req.new_password))
return {"message": "Password changed successfully"} return {"message": "Password changed successfully"}
# RBAC admin endpoints # RBAC admin endpoints
@router.get("/rbac/config") @router.get("/rbac/config")
async def rbac_config(current_user=Depends(auth.get_current_user)): async def rbac_config(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 from rbac import load_rbac
return load_rbac() return load_rbac()
@router.put("/rbac/overrides/{username}") @router.put("/rbac/overrides/{username}")
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 update_rbac from rbac import update_rbac
@@ -251,16 +208,14 @@ async def rbac_set_override(username: str, request: Request, current_user=Depend
update_rbac(mutator) update_rbac(mutator)
return {"ok": True} return {"ok": True}
@router.get("/preferences") @router.get("/preferences")
async def get_preferences(current_user=Depends(auth.get_current_user)): async def get_preferences(current_user = Depends(auth.get_current_user)):
from rbac import get_sidebar_order from rbac import get_sidebar_order
order = get_sidebar_order(current_user.username) order = get_sidebar_order(current_user.username)
return {"sidebar_order": order} return {"sidebar_order": order}
@router.put("/preferences") @router.put("/preferences")
async def save_preferences(request: Request, current_user=Depends(auth.get_current_user)): async def save_preferences(request: Request, current_user = Depends(auth.get_current_user)):
import traceback import traceback
from rbac import save_sidebar_order from rbac import save_sidebar_order
try: try:
@@ -277,9 +232,8 @@ async def save_preferences(request: Request, current_user=Depends(auth.get_curre
traceback.print_exc() traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.delete("/rbac/overrides/{username}") @router.delete("/rbac/overrides/{username}")
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 update_rbac from rbac import update_rbac
@@ -290,9 +244,8 @@ async def rbac_delete_override(username: str, current_user=Depends(auth.get_curr
update_rbac(mutator) 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 update_rbac from rbac import update_rbac
+2 -3
View File
@@ -1,7 +1,6 @@
import docker import docker
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Query
from config import DOCKER_HOST from config import DOCKER_HOST
from rbac import require_admin
router = APIRouter() router = APIRouter()
client = docker.DockerClient(base_url=DOCKER_HOST) client = docker.DockerClient(base_url=DOCKER_HOST)
@@ -22,7 +21,7 @@ def list_containers():
] ]
@router.post("/containers/{container_id}/{action}", dependencies=[Depends(require_admin())]) @router.post("/containers/{container_id}/{action}")
def container_action(container_id: str, action: str): def container_action(container_id: str, action: str):
if action not in ("start", "stop", "restart"): if action not in ("start", "stop", "restart"):
return {"error": "invalid action"} return {"error": "invalid action"}
+3 -4
View File
@@ -2,10 +2,9 @@ import os
import re import re
import shutil import shutil
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException from fastapi import APIRouter, UploadFile, File, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from config import VOLUME_ROOT from config import VOLUME_ROOT
from rbac import require_admin
router = APIRouter() router = APIRouter()
BASE = Path(VOLUME_ROOT) BASE = Path(VOLUME_ROOT)
@@ -80,7 +79,7 @@ def download(path: str):
raise HTTPException(status_code=403, detail="Access denied") raise HTTPException(status_code=403, detail="Access denied")
@router.post("/upload", dependencies=[Depends(require_admin())]) @router.post("/upload")
async def upload(path: str, file: UploadFile = File(...)): async def upload(path: str, file: UploadFile = File(...)):
try: try:
safe_name = _sanitize_filename(file.filename) safe_name = _sanitize_filename(file.filename)
@@ -100,7 +99,7 @@ async def upload(path: str, file: UploadFile = File(...)):
return {"ok": True} return {"ok": True}
@router.delete("/delete", dependencies=[Depends(require_admin())]) @router.delete("/delete")
def delete(path: str, recursive: bool = False): def delete(path: str, recursive: bool = False):
try: try:
target = _safe_path(path) target = _safe_path(path)
+2 -3
View File
@@ -2,7 +2,7 @@
import json import json
import time import time
from datetime import timedelta from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from slowapi import Limiter from slowapi import Limiter
from slowapi.util import get_remote_address from slowapi.util import get_remote_address
@@ -112,7 +112,7 @@ async def passkey_login_options(request: Request):
@router.post("/passkey/login/verify") @router.post("/passkey/login/verify")
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def passkey_login_verify(request: Request, response: Response): async def passkey_login_verify(request: Request):
"""Verify passkey authentication and issue tokens.""" """Verify passkey authentication and issue tokens."""
body = await request.json() body = await request.json()
challenge = _get_challenge(body.get("response", {}).get("clientDataJSON", "")) challenge = _get_challenge(body.get("response", {}).get("clientDataJSON", ""))
@@ -146,7 +146,6 @@ async def passkey_login_verify(request: Request, response: Response):
expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES), expires_delta=timedelta(minutes=config.ACCESS_TOKEN_EXPIRE_MINUTES),
) )
refresh_token = auth.create_refresh_token(data={"sub": username, "role": "admin"}) refresh_token = auth.create_refresh_token(data={"sub": username, "role": "admin"})
auth.set_auth_cookies(response, access_token, refresh_token)
return { return {
"access_token": access_token, "access_token": access_token,
"refresh_token": refresh_token, "refresh_token": refresh_token,
+10 -24
View File
@@ -6,7 +6,7 @@ from collections import Counter
from fastapi import WebSocket, Query from fastapi import WebSocket, Query
import asyncssh import asyncssh
import config import config
import auth from auth import get_current_user_ws
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -95,13 +95,12 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str
await websocket.close(code=1008, reason="Unknown host") await websocket.close(code=1008, reason="Unknown host")
return return
access_token = websocket.cookies.get(auth.ACCESS_COOKIE_NAME) or token if not token:
if not access_token:
await websocket.close(code=1008, reason="Unauthorized") await websocket.close(code=1008, reason="Unauthorized")
return return
try: try:
user = await auth.get_current_user_ws(access_token) user = await get_current_user_ws(token)
except Exception: except Exception:
await websocket.close(code=1008, reason="Unauthorized") await websocket.close(code=1008, reason="Unauthorized")
return return
@@ -125,22 +124,13 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str
h = HOSTS[host] h = HOSTS[host]
try: try:
conn = await asyncio.wait_for( conn = await asyncssh.connect(
asyncssh.connect(
h["host"], username=h["user"], h["host"], username=h["user"],
client_keys=[h["key"]], known_hosts=_known_hosts, client_keys=[h["key"]], known_hosts=_known_hosts,
keepalive_interval=15, keepalive_interval=30,
keepalive_count_max=8, keepalive_count_max=3,
),
timeout=10.0
) )
except asyncio.TimeoutError: except Exception:
logger.error("SSH connection to %s timed out after 10s", host)
await _release_session(session_id)
await websocket.close(code=1011, reason="SSH connection timeout")
return
except Exception as e:
logger.error("SSH connection to %s failed: %s", host, e)
await _release_session(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
@@ -158,12 +148,8 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str
if not data: if not data:
break break
await websocket.send_bytes(data) await websocket.send_bytes(data)
except asyncssh.Error as e: except (asyncssh.Error, asyncio.CancelledError):
logger.warning("SSH read error for %s: %s", host, e)
except asyncio.CancelledError:
pass pass
except Exception as e:
logger.error("Unexpected error reading SSH for %s: %s", host, e)
read_task = asyncio.create_task(read_ssh()) read_task = asyncio.create_task(read_ssh())
@@ -182,8 +168,8 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str
if msg["text"] == "__ping__": if msg["text"] == "__ping__":
continue continue
process.stdin.write(msg["text"].encode()) process.stdin.write(msg["text"].encode())
except Exception as e: except Exception:
logger.info("WebSocket receive loop ended for %s: %s", host, e) pass
finally: finally:
read_task.cancel() read_task.cancel()
process.close() process.close()
-2
View File
@@ -25,8 +25,6 @@ services:
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-} - TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
- TELEGRAM_PROXY=${TELEGRAM_PROXY:-} - TELEGRAM_PROXY=${TELEGRAM_PROXY:-}
- CORS_ORIGINS=https://dev.nas.jimmygan.com,https://dev.nas.jimmygan.com:8443 - CORS_ORIGINS=https://dev.nas.jimmygan.com,https://dev.nas.jimmygan.com:8443
- WEBAUTHN_RP_ID=jimmygan.com
- WEBAUTHN_ORIGINS=https://dev.nas.jimmygan.com,https://dev.nas.jimmygan.com:8443,https://auth.jimmygan.com:8443
- LITELLM_URL=http://litellm:4005 - LITELLM_URL=http://litellm:4005
- LITELLM_HEALTH_API_KEY=${LITELLM_HEALTH_API_KEY:-} - LITELLM_HEALTH_API_KEY=${LITELLM_HEALTH_API_KEY:-}
volumes: volumes:
-2
View File
@@ -47,8 +47,6 @@ services:
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-} - TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
- TELEGRAM_PROXY=${TELEGRAM_PROXY:-} - TELEGRAM_PROXY=${TELEGRAM_PROXY:-}
- CORS_ORIGINS=https://nas.jimmygan.com - CORS_ORIGINS=https://nas.jimmygan.com
- WEBAUTHN_RP_ID=jimmygan.com
- WEBAUTHN_ORIGINS=https://nas.jimmygan.com,https://auth.jimmygan.com:8443
- LITELLM_HEALTH_API_KEY=${LITELLM_HEALTH_API_KEY:-} - LITELLM_HEALTH_API_KEY=${LITELLM_HEALTH_API_KEY:-}
- LITELLM_URL=http://litellm:4005 - LITELLM_URL=http://litellm:4005
volumes: volumes:
+7 -56
View File
@@ -14,22 +14,7 @@
import InfoEngine from "./routes/InfoEngine.svelte"; import InfoEngine from "./routes/InfoEngine.svelte";
import Login from "./routes/Login.svelte"; import Login from "./routes/Login.svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { getToken, checkAuth, setToken, currentUser, setCurrentUser, getPreferences, savePreferences, tryRefreshSession, logout as logoutSession } from "./lib/api.js"; import { getToken, checkAuth, setToken, setRefreshToken, currentUser, setCurrentUser, getPreferences, savePreferences } from "./lib/api.js";
const KNOWN_PAGES = new Set([
"dashboard",
"docker",
"gitea",
"files",
"terminal",
"openclaw",
"chat-digest",
"settings",
"security",
"litellm",
"cc-connect",
"info-engine",
]);
let page = $state("dashboard"); let page = $state("dashboard");
let dark = $state(false); let dark = $state(false);
@@ -42,26 +27,6 @@
let sidebarOrder = $state(null); let sidebarOrder = $state(null);
let orderSaveTimer = null; let orderSaveTimer = null;
function getRequestedPage() {
const requestedPage = new URLSearchParams(window.location.search).get("page");
return KNOWN_PAGES.has(requestedPage) ? requestedPage : null;
}
function canOpenPage(pageId) {
if (!pageId || !KNOWN_PAGES.has(pageId)) return false;
if (pageId === "settings") return userRole === "admin";
if (["litellm", "cc-connect", "info-engine"].includes(pageId)) return hasPageAccess("dashboard");
return hasPageAccess(pageId);
}
function syncPageQuery(pageId) {
const url = new URL(window.location.href);
if (pageId === "dashboard") url.searchParams.delete("page");
else url.searchParams.set("page", pageId);
if (pageId !== "terminal") url.searchParams.delete("host");
history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`);
}
async function fetchUserInfo() { async function fetchUserInfo() {
try { try {
const data = await checkAuth(); const data = await checkAuth();
@@ -106,14 +71,13 @@
// Try proxy auth first (Authelia forward-auth sets Remote-User header) // Try proxy auth first (Authelia forward-auth sets Remote-User header)
try { try {
const proxyRes = await fetch("/api/auth/proxy", { credentials: "same-origin" }); const proxyRes = await fetch("/api/auth/proxy");
if (proxyRes.ok) { if (proxyRes.ok) {
const data = await proxyRes.json(); const data = await proxyRes.json();
setToken(data.access_token); setToken(data.access_token);
setRefreshToken(data.refresh_token);
authorized = true; authorized = true;
await fetchUserInfo(); await fetchUserInfo();
const requestedPage = getRequestedPage();
if (canOpenPage(requestedPage)) page = requestedPage;
loading = false; loading = false;
return; return;
} }
@@ -122,20 +86,16 @@
} }
// Regular token auth check // Regular token auth check
if (!getToken()) { const token = getToken();
const refreshed = await tryRefreshSession(); if (!token) {
if (!refreshed) {
loading = false; loading = false;
authorized = false; authorized = false;
return; return;
} }
}
try { try {
await fetchUserInfo(); await fetchUserInfo();
authorized = true; authorized = true;
const requestedPage = getRequestedPage();
if (canOpenPage(requestedPage)) page = requestedPage;
} catch (e) { } catch (e) {
console.error("Auth check failed:", e); console.error("Auth check failed:", e);
setToken(""); setToken("");
@@ -145,11 +105,6 @@
} }
}); });
$effect(() => {
if (!authorized || loading || !canOpenPage(page)) return;
syncPageQuery(page);
});
function toggleTheme() { function toggleTheme() {
dark = !dark; dark = !dark;
if (dark) { if (dark) {
@@ -161,13 +116,9 @@
} }
} }
async function logout() { function logout() {
try {
await logoutSession();
} catch (e) {
console.error("Logout failed:", e);
}
setToken(""); setToken("");
setRefreshToken("");
window.location.reload(); window.location.reload();
} }
</script> </script>
+21 -45
View File
@@ -1,5 +1,5 @@
const BASE = "/api"; const BASE = "/api";
let token = ""; let token = localStorage.getItem("token") || "";
// Current user info populated after auth // Current user info populated after auth
export let currentUser = { username: "", role: "", pages: [] }; export let currentUser = { username: "", role: "", pages: [] };
@@ -18,51 +18,33 @@ export function getToken() {
return token; return token;
} }
// Refresh token is cookie-managed server-side. export function setRefreshToken(t) {
export function setRefreshToken() {} if (t) localStorage.setItem("refresh_token", t);
else localStorage.removeItem("refresh_token");
}
let refreshPromise = null; function getRefreshToken() {
function getLegacyRefreshToken() {
return localStorage.getItem("refresh_token") || ""; return localStorage.getItem("refresh_token") || "";
} }
function clearLegacyRefreshToken() { let refreshPromise = null;
localStorage.removeItem("refresh_token");
}
async function requestRefresh(body) { async function tryRefresh() {
if (refreshPromise) return refreshPromise;
const rt = getRefreshToken();
if (!rt) return false;
refreshPromise = (async () => {
try {
const r = await fetch(BASE + "/auth/refresh", { const r = await fetch(BASE + "/auth/refresh", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "same-origin", body: JSON.stringify({ refresh_token: rt }),
body: body ? JSON.stringify(body) : undefined,
}); });
if (!r.ok) return false; if (!r.ok) return false;
const data = await r.json(); const data = await r.json();
setToken(data.access_token || ""); setToken(data.access_token);
return !!data.access_token; setRefreshToken(data.refresh_token);
}
export async function tryRefreshSession() {
if (refreshPromise) return refreshPromise;
refreshPromise = (async () => {
try {
const cookieRefreshed = await requestRefresh();
if (cookieRefreshed) {
clearLegacyRefreshToken();
return true; return true;
}
const legacyRefreshToken = getLegacyRefreshToken();
if (!legacyRefreshToken) return false;
const legacyRefreshed = await requestRefresh({ refresh_token: legacyRefreshToken });
if (legacyRefreshed) {
clearLegacyRefreshToken();
return true;
}
clearLegacyRefreshToken();
return false;
} catch { } catch {
return false; return false;
} finally { } finally {
@@ -82,29 +64,27 @@ async function request(path, opts = {}) {
delete opts.json; delete opts.json;
} }
const fetchOpts = { ...opts, headers };
if (path.startsWith("/auth/")) fetchOpts.credentials = "same-origin";
try { try {
let r = await fetch(BASE + path, fetchOpts); let r = await fetch(BASE + path, { ...opts, headers });
if (r.status === 401 && !path.includes("/auth/refresh")) { if (r.status === 401 && !path.includes("/auth/refresh")) {
const refreshed = await tryRefreshSession(); const refreshed = await tryRefresh();
if (refreshed) { if (refreshed) {
headers["Authorization"] = `Bearer ${token}`; headers["Authorization"] = `Bearer ${token}`;
r = await fetch(BASE + path, fetchOpts); r = await fetch(BASE + path, { ...opts, headers });
} }
} }
if (r.status === 401) { if (r.status === 401) {
setToken(""); setToken("");
setRefreshToken("");
throw new Error("Unauthorized"); throw new Error("Unauthorized");
} }
if (!r.ok) { if (!r.ok) {
const error = new Error(`HTTP ${r.status}`); const error = new Error(`HTTP ${r.status}`);
error.status = r.status; error.status = r.status;
try { error.body = await r.json(); } catch {} try { error.body = await r.json(); } catch { }
throw error; throw error;
} }
return r.json(); return r.json();
@@ -151,10 +131,6 @@ export function checkAuth() {
return request("/auth/me"); return request("/auth/me");
} }
export function logout() {
return request("/auth/logout", { method: "POST" });
}
export function getPreferences() { export function getPreferences() {
return get("/auth/preferences"); return get("/auth/preferences");
} }
+3 -1
View File
@@ -1,5 +1,5 @@
<script> <script>
import { login, setToken } from "../lib/api.js"; import { login, setToken, setRefreshToken, get, post } from "../lib/api.js";
import { fade, fly } from "svelte/transition"; import { fade, fly } from "svelte/transition";
let username = $state(""); let username = $state("");
@@ -56,6 +56,7 @@
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || "Passkey verification failed"); } if (!res.ok) { const e = await res.json(); throw new Error(e.detail || "Passkey verification failed"); }
const data = await res.json(); const data = await res.json();
setToken(data.access_token); setToken(data.access_token);
setRefreshToken(data.refresh_token);
window.location.reload(); window.location.reload();
} catch (e) { } catch (e) {
if (e.name === "NotAllowedError") { error = "Passkey authentication cancelled"; } if (e.name === "NotAllowedError") { error = "Passkey authentication cancelled"; }
@@ -78,6 +79,7 @@
const res = await login(payload); // api.js helper sends JSON const res = await login(payload); // api.js helper sends JSON
setToken(res.access_token); setToken(res.access_token);
setRefreshToken(res.refresh_token);
// Determine if we need to reload or just update state. // Determine if we need to reload or just update state.
// Reload is safest to clear any stale state. // Reload is safest to clear any stale state.
window.location.reload(); window.location.reload();
+2 -14
View File
@@ -36,27 +36,19 @@
} }
async function registerPasskey() { async function registerPasskey() {
alert("registerPasskey called!");
passkeyLoading = true; passkeyLoading = true;
passkeyMsg = ""; passkeyErr = ""; passkeyMsg = ""; passkeyErr = "";
try { try {
alert("Checking PublicKeyCredential support...");
if (!window.PublicKeyCredential) { if (!window.PublicKeyCredential) {
alert("PublicKeyCredential NOT supported!");
throw new Error("Passkeys are not supported in this browser or context"); throw new Error("Passkeys are not supported in this browser or context");
} }
alert("Fetching registration options...");
const opts = await post("/auth/passkey/register/options"); const opts = await post("/auth/passkey/register/options");
alert("Got options: " + JSON.stringify(opts).substring(0, 100));
opts.challenge = base64urlToBuffer(opts.challenge); opts.challenge = base64urlToBuffer(opts.challenge);
opts.user.id = base64urlToBuffer(opts.user.id); opts.user.id = base64urlToBuffer(opts.user.id);
if (opts.excludeCredentials) { if (opts.excludeCredentials) {
opts.excludeCredentials = opts.excludeCredentials.map(c => ({ ...c, id: base64urlToBuffer(c.id) })); opts.excludeCredentials = opts.excludeCredentials.map(c => ({ ...c, id: base64urlToBuffer(c.id) }));
} }
const cred = await navigator.credentials.create({ publicKey: opts }); const cred = await navigator.credentials.create({ publicKey: opts });
if (!cred) {
throw new Error("Passkey creation was cancelled");
}
const name = prompt("Name this passkey (e.g. MacBook, iPhone):", "Passkey") || "Passkey"; const name = prompt("Name this passkey (e.g. MacBook, iPhone):", "Passkey") || "Passkey";
const body = { const body = {
id: cred.id, id: cred.id,
@@ -72,13 +64,9 @@
passkeyMsg = "Passkey registered successfully"; passkeyMsg = "Passkey registered successfully";
await loadPasskeys(); await loadPasskeys();
} catch (e) { } catch (e) {
console.error("Passkey registration error:", e); passkeyErr = e.body?.detail || e.message || "Failed to register passkey";
const errorMsg = e.body?.detail || e.message || "Failed to register passkey";
passkeyErr = errorMsg;
alert("Passkey Error: " + errorMsg + "\n\nFull error: " + JSON.stringify(e, null, 2));
} finally {
passkeyLoading = false;
} }
passkeyLoading = false;
} }
async function clearPasskeys() { async function clearPasskeys() {
+12 -71
View File
@@ -1,7 +1,7 @@
<script> <script>
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { VoiceSession } from "../lib/voice.js"; import { VoiceSession } from "../lib/voice.js";
import { tryRefreshSession } from "../lib/api.js"; import { getToken } from "../lib/api.js";
const PERSISTENCE_STATUS_PREFIX = "__DASHBOARD_PERSISTENCE__:"; const PERSISTENCE_STATUS_PREFIX = "__DASHBOARD_PERSISTENCE__:";
const PERSISTENCE_STATUS_PREFIX_BYTES = new TextEncoder().encode(PERSISTENCE_STATUS_PREFIX); const PERSISTENCE_STATUS_PREFIX_BYTES = new TextEncoder().encode(PERSISTENCE_STATUS_PREFIX);
@@ -189,13 +189,11 @@
fit.fit(); fit.fit();
const proto = location.protocol === "https:" ? "wss:" : "ws:"; const proto = location.protocol === "https:" ? "wss:" : "ws:";
const token = getToken() || "";
let heartbeat = null; let heartbeat = null;
let reconnectTimer = null; let reconnectTimer = null;
let closedManually = false; let closedManually = false;
let reconnecting = false; let reconnecting = false;
let authRecoveryInFlight = false;
let reconnectAttempts = 0;
const MAX_RECONNECT_DELAY = 30000;
function clearHeartbeat() { function clearHeartbeat() {
if (heartbeat) { if (heartbeat) {
@@ -204,46 +202,21 @@
} }
} }
function handleAuthExpired(reason = "Session expired — please log in again") {
clearHeartbeat();
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
reconnecting = false;
updateTab(tabId, {
connected: false,
error: reason,
statusBanner: "",
});
term.write(`\r\n\x1b[90m[${reason}]\x1b[0m`);
const d = tabData.get(tabId);
if (d) {
d.ws = null;
d.heartbeat = heartbeat;
d.reconnectTimer = reconnectTimer;
d.closedManually = closedManually;
d.reconnecting = reconnecting;
}
}
function scheduleReconnect(reason) { function scheduleReconnect(reason) {
clearHeartbeat(); clearHeartbeat();
if (closedManually || reconnectTimer) return; if (closedManually || reconnectTimer) return;
reconnecting = true; reconnecting = true;
reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), MAX_RECONNECT_DELAY);
updateTab(tabId, { updateTab(tabId, {
connected: false, connected: false,
error: `${reason} — reconnecting in ${Math.round(delay / 1000)}s (attempt ${reconnectAttempts})...`, error: `${reason} — reconnecting...`,
statusBanner: tab.persistent ? "Reconnecting to persistent session..." : "", statusBanner: tab.persistent ? "Reconnecting to persistent session..." : "",
}); });
term.write(`\r\n\x1b[90m[${reason} — reconnecting in ${Math.round(delay / 1000)}s...]\x1b[0m`); term.write(`\r\n\x1b[90m[${reason} — reconnecting...]\x1b[0m`);
reconnectTimer = setTimeout(() => { reconnectTimer = setTimeout(() => {
reconnectTimer = null; reconnectTimer = null;
if (!tabData.has(tabId) || closedManually) return; if (!tabData.has(tabId) || closedManually) return;
connect(true); connect();
}, delay); }, 2000);
} }
function sendSize(ws) { function sendSize(ws) {
@@ -256,22 +229,14 @@
} }
} }
async function connect(forceRefresh = false) { function connect() {
if (forceRefresh) { const token = getToken() || "";
const refreshed = await tryRefreshSession();
if (!refreshed) {
handleAuthExpired();
return null;
}
}
const ws = new WebSocket( const ws = new WebSocket(
`${proto}//${location.host}/ws/terminal?host=${encodeURIComponent(tab.hostId)}` `${proto}//${location.host}/ws/terminal?host=${encodeURIComponent(tab.hostId)}&token=${encodeURIComponent(token)}`
); );
ws.binaryType = "arraybuffer"; ws.binaryType = "arraybuffer";
ws.onopen = () => { ws.onopen = () => {
reconnectAttempts = 0;
updateTab(tabId, { updateTab(tabId, {
connected: true, connected: true,
error: "", error: "",
@@ -281,7 +246,7 @@
clearHeartbeat(); clearHeartbeat();
heartbeat = setInterval(() => { heartbeat = setInterval(() => {
if (ws.readyState === 1) ws.send("__ping__"); if (ws.readyState === 1) ws.send("__ping__");
}, 12000); }, 25000);
const d = tabData.get(tabId); const d = tabData.get(tabId);
if (d) { if (d) {
d.ws = ws; d.ws = ws;
@@ -307,30 +272,10 @@
} }
term.write(data); term.write(data);
}; };
ws.onclose = async (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); const d = tabData.get(tabId);
if (d) d.ws = null; if (d) d.ws = null;
if (e.code === 1008 && reason === "Unauthorized") {
if (!closedManually && !authRecoveryInFlight) {
authRecoveryInFlight = true;
const refreshed = await tryRefreshSession();
authRecoveryInFlight = false;
if (refreshed && !closedManually) {
reconnecting = true;
updateTab(tabId, {
connected: false,
error: "Refreshing session...",
statusBanner: tab.persistent ? "Reconnecting to persistent session..." : "",
});
term.write(`\r\n\x1b[90m[Refreshing session...]\x1b[0m`);
void connect(false);
return;
}
}
handleAuthExpired("Session expired — please log in again");
return;
}
if (!closedManually) scheduleReconnect(reason); if (!closedManually) scheduleReconnect(reason);
else { else {
clearHeartbeat(); clearHeartbeat();
@@ -356,11 +301,7 @@
return ws; return ws;
} }
let ws = null; let ws = connect();
void connect().then((connectedWs) => {
ws = connectedWs;
if (connectedWs) voice.attach(term, connectedWs);
});
term.onData((data) => { term.onData((data) => {
const currentWs = tabData.get(tabId)?.ws; const currentWs = tabData.get(tabId)?.ws;
if (currentWs?.readyState === 1) currentWs.send(new TextEncoder().encode(data)); if (currentWs?.readyState === 1) currentWs.send(new TextEncoder().encode(data));
-6
View File
@@ -28,12 +28,6 @@ nas.jimmygan.com {
reverse_proxy 100.78.131.124:4000 reverse_proxy 100.78.131.124:4000
} }
dev.nas.jimmygan.com {
import security_headers
import authelia
reverse_proxy 100.78.131.124:4001
}
music.jimmygan.com { music.jimmygan.com {
import security_headers import security_headers
import authelia import authelia