diff --git a/dashboard/backend/config.py b/dashboard/backend/config.py index 4c76cdd..cbdf222 100644 --- a/dashboard/backend/config.py +++ b/dashboard/backend/config.py @@ -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", "nas.jimmygan.com") -WEBAUTHN_ORIGINS = os.environ.get("WEBAUTHN_ORIGIN", "https://nas.jimmygan.com,https://nas.jimmygan.com:8443").split(",") +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(",") # CORS CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "https://nas.jimmygan.com,https://nas.jimmygan.com:8443").split(",") diff --git a/dashboard/backend/routers/terminal.py b/dashboard/backend/routers/terminal.py index e6134bd..8750a02 100644 --- a/dashboard/backend/routers/terminal.py +++ b/dashboard/backend/routers/terminal.py @@ -125,13 +125,22 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str h = HOSTS[host] try: - conn = await asyncssh.connect( - h["host"], username=h["user"], - client_keys=[h["key"]], known_hosts=_known_hosts, - keepalive_interval=30, - keepalive_count_max=3, + 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 ) - 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 websocket.close(code=1011, reason="SSH connection failed") return @@ -149,8 +158,12 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str if not data: break 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 + except Exception as e: + logger.error("Unexpected error reading SSH for %s: %s", host, e) read_task = asyncio.create_task(read_ssh()) @@ -169,8 +182,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: - pass + except Exception as e: + logger.info("WebSocket receive loop ended for %s: %s", host, e) finally: read_task.cancel() process.close() diff --git a/dashboard/docker-compose.dev.yml b/dashboard/docker-compose.dev.yml index e85e565..caaac37 100644 --- a/dashboard/docker-compose.dev.yml +++ b/dashboard/docker-compose.dev.yml @@ -25,6 +25,8 @@ 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: diff --git a/dashboard/docker-compose.yml b/dashboard/docker-compose.yml index 636b80a..2c1b74b 100644 --- a/dashboard/docker-compose.yml +++ b/dashboard/docker-compose.yml @@ -47,6 +47,8 @@ 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: diff --git a/dashboard/frontend/src/lib/api.js b/dashboard/frontend/src/lib/api.js index de188bc..66e3ed8 100644 --- a/dashboard/frontend/src/lib/api.js +++ b/dashboard/frontend/src/lib/api.js @@ -23,19 +23,46 @@ export function setRefreshToken() {} let refreshPromise = null; +function getLegacyRefreshToken() { + return localStorage.getItem("refresh_token") || ""; +} + +function clearLegacyRefreshToken() { + localStorage.removeItem("refresh_token"); +} + +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() { if (refreshPromise) return refreshPromise; refreshPromise = (async () => { try { - const r = await fetch(BASE + "/auth/refresh", { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "same-origin", - }); - if (!r.ok) return false; - const data = await r.json(); - setToken(data.access_token || ""); - return !!data.access_token; + 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; } catch { return false; } finally { diff --git a/dashboard/frontend/src/routes/Settings.svelte b/dashboard/frontend/src/routes/Settings.svelte index 2f2dd7d..88145e6 100644 --- a/dashboard/frontend/src/routes/Settings.svelte +++ b/dashboard/frontend/src/routes/Settings.svelte @@ -36,19 +36,27 @@ } 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, @@ -64,9 +72,13 @@ passkeyMsg = "Passkey registered successfully"; await loadPasskeys(); } 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() { diff --git a/dashboard/frontend/src/routes/Terminal.svelte b/dashboard/frontend/src/routes/Terminal.svelte index 60f5bc4..273147c 100644 --- a/dashboard/frontend/src/routes/Terminal.svelte +++ b/dashboard/frontend/src/routes/Terminal.svelte @@ -194,6 +194,8 @@ let closedManually = false; let reconnecting = false; let authRecoveryInFlight = false; + let reconnectAttempts = 0; + const MAX_RECONNECT_DELAY = 30000; function clearHeartbeat() { if (heartbeat) { @@ -229,17 +231,19 @@ 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...`, + error: `${reason} — reconnecting in ${Math.round(delay / 1000)}s (attempt ${reconnectAttempts})...`, 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 = null; if (!tabData.has(tabId) || closedManually) return; connect(true); - }, 2000); + }, delay); } function sendSize(ws) { @@ -267,6 +271,7 @@ ws.binaryType = "arraybuffer"; ws.onopen = () => { + reconnectAttempts = 0; updateTab(tabId, { connected: true, error: "", @@ -276,7 +281,7 @@ clearHeartbeat(); heartbeat = setInterval(() => { if (ws.readyState === 1) ws.send("__ping__"); - }, 25000); + }, 12000); const d = tabData.get(tabId); if (d) { d.ws = ws; @@ -311,7 +316,7 @@ authRecoveryInFlight = true; const refreshed = await tryRefreshSession(); authRecoveryInFlight = false; - if (refreshed) { + if (refreshed && !closedManually) { reconnecting = true; updateTab(tabId, { connected: false,