Merge dev to main - Immich and Terminal fixes #36

Merged
jimmy merged 5 commits from dev into main 2026-03-15 22:50:19 +08:00
3 changed files with 53 additions and 15 deletions
+18 -4
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
@@ -122,6 +133,7 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str
session_id = session_or_code session_id = session_or_code
await websocket.accept() await websocket.accept()
logger.info("WebSocket accepted for %s (user: %s)", host, user.username)
h = HOSTS[host] h = HOSTS[host]
try: try:
@@ -134,6 +146,7 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str
), ),
timeout=10.0 timeout=10.0
) )
logger.info("SSH connection established to %s", host)
except asyncio.TimeoutError: except asyncio.TimeoutError:
logger.error("SSH connection to %s timed out after 10s", host) logger.error("SSH connection to %s timed out after 10s", host)
await _release_session(session_id) await _release_session(session_id)
@@ -180,11 +193,12 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str
process.stdin.write(data) process.stdin.write(data)
elif "text" in msg and msg["text"]: elif "text" in msg and msg["text"]:
if msg["text"] == "__ping__": if msg["text"] == "__ping__":
logger.debug("Received ping from %s, sending pong", host)
await websocket.send_text("__pong__") await websocket.send_text("__pong__")
continue continue
process.stdin.write(msg["text"].encode()) process.stdin.write(msg["text"].encode())
except Exception as e: except Exception as e:
logger.info("WebSocket receive loop ended for %s: %s", host, e) logger.warning("WebSocket receive loop ended for %s: %s", host, e)
finally: finally:
read_task.cancel() read_task.cancel()
process.close() process.close()
+31 -8
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
let 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;
@@ -333,9 +343,20 @@
}; };
ws.onclose = async (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); 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();
@@ -363,13 +386,13 @@
} }
}; };
ws.onerror = () => { ws.onerror = () => {
const d = tabData.get(tabId); d = tabData.get(tabId);
if (d) d.ws = null; if (d) d.ws = null;
if (!closedManually) scheduleReconnect("Connection failed"); if (!closedManually) scheduleReconnect("Connection failed");
else updateTab(tabId, { connected: false, error: "Connection failed" }); else updateTab(tabId, { connected: false, error: "Connection failed" });
}; };
const d = tabData.get(tabId); d = tabData.get(tabId);
if (d) { if (d) {
d.ws = ws; d.ws = ws;
d.heartbeat = heartbeat; d.heartbeat = heartbeat;
+4 -3
View File
@@ -6,7 +6,7 @@ services:
labels: labels:
- "com.centurylinklabs.watchtower.enable=true" - "com.centurylinklabs.watchtower.enable=true"
ports: ports:
- "127.0.0.1:2283:2283" - "0.0.0.0:2283:2283"
volumes: volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload - ${UPLOAD_LOCATION}:/usr/src/app/upload
- /volume1/photo:/mnt/photo:ro - /volume1/photo:/mnt/photo:ro
@@ -18,9 +18,10 @@ services:
DB_PASSWORD: ${DB_PASSWORD} DB_PASSWORD: ${DB_PASSWORD}
DB_DATABASE_NAME: ${DB_DATABASE_NAME} DB_DATABASE_NAME: ${DB_DATABASE_NAME}
REDIS_HOSTNAME: immich-redis REDIS_HOSTNAME: immich-redis
MACHINE_LEARNING_URL: http://100.65.185.12:3003 IMMICH_MACHINE_LEARNING_ENABLED: "false"
IMMICH_MEDIA_FFMPEG_THREADS: 2
healthcheck: healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:2283/api/server/ping"] test: ["CMD-SHELL", "curl -f http://localhost:2283/api/server/ping || exit 1"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3