Merge pull request #36: Immich and Terminal fixes
This commit was merged in pull request #36.
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user