14 Commits

Author SHA1 Message Date
Gan, Jimmy 74100c8882 fix: change WebAuthn RP_ID to jimmygan.com to support all subdomains
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 2m0s
2026-03-11 01:43:19 +08:00
Gan, Jimmy 5c5baba3d4 debug: add step-by-step alerts to trace passkey registration flow
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 2m24s
2026-03-11 01:30:12 +08:00
Gan, Jimmy 7277560856 debug: add alert to show WebAuthn errors on iPad
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 2m11s
- Add alert() to display full error details for debugging
- Helps diagnose issues when browser console is unavailable
2026-03-11 01:25:36 +08:00
Gan, Jimmy b98333db36 fix: correct WebAuthn RP_ID to match domain suffix
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 1m48s
- Change RP_ID from jimmygan.com to nas.jimmygan.com
- RP_ID must be a valid suffix of the origin domain
- Fixes WebAuthn failing silently on iOS/iPadOS
2026-03-11 01:15:36 +08:00
Gan, Jimmy 42c5693ede fix: add WebAuthn environment variables to docker-compose
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 4m23s
- Set WEBAUTHN_RP_ID=jimmygan.com for both main and dev
- Set WEBAUTHN_ORIGINS to match respective domains
- Fixes passkey registration failing due to origin mismatch
2026-03-11 01:01:14 +08:00
Gan, Jimmy 2f8a5d84dc fix: WebAuthn config typo and improve error handling
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 1m41s
- Fix WEBAUTHN_ORIGIN -> WEBAUTHN_ORIGINS env var name
- Add null check for cancelled passkey creation
- Use finally block to ensure loading state is always reset
- Add console.error for better debugging
2026-03-11 00:53:28 +08:00
Gan, Jimmy 75cfafd388 chore: remove CI test comment
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 2m18s
2026-03-11 00:47:22 +08:00
Gan, Jimmy 80ad5e9197 test: verify CI auto-trigger for dev branch
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 5m54s
2026-03-11 00:41:07 +08:00
Gan, Jimmy cc3e9dc6fa fix: improve terminal robustness with exponential backoff and better keepalive
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 3m11s
- Backend: Add 10s connection timeout, improve SSH keepalive (15s × 8 retries)
- Backend: Add detailed error logging for debugging
- Frontend: Implement exponential backoff (1s → 30s max) instead of fixed 2s
- Frontend: Reduce heartbeat to 12s (within 15s SSH keepalive window)
- Frontend: Reset reconnect counter on successful connection
- Frontend: Fix race condition in auth recovery after manual close
2026-03-11 00:24:17 +08:00
Gan, Jimmy 4201917e17 fix: harden dashboard RBAC and cookie auth flows
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 2m17s
Restrict Docker/Files writes to admins, move terminal websocket auth to cookie-first with temporary query fallback, and migrate refresh-token handling to httpOnly cookies for safer session persistence.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-09 23:44:23 +08:00
Gan, Jimmy e72665c466 fix: refresh terminal websocket auth on reconnect
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 3m16s
Reuse the existing refresh-token flow before opening terminal websocket connections so reconnects can recover from expired access tokens without requiring a full dashboard reload.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-07 17:49:46 +08:00
Gan, Jimmy c162164528 fix: add dev dashboard caddy route
Add a dedicated dev.nas.jimmygan.com reverse proxy entry so the dev dashboard can be reached through the VPS Caddy configuration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-07 11:50:56 +08:00
Gan, Jimmy 175ebeede4 fix: sync dashboard page query state
Deploy Claude Dev (Dev) / deploy-claude-dev-dev (push) Successful in 17m2s
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 2m48s
Keep the selected dashboard page in sync with lightweight URL query params so direct terminal links can land on the right page without exposing extra client state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-07 11:02:53 +08:00
Gan, Jimmy c8255dccec 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:00:58 +08:00
18 changed files with 379 additions and 133 deletions
+7 -4
View File
@@ -16,6 +16,7 @@ RUN apt-get update \
python3 \ python3 \
ripgrep \ ripgrep \
rsync \ rsync \
tmux \
unzip \ unzip \
wget \ wget \
zip \ zip \
@@ -31,20 +32,22 @@ RUN npm install -g @anthropic-ai/claude-code
RUN python3 - <<'PY' RUN python3 - <<'PY'
from pathlib import Path from pathlib import Path
import re
path = Path("/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js") path = Path("/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js")
if not path.exists(): if not path.exists():
raise SystemExit(f"claude cli not found: {path}") raise SystemExit(f"claude cli not found: {path}")
content = path.read_text() content = path.read_text()
old = 'F8.get("https://api.anthropic.com/api/hello",{headers:K8($)}).catch((()=>!1))' pattern = r'let A=U7\(\),q=new URL\(A\.TOKEN_URL\),K=\[`\$\{A\.BASE_API_URL\}/api/hello`,`\$\{q\.origin\}/v1/oauth/hello`\],Y=async\(_\)=>\{try\{let \$=await I8\.get\(_,\{headers:\{"User-Agent":ey\(\)\}\}\);if\(\$\.status!==200\)return\{success:!1,error:`Failed to connect to \$\{new URL\(_\)\.hostname\}: Status \$\{\$\.status\}`\};return\{success:!0\}\}catch\(\$\)\{'
if old not in content: replacement = 'return'
patched, count = re.subn(pattern, replacement, content, count=1)
if count != 1:
raise SystemExit("preflight patch target not found in claude cli; refusing to build") raise SystemExit("preflight patch target not found in claude cli; refusing to build")
patched = content.replace(old, "!0")
path.write_text(patched) path.write_text(patched)
if old in path.read_text(): if re.search(pattern, path.read_text()):
raise SystemExit("preflight patch did not apply cleanly") raise SystemExit("preflight patch did not apply cleanly")
print("Applied Claude preflight bypass patch") print("Applied Claude preflight bypass patch")
+1
View File
@@ -13,4 +13,5 @@ rsync
zip zip
unzip unzip
make make
tmux
claude claude
+47 -21
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, status from fastapi import Depends, HTTPException, Response, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
import config import config
@@ -14,6 +14,12 @@ 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)
@@ -26,18 +32,49 @@ 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"}, headers={"WWW-Authenticate": "Bearer"} if include_auth_header else None,
) )
try: try:
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM]) payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
@@ -49,29 +86,18 @@ async def get_current_user(token: str = Depends(oauth2_scheme)):
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):
from rbac import User, get_pages, get_sidebar_links return user_from_access_token(token, include_auth_header=False)
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", "nas.jimmygan.com") WEBAUTHN_RP_ID = os.environ.get("WEBAUTHN_RP_ID", "jimmygan.com")
WEBAUTHN_ORIGINS = os.environ.get("WEBAUTHN_ORIGIN", "https://nas.jimmygan.com,https://nas.jimmygan.com:8443").split(",") WEBAUTHN_ORIGINS = os.environ.get("WEBAUTHN_ORIGINS", "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, require_write from rbac import require_page
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")), Depends(require_write())]) dependencies=[Depends(_inject_user), Depends(require_page("docker"))])
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")), Depends(require_write())]) dependencies=[Depends(_inject_user), Depends(require_page("files"))])
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 Query param) # WebSocket endpoint (auth handled inside handler via auth cookie; query token fallback temporary)
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")
+78 -31
View File
@@ -2,7 +2,8 @@
from datetime import timedelta from datetime import timedelta
import asyncio import asyncio
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException, status, Request from typing import Optional
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
@@ -15,11 +16,13 @@ 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
@@ -28,8 +31,46 @@ 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)
@@ -52,9 +93,10 @@ 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): async def login(creds: LoginRequest, request: Request, response: Response):
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()):
@@ -89,6 +131,7 @@ async def login(creds: LoginRequest, request: Request):
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,
@@ -98,8 +141,9 @@ async def login(creds: LoginRequest, request: Request):
"role": "admin", "role": "admin",
} }
@router.get("/proxy") @router.get("/proxy")
async def proxy_auth(request: Request): async def proxy_auth(request: Request, response: Response):
"""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
@@ -125,6 +169,7 @@ async def proxy_auth(request: Request):
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,
@@ -134,8 +179,9 @@ async def proxy_auth(request: Request):
"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,
@@ -145,36 +191,31 @@ 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(req: RefreshRequest, request: Request): async def refresh_token(request: Request, response: Response, req: Optional[RefreshRequest] = Body(default=None)):
try: refresh_token_value = _extract_refresh_token(request, req)
payload = jwt.decode(req.refresh_token, config.SECRET_KEY, algorithms=[config.ALGORITHM]) access_token, refresh_token = await _refresh_tokens_from_value(refresh_token_value)
if payload.get("type") != "refresh": _set_auth_response_tokens(response, access_token, refresh_token)
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": access_token, "refresh_token": refresh_token} return {"access_token": access_token, "refresh_token": refresh_token}
@router.post("/logout")
async def logout(response: Response):
auth.increment_token_version()
auth.clear_auth_cookies(response)
return {"ok": True}
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:
@@ -182,16 +223,18 @@ 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
@@ -208,14 +251,16 @@ async def rbac_set_override(username: str, request: Request, current_user = Depe
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:
@@ -232,8 +277,9 @@ async def save_preferences(request: Request, current_user = Depends(auth.get_cur
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
@@ -244,8 +290,9 @@ async def rbac_delete_override(username: str, current_user = Depends(auth.get_cu
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
+3 -2
View File
@@ -1,6 +1,7 @@
import docker import docker
from fastapi import APIRouter, Query from fastapi import APIRouter, Depends, 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)
@@ -21,7 +22,7 @@ def list_containers():
] ]
@router.post("/containers/{container_id}/{action}") @router.post("/containers/{container_id}/{action}", dependencies=[Depends(require_admin())])
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"}
+4 -3
View File
@@ -2,9 +2,10 @@ import os
import re import re
import shutil import shutil
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, UploadFile, File, HTTPException from fastapi import APIRouter, Depends, 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)
@@ -79,7 +80,7 @@ def download(path: str):
raise HTTPException(status_code=403, detail="Access denied") raise HTTPException(status_code=403, detail="Access denied")
@router.post("/upload") @router.post("/upload", dependencies=[Depends(require_admin())])
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)
@@ -99,7 +100,7 @@ async def upload(path: str, file: UploadFile = File(...)):
return {"ok": True} return {"ok": True}
@router.delete("/delete") @router.delete("/delete", dependencies=[Depends(require_admin())])
def delete(path: str, recursive: bool = False): def delete(path: str, recursive: bool = False):
try: try:
target = _safe_path(path) target = _safe_path(path)
+3 -2
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 from fastapi import APIRouter, Depends, HTTPException, Request, Response
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): async def passkey_login_verify(request: Request, response: Response):
"""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,6 +146,7 @@ async def passkey_login_verify(request: Request):
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,
+24 -10
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
from auth import get_current_user_ws import auth
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -95,12 +95,13 @@ 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
if not token: access_token = websocket.cookies.get(auth.ACCESS_COOKIE_NAME) or 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 get_current_user_ws(token) user = await auth.get_current_user_ws(access_token)
except Exception: except Exception:
await websocket.close(code=1008, reason="Unauthorized") await websocket.close(code=1008, reason="Unauthorized")
return return
@@ -124,13 +125,22 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str
h = HOSTS[host] h = HOSTS[host]
try: try:
conn = await asyncssh.connect( conn = await asyncio.wait_for(
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=30, keepalive_interval=15,
keepalive_count_max=3, keepalive_count_max=8,
),
timeout=10.0
) )
except Exception: 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)
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
@@ -148,8 +158,12 @@ 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, asyncio.CancelledError): except asyncssh.Error as e:
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())
@@ -168,8 +182,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: except Exception as e:
pass logger.info("WebSocket receive loop ended for %s: %s", host, e)
finally: finally:
read_task.cancel() read_task.cancel()
process.close() process.close()
+2
View File
@@ -25,6 +25,8 @@ 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,6 +47,8 @@ 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:
+56 -7
View File
@@ -14,7 +14,22 @@
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, setRefreshToken, currentUser, setCurrentUser, getPreferences, savePreferences } from "./lib/api.js"; 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",
]);
let page = $state("dashboard"); let page = $state("dashboard");
let dark = $state(false); let dark = $state(false);
@@ -27,6 +42,26 @@
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();
@@ -71,13 +106,14 @@
// 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"); const proxyRes = await fetch("/api/auth/proxy", { credentials: "same-origin" });
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;
} }
@@ -86,16 +122,20 @@
} }
// Regular token auth check // Regular token auth check
const token = getToken(); if (!getToken()) {
if (!token) { const refreshed = await tryRefreshSession();
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("");
@@ -105,6 +145,11 @@
} }
}); });
$effect(() => {
if (!authorized || loading || !canOpenPage(page)) return;
syncPageQuery(page);
});
function toggleTheme() { function toggleTheme() {
dark = !dark; dark = !dark;
if (dark) { if (dark) {
@@ -116,9 +161,13 @@
} }
} }
function logout() { async function logout() {
try {
await logoutSession();
} catch (e) {
console.error("Logout failed:", e);
}
setToken(""); setToken("");
setRefreshToken("");
window.location.reload(); window.location.reload();
} }
</script> </script>
+47 -23
View File
@@ -1,5 +1,5 @@
const BASE = "/api"; const BASE = "/api";
let token = localStorage.getItem("token") || ""; let 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,33 +18,51 @@ export function getToken() {
return token; return token;
} }
export function setRefreshToken(t) { // Refresh token is cookie-managed server-side.
if (t) localStorage.setItem("refresh_token", t); export function setRefreshToken() {}
else localStorage.removeItem("refresh_token");
}
function getRefreshToken() {
return localStorage.getItem("refresh_token") || "";
}
let refreshPromise = null; let refreshPromise = null;
async function tryRefresh() { function getLegacyRefreshToken() {
if (refreshPromise) return refreshPromise; return localStorage.getItem("refresh_token") || "";
const rt = getRefreshToken(); }
if (!rt) return false;
refreshPromise = (async () => { function clearLegacyRefreshToken() {
try { localStorage.removeItem("refresh_token");
}
async function requestRefresh(body) {
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" },
body: JSON.stringify({ refresh_token: rt }), credentials: "same-origin",
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 || "");
setRefreshToken(data.refresh_token); return !!data.access_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 {
@@ -64,27 +82,29 @@ 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, { ...opts, headers }); let r = await fetch(BASE + path, fetchOpts);
if (r.status === 401 && !path.includes("/auth/refresh")) { if (r.status === 401 && !path.includes("/auth/refresh")) {
const refreshed = await tryRefresh(); const refreshed = await tryRefreshSession();
if (refreshed) { if (refreshed) {
headers["Authorization"] = `Bearer ${token}`; headers["Authorization"] = `Bearer ${token}`;
r = await fetch(BASE + path, { ...opts, headers }); r = await fetch(BASE + path, fetchOpts);
} }
} }
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();
@@ -131,6 +151,10 @@ 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");
} }
+1 -3
View File
@@ -1,5 +1,5 @@
<script> <script>
import { login, setToken, setRefreshToken, get, post } from "../lib/api.js"; import { login, setToken } from "../lib/api.js";
import { fade, fly } from "svelte/transition"; import { fade, fly } from "svelte/transition";
let username = $state(""); let username = $state("");
@@ -56,7 +56,6 @@
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"; }
@@ -79,7 +78,6 @@
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();
+14 -2
View File
@@ -36,19 +36,27 @@
} }
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,
@@ -64,10 +72,14 @@
passkeyMsg = "Passkey registered successfully"; passkeyMsg = "Passkey registered successfully";
await loadPasskeys(); await loadPasskeys();
} catch (e) { } catch (e) {
passkeyErr = e.body?.detail || e.message || "Failed to register passkey"; 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; passkeyLoading = false;
} }
}
async function clearPasskeys() { async function clearPasskeys() {
if (!confirm("Remove all passkeys?")) return; if (!confirm("Remove all passkeys?")) return;
+71 -12
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 { getToken } from "../lib/api.js"; import { tryRefreshSession } 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,11 +189,13 @@
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) {
@@ -202,21 +204,46 @@
} }
} }
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...`, error: `${reason} — reconnecting in ${Math.round(delay / 1000)}s (attempt ${reconnectAttempts})...`,
statusBanner: tab.persistent ? "Reconnecting to persistent session..." : "", statusBanner: tab.persistent ? "Reconnecting to persistent session..." : "",
}); });
term.write(`\r\n\x1b[90m[${reason} — reconnecting...]\x1b[0m`); term.write(`\r\n\x1b[90m[${reason} — reconnecting in ${Math.round(delay / 1000)}s...]\x1b[0m`);
reconnectTimer = setTimeout(() => { reconnectTimer = setTimeout(() => {
reconnectTimer = null; reconnectTimer = null;
if (!tabData.has(tabId) || closedManually) return; if (!tabData.has(tabId) || closedManually) return;
connect(); connect(true);
}, 2000); }, delay);
} }
function sendSize(ws) { function sendSize(ws) {
@@ -229,14 +256,22 @@
} }
} }
function connect() { async function connect(forceRefresh = false) {
const token = getToken() || ""; if (forceRefresh) {
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)}&token=${encodeURIComponent(token)}` `${proto}//${location.host}/ws/terminal?host=${encodeURIComponent(tab.hostId)}`
); );
ws.binaryType = "arraybuffer"; ws.binaryType = "arraybuffer";
ws.onopen = () => { ws.onopen = () => {
reconnectAttempts = 0;
updateTab(tabId, { updateTab(tabId, {
connected: true, connected: true,
error: "", error: "",
@@ -246,7 +281,7 @@
clearHeartbeat(); clearHeartbeat();
heartbeat = setInterval(() => { heartbeat = setInterval(() => {
if (ws.readyState === 1) ws.send("__ping__"); if (ws.readyState === 1) ws.send("__ping__");
}, 25000); }, 12000);
const d = tabData.get(tabId); const d = tabData.get(tabId);
if (d) { if (d) {
d.ws = ws; d.ws = ws;
@@ -272,10 +307,30 @@
} }
term.write(data); term.write(data);
}; };
ws.onclose = (e) => { ws.onclose = async (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();
@@ -301,7 +356,11 @@
return ws; return ws;
} }
let ws = connect(); let ws = null;
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,6 +28,12 @@ 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