fix: resolve terminal WebSocket auth failures and improve reconnection
Deploy Dashboard (Dev) / deploy-dev (push) Failing after 1m8s

- Remove insecure query token parameter from backend (cookie-only auth)
- Add detailed JWT error logging (expired, invalid, missing tokens)
- Force token refresh before first WebSocket connection
- Stop reconnection after 3 consecutive auth failures
- Increase ping interval from 12s to 30s (reduce traffic)
- Increase pong timeout from 5s to 10s (handle network latency)
- Show attempt counter in reconnection error messages

Root cause: Frontend was reconnecting with 6-day-old expired token,
causing 403 rejections. Now ensures fresh cookies before connecting.
This commit is contained in:
Gan, Jimmy
2026-03-13 22:26:22 +08:00
parent 00b5d362e7
commit 7b17a53cab
2 changed files with 42 additions and 8 deletions
+14 -3
View File
@@ -2,6 +2,7 @@ import asyncio
import struct import struct
import logging import logging
import shlex import shlex
import jwt
from collections import Counter from collections import Counter
from fastapi import WebSocket, Query from fastapi import WebSocket, Query
import asyncssh import asyncssh
@@ -90,19 +91,29 @@ async def _release_session(session_id: int | None):
_active_sessions.pop(session_id, None) _active_sessions.pop(session_id, None)
async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str | None = Query(None)): async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")):
if host not in HOSTS: if host not in HOSTS:
await websocket.close(code=1008, reason="Unknown host") await websocket.close(code=1008, reason="Unknown host")
return return
access_token = websocket.cookies.get(auth.ACCESS_COOKIE_NAME) or token access_token = websocket.cookies.get(auth.ACCESS_COOKIE_NAME)
if not access_token: if not access_token:
logger.warning("WebSocket auth failed for %s: no access token cookie", host)
await websocket.close(code=1008, reason="Unauthorized") await websocket.close(code=1008, reason="Unauthorized")
return return
try: try:
user = await auth.get_current_user_ws(access_token) user = await auth.get_current_user_ws(access_token)
except Exception: except jwt.ExpiredSignatureError as e:
logger.warning("WebSocket auth failed for %s: token expired - %s", host, e)
await websocket.close(code=1008, reason="Token expired")
return
except jwt.InvalidTokenError as e:
logger.warning("WebSocket auth failed for %s: invalid token - %s", host, e)
await websocket.close(code=1008, reason="Invalid token")
return
except Exception as e:
logger.error("WebSocket auth failed for %s: unexpected error - %s", host, e)
await websocket.close(code=1008, reason="Unauthorized") await websocket.close(code=1008, reason="Unauthorized")
return return
+28 -5
View File
@@ -196,8 +196,11 @@
let reconnecting = false; let reconnecting = false;
let authRecoveryInFlight = false; let authRecoveryInFlight = false;
let reconnectAttempts = 0; let reconnectAttempts = 0;
let consecutiveAuthFailures = 0;
const MAX_RECONNECT_DELAY = 30000; const MAX_RECONNECT_DELAY = 30000;
const PONG_TIMEOUT = 5000; // 5s to receive pong const MAX_AUTH_FAILURES = 3;
const PING_INTERVAL = 30000; // 30s between pings
const PONG_TIMEOUT = 10000; // 10s to receive pong
function clearHeartbeat() { function clearHeartbeat() {
if (heartbeat) { if (heartbeat) {
@@ -263,12 +266,18 @@
} }
async function connect(forceRefresh = false) { async function connect(forceRefresh = false) {
if (forceRefresh) { // Always refresh token before first connection to ensure fresh cookies
const d = tabData.get(tabId);
const isFirstConnect = !d?.hasConnectedOnce;
if (forceRefresh || isFirstConnect) {
const refreshed = await tryRefreshSession(); const refreshed = await tryRefreshSession();
if (!refreshed) { if (!refreshed) {
handleAuthExpired(); handleAuthExpired();
return null; return null;
} }
// Mark that we've connected once with fresh token
if (d) d.hasConnectedOnce = true;
} }
const ws = new WebSocket( const ws = new WebSocket(
@@ -278,6 +287,7 @@
ws.onopen = () => { ws.onopen = () => {
reconnectAttempts = 0; reconnectAttempts = 0;
consecutiveAuthFailures = 0; // Reset auth failure counter on successful connection
updateTab(tabId, { updateTab(tabId, {
connected: true, connected: true,
error: "", error: "",
@@ -295,7 +305,7 @@
} }
}, PONG_TIMEOUT); }, PONG_TIMEOUT);
} }
}, 12000); }, PING_INTERVAL);
const d = tabData.get(tabId); const d = tabData.get(tabId);
if (d) { if (d) {
d.ws = ws; d.ws = ws;
@@ -335,7 +345,18 @@
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") {
// Handle auth failures (code 1008)
if (e.code === 1008) {
consecutiveAuthFailures++;
// Stop after MAX_AUTH_FAILURES consecutive auth failures
if (consecutiveAuthFailures >= MAX_AUTH_FAILURES) {
handleAuthExpired(`Session expired after ${MAX_AUTH_FAILURES} attempts — please refresh page`);
return;
}
// Try to refresh session
if (!closedManually && !authRecoveryInFlight) { if (!closedManually && !authRecoveryInFlight) {
authRecoveryInFlight = true; authRecoveryInFlight = true;
const refreshed = await tryRefreshSession(); const refreshed = await tryRefreshSession();
@@ -344,7 +365,7 @@
reconnecting = true; reconnecting = true;
updateTab(tabId, { updateTab(tabId, {
connected: false, connected: false,
error: "Refreshing session...", error: `Refreshing session (attempt ${consecutiveAuthFailures}/${MAX_AUTH_FAILURES})...`,
statusBanner: tab.persistent ? "Reconnecting to persistent session..." : "", statusBanner: tab.persistent ? "Reconnecting to persistent session..." : "",
}); });
term.write(`\r\n\x1b[90m[Refreshing session...]\x1b[0m`); term.write(`\r\n\x1b[90m[Refreshing session...]\x1b[0m`);
@@ -355,6 +376,8 @@
handleAuthExpired("Session expired — please log in again"); handleAuthExpired("Session expired — please log in again");
return; return;
} }
// Non-auth failures: continue with normal reconnection
if (!closedManually) scheduleReconnect(reason); if (!closedManually) scheduleReconnect(reason);
else { else {
clearHeartbeat(); clearHeartbeat();