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
from passlib.context import CryptContext
import pyotp
from fastapi import Depends, HTTPException, Response, status
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
import config
@@ -14,12 +14,6 @@ import config
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
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):
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"})
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
def create_refresh_token(data: dict):
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(minutes=config.REFRESH_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire, "type": "refresh", "tv": _load_token_version()})
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
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):
async def get_current_user(token: str = Depends(oauth2_scheme)):
from rbac import User, get_pages, get_sidebar_links
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"} if include_auth_header else None,
headers={"WWW-Authenticate": "Bearer"},
)
try:
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
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)
async def get_current_user(token: str = Depends(oauth2_scheme)):
return user_from_access_token(token)
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
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_PROXY = os.environ.get("TELEGRAM_PROXY", "")
WEBAUTHN_RP_ID = os.environ.get("WEBAUTHN_RP_ID", "jimmygan.com")
WEBAUTHN_ORIGINS = os.environ.get("WEBAUTHN_ORIGINS", "https://nas.jimmygan.com,https://nas.jimmygan.com:8443").split(",")
WEBAUTHN_RP_ID = os.environ.get("WEBAUTHN_RP_ID", "nas.jimmygan.com")
WEBAUTHN_ORIGINS = os.environ.get("WEBAUTHN_ORIGIN", "https://nas.jimmygan.com,https://nas.jimmygan.com:8443").split(",")
# CORS
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
import auth as auth_module
import config
from rbac import require_page
from rbac import require_page, require_write
import asyncio
import httpx
@@ -160,11 +160,11 @@ app.include_router(totp.router, prefix="/api/auth", tags=["totp"])
# Protected endpoints with page-level RBAC
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",
dependencies=[Depends(_inject_user), Depends(require_page("gitea"))])
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",
dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))])
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",
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.mount("/", StaticFiles(directory="/app/static", html=True), name="static")
+32 -79
View File
@@ -2,8 +2,7 @@
from datetime import timedelta
import asyncio
import httpx
from typing import Optional
from fastapi import APIRouter, Body, Depends, HTTPException, status, Request, Response
from fastapi import APIRouter, Depends, HTTPException, status, Request
from pydantic import BaseModel
from slowapi import Limiter
from slowapi.util import get_remote_address
@@ -16,13 +15,11 @@ logger = logging.getLogger(__name__)
router = APIRouter()
limiter = Limiter(key_func=get_remote_address)
class LoginRequest(BaseModel):
username: str
password: str
totp_code: str = ""
class Token(BaseModel):
access_token: str
refresh_token: str
@@ -31,46 +28,8 @@ class Token(BaseModel):
has_2fa: bool
role: str = "admin"
class RefreshRequest(BaseModel):
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
refresh_token: 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)
@@ -82,7 +41,7 @@ async def send_failed_login_alert(ip_address: str, username: str, reason: str):
client_options = {}
if config.TELEGRAM_PROXY:
client_options["proxy"] = config.TELEGRAM_PROXY
async with httpx.AsyncClient(**client_options) as client:
res = await client.post(
f"https://api.telegram.org/bot{config.TELEGRAM_BOT_TOKEN}/sendMessage",
@@ -93,10 +52,9 @@ async def send_failed_login_alert(ip_address: str, username: str, reason: str):
except Exception as e:
logger.warning("Failed to send Telegram alert: %s", e)
@router.post("/login", response_model=Token)
@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
# Verify username/password
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),
)
refresh_token = auth.create_refresh_token(data={"sub": creds.username, "role": "admin"})
_set_auth_response_tokens(response, access_token, refresh_token)
return {
"access_token": access_token,
"refresh_token": refresh_token,
@@ -141,9 +98,8 @@ async def login(creds: LoginRequest, request: Request, response: Response):
"role": "admin",
}
@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.
Caddy sets Remote-User and Remote-Groups headers after successful Authelia verification."""
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),
)
refresh_token = auth.create_refresh_token(data={"sub": remote_user, "role": role})
_set_auth_response_tokens(response, access_token, refresh_token)
return {
"access_token": access_token,
"refresh_token": refresh_token,
@@ -179,9 +134,8 @@ async def proxy_auth(request: Request, response: Response):
"role": role,
}
@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()
return {
"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),
}
@router.post("/refresh")
@limiter.limit("10/minute")
async def refresh_token(request: Request, response: Response, req: Optional[RefreshRequest] = Body(default=None)):
refresh_token_value = _extract_refresh_token(request, req)
access_token, refresh_token = await _refresh_tokens_from_value(refresh_token_value)
_set_auth_response_tokens(response, access_token, refresh_token)
return {"access_token": access_token, "refresh_token": refresh_token}
@router.post("/logout")
async def logout(response: Response):
async def refresh_token(req: RefreshRequest, request: Request):
try:
payload = jwt.decode(req.refresh_token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
if payload.get("type") != "refresh":
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()
auth.clear_auth_cookies(response)
return {"ok": True}
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": access_token, "refresh_token": refresh_token}
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str
@router.post("/change-password")
@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()):
raise HTTPException(status_code=400, detail="Current password is incorrect")
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))
return {"message": "Password changed successfully"}
# RBAC admin endpoints
@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":
raise HTTPException(status_code=403, detail="Admin only")
from rbac import load_rbac
return load_rbac()
@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":
raise HTTPException(status_code=403, detail="Admin only")
from rbac import update_rbac
@@ -251,16 +208,14 @@ async def rbac_set_override(username: str, request: Request, current_user=Depend
update_rbac(mutator)
return {"ok": True}
@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
order = get_sidebar_order(current_user.username)
return {"sidebar_order": order}
@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
from rbac import save_sidebar_order
try:
@@ -277,9 +232,8 @@ async def save_preferences(request: Request, current_user=Depends(auth.get_curre
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@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":
raise HTTPException(status_code=403, detail="Admin only")
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)
return {"ok": True}
@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":
raise HTTPException(status_code=403, detail="Admin only")
from rbac import update_rbac
+2 -3
View File
@@ -1,7 +1,6 @@
import docker
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Query
from config import DOCKER_HOST
from rbac import require_admin
router = APIRouter()
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):
if action not in ("start", "stop", "restart"):
return {"error": "invalid action"}
+3 -4
View File
@@ -2,10 +2,9 @@ import os
import re
import shutil
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 config import VOLUME_ROOT
from rbac import require_admin
router = APIRouter()
BASE = Path(VOLUME_ROOT)
@@ -80,7 +79,7 @@ def download(path: str):
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(...)):
try:
safe_name = _sanitize_filename(file.filename)
@@ -100,7 +99,7 @@ async def upload(path: str, file: UploadFile = File(...)):
return {"ok": True}
@router.delete("/delete", dependencies=[Depends(require_admin())])
@router.delete("/delete")
def delete(path: str, recursive: bool = False):
try:
target = _safe_path(path)
+2 -3
View File
@@ -2,7 +2,7 @@
import json
import time
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 slowapi import Limiter
from slowapi.util import get_remote_address
@@ -112,7 +112,7 @@ async def passkey_login_options(request: Request):
@router.post("/passkey/login/verify")
@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."""
body = await request.json()
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),
)
refresh_token = auth.create_refresh_token(data={"sub": username, "role": "admin"})
auth.set_auth_cookies(response, access_token, refresh_token)
return {
"access_token": access_token,
"refresh_token": refresh_token,
+12 -26
View File
@@ -6,7 +6,7 @@ from collections import Counter
from fastapi import WebSocket, Query
import asyncssh
import config
import auth
from auth import get_current_user_ws
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")
return
access_token = websocket.cookies.get(auth.ACCESS_COOKIE_NAME) or token
if not access_token:
if not token:
await websocket.close(code=1008, reason="Unauthorized")
return
try:
user = await auth.get_current_user_ws(access_token)
user = await get_current_user_ws(token)
except Exception:
await websocket.close(code=1008, reason="Unauthorized")
return
@@ -125,22 +124,13 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str
h = HOSTS[host]
try:
conn = await asyncio.wait_for(
asyncssh.connect(
h["host"], username=h["user"],
client_keys=[h["key"]], known_hosts=_known_hosts,
keepalive_interval=15,
keepalive_count_max=8,
),
timeout=10.0
conn = await asyncssh.connect(
h["host"], username=h["user"],
client_keys=[h["key"]], known_hosts=_known_hosts,
keepalive_interval=30,
keepalive_count_max=3,
)
except asyncio.TimeoutError:
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)
except Exception:
await _release_session(session_id)
await websocket.close(code=1011, reason="SSH connection failed")
return
@@ -158,12 +148,8 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str
if not data:
break
await websocket.send_bytes(data)
except asyncssh.Error as e:
logger.warning("SSH read error for %s: %s", host, e)
except asyncio.CancelledError:
except (asyncssh.Error, asyncio.CancelledError):
pass
except Exception as e:
logger.error("Unexpected error reading SSH for %s: %s", host, e)
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__":
continue
process.stdin.write(msg["text"].encode())
except Exception as e:
logger.info("WebSocket receive loop ended for %s: %s", host, e)
except Exception:
pass
finally:
read_task.cancel()
process.close()
-2
View File
@@ -25,8 +25,6 @@ services:
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
- TELEGRAM_PROXY=${TELEGRAM_PROXY:-}
- 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_HEALTH_API_KEY=${LITELLM_HEALTH_API_KEY:-}
volumes:
-2
View File
@@ -47,8 +47,6 @@ services:
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
- TELEGRAM_PROXY=${TELEGRAM_PROXY:-}
- 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_URL=http://litellm:4005
volumes:
+7 -56
View File
@@ -14,22 +14,7 @@
import InfoEngine from "./routes/InfoEngine.svelte";
import Login from "./routes/Login.svelte";
import { onMount } from "svelte";
import { getToken, checkAuth, setToken, currentUser, setCurrentUser, getPreferences, savePreferences, tryRefreshSession, logout as logoutSession } from "./lib/api.js";
const KNOWN_PAGES = new Set([
"dashboard",
"docker",
"gitea",
"files",
"terminal",
"openclaw",
"chat-digest",
"settings",
"security",
"litellm",
"cc-connect",
"info-engine",
]);
import { getToken, checkAuth, setToken, setRefreshToken, currentUser, setCurrentUser, getPreferences, savePreferences } from "./lib/api.js";
let page = $state("dashboard");
let dark = $state(false);
@@ -42,26 +27,6 @@
let sidebarOrder = $state(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() {
try {
const data = await checkAuth();
@@ -106,14 +71,13 @@
// Try proxy auth first (Authelia forward-auth sets Remote-User header)
try {
const proxyRes = await fetch("/api/auth/proxy", { credentials: "same-origin" });
const proxyRes = await fetch("/api/auth/proxy");
if (proxyRes.ok) {
const data = await proxyRes.json();
setToken(data.access_token);
setRefreshToken(data.refresh_token);
authorized = true;
await fetchUserInfo();
const requestedPage = getRequestedPage();
if (canOpenPage(requestedPage)) page = requestedPage;
loading = false;
return;
}
@@ -122,20 +86,16 @@
}
// Regular token auth check
if (!getToken()) {
const refreshed = await tryRefreshSession();
if (!refreshed) {
const token = getToken();
if (!token) {
loading = false;
authorized = false;
return;
}
}
try {
await fetchUserInfo();
authorized = true;
const requestedPage = getRequestedPage();
if (canOpenPage(requestedPage)) page = requestedPage;
} catch (e) {
console.error("Auth check failed:", e);
setToken("");
@@ -145,11 +105,6 @@
}
});
$effect(() => {
if (!authorized || loading || !canOpenPage(page)) return;
syncPageQuery(page);
});
function toggleTheme() {
dark = !dark;
if (dark) {
@@ -161,13 +116,9 @@
}
}
async function logout() {
try {
await logoutSession();
} catch (e) {
console.error("Logout failed:", e);
}
function logout() {
setToken("");
setRefreshToken("");
window.location.reload();
}
</script>
+25 -49
View File
@@ -1,5 +1,5 @@
const BASE = "/api";
let token = "";
let token = localStorage.getItem("token") || "";
// Current user info populated after auth
export let currentUser = { username: "", role: "", pages: [] };
@@ -18,51 +18,33 @@ export function getToken() {
return token;
}
// Refresh token is cookie-managed server-side.
export function setRefreshToken() {}
export function setRefreshToken(t) {
if (t) localStorage.setItem("refresh_token", t);
else localStorage.removeItem("refresh_token");
}
let refreshPromise = null;
function getLegacyRefreshToken() {
function getRefreshToken() {
return localStorage.getItem("refresh_token") || "";
}
function clearLegacyRefreshToken() {
localStorage.removeItem("refresh_token");
}
let refreshPromise = null;
async function requestRefresh(body) {
const r = await fetch(BASE + "/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: body ? JSON.stringify(body) : undefined,
});
if (!r.ok) return false;
const data = await r.json();
setToken(data.access_token || "");
return !!data.access_token;
}
export async function tryRefreshSession() {
async function tryRefresh() {
if (refreshPromise) return refreshPromise;
const rt = getRefreshToken();
if (!rt) return false;
refreshPromise = (async () => {
try {
const cookieRefreshed = await requestRefresh();
if (cookieRefreshed) {
clearLegacyRefreshToken();
return true;
}
const legacyRefreshToken = getLegacyRefreshToken();
if (!legacyRefreshToken) return false;
const legacyRefreshed = await requestRefresh({ refresh_token: legacyRefreshToken });
if (legacyRefreshed) {
clearLegacyRefreshToken();
return true;
}
clearLegacyRefreshToken();
return false;
const r = await fetch(BASE + "/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: rt }),
});
if (!r.ok) return false;
const data = await r.json();
setToken(data.access_token);
setRefreshToken(data.refresh_token);
return true;
} catch {
return false;
} finally {
@@ -82,29 +64,27 @@ async function request(path, opts = {}) {
delete opts.json;
}
const fetchOpts = { ...opts, headers };
if (path.startsWith("/auth/")) fetchOpts.credentials = "same-origin";
try {
let r = await fetch(BASE + path, fetchOpts);
let r = await fetch(BASE + path, { ...opts, headers });
if (r.status === 401 && !path.includes("/auth/refresh")) {
const refreshed = await tryRefreshSession();
const refreshed = await tryRefresh();
if (refreshed) {
headers["Authorization"] = `Bearer ${token}`;
r = await fetch(BASE + path, fetchOpts);
r = await fetch(BASE + path, { ...opts, headers });
}
}
if (r.status === 401) {
setToken("");
setRefreshToken("");
throw new Error("Unauthorized");
}
if (!r.ok) {
const error = new Error(`HTTP ${r.status}`);
error.status = r.status;
try { error.body = await r.json(); } catch {}
try { error.body = await r.json(); } catch { }
throw error;
}
return r.json();
@@ -151,10 +131,6 @@ export function checkAuth() {
return request("/auth/me");
}
export function logout() {
return request("/auth/logout", { method: "POST" });
}
export function getPreferences() {
return get("/auth/preferences");
}
+3 -1
View File
@@ -1,5 +1,5 @@
<script>
import { login, setToken } from "../lib/api.js";
import { login, setToken, setRefreshToken, get, post } from "../lib/api.js";
import { fade, fly } from "svelte/transition";
let username = $state("");
@@ -56,6 +56,7 @@
if (!res.ok) { const e = await res.json(); throw new Error(e.detail || "Passkey verification failed"); }
const data = await res.json();
setToken(data.access_token);
setRefreshToken(data.refresh_token);
window.location.reload();
} catch (e) {
if (e.name === "NotAllowedError") { error = "Passkey authentication cancelled"; }
@@ -78,6 +79,7 @@
const res = await login(payload); // api.js helper sends JSON
setToken(res.access_token);
setRefreshToken(res.refresh_token);
// Determine if we need to reload or just update state.
// Reload is safest to clear any stale state.
window.location.reload();
+2 -14
View File
@@ -36,27 +36,19 @@
}
async function registerPasskey() {
alert("registerPasskey called!");
passkeyLoading = true;
passkeyMsg = ""; passkeyErr = "";
try {
alert("Checking PublicKeyCredential support...");
if (!window.PublicKeyCredential) {
alert("PublicKeyCredential NOT supported!");
throw new Error("Passkeys are not supported in this browser or context");
}
alert("Fetching registration options...");
const opts = await post("/auth/passkey/register/options");
alert("Got options: " + JSON.stringify(opts).substring(0, 100));
opts.challenge = base64urlToBuffer(opts.challenge);
opts.user.id = base64urlToBuffer(opts.user.id);
if (opts.excludeCredentials) {
opts.excludeCredentials = opts.excludeCredentials.map(c => ({ ...c, id: base64urlToBuffer(c.id) }));
}
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 body = {
id: cred.id,
@@ -72,13 +64,9 @@
passkeyMsg = "Passkey registered successfully";
await loadPasskeys();
} catch (e) {
console.error("Passkey registration error:", e);
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;
passkeyErr = e.body?.detail || e.message || "Failed to register passkey";
}
passkeyLoading = false;
}
async function clearPasskeys() {
+12 -71
View File
@@ -1,7 +1,7 @@
<script>
import { onMount, onDestroy } from "svelte";
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_BYTES = new TextEncoder().encode(PERSISTENCE_STATUS_PREFIX);
@@ -189,13 +189,11 @@
fit.fit();
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const token = getToken() || "";
let heartbeat = null;
let reconnectTimer = null;
let closedManually = false;
let reconnecting = false;
let authRecoveryInFlight = false;
let reconnectAttempts = 0;
const MAX_RECONNECT_DELAY = 30000;
function clearHeartbeat() {
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) {
clearHeartbeat();
if (closedManually || reconnectTimer) return;
reconnecting = true;
reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), MAX_RECONNECT_DELAY);
updateTab(tabId, {
connected: false,
error: `${reason} — reconnecting in ${Math.round(delay / 1000)}s (attempt ${reconnectAttempts})...`,
error: `${reason} — reconnecting...`,
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 = null;
if (!tabData.has(tabId) || closedManually) return;
connect(true);
}, delay);
connect();
}, 2000);
}
function sendSize(ws) {
@@ -256,22 +229,14 @@
}
}
async function connect(forceRefresh = false) {
if (forceRefresh) {
const refreshed = await tryRefreshSession();
if (!refreshed) {
handleAuthExpired();
return null;
}
}
function connect() {
const token = getToken() || "";
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.onopen = () => {
reconnectAttempts = 0;
updateTab(tabId, {
connected: true,
error: "",
@@ -281,7 +246,7 @@
clearHeartbeat();
heartbeat = setInterval(() => {
if (ws.readyState === 1) ws.send("__ping__");
}, 12000);
}, 25000);
const d = tabData.get(tabId);
if (d) {
d.ws = ws;
@@ -307,30 +272,10 @@
}
term.write(data);
};
ws.onclose = async (e) => {
ws.onclose = (e) => {
const reason = e.reason || `Connection closed (code ${e.code})`;
const d = tabData.get(tabId);
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);
else {
clearHeartbeat();
@@ -356,11 +301,7 @@
return ws;
}
let ws = null;
void connect().then((connectedWs) => {
ws = connectedWs;
if (connectedWs) voice.attach(term, connectedWs);
});
let ws = connect();
term.onData((data) => {
const currentWs = tabData.get(tabId)?.ws;
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
}
dev.nas.jimmygan.com {
import security_headers
import authelia
reverse_proxy 100.78.131.124:4001
}
music.jimmygan.com {
import security_headers
import authelia