fix: raise terminal per-user session cap #28

Merged
jimmy merged 2 commits from dev into main 2026-03-07 10:45:38 +08:00
3 changed files with 174 additions and 18 deletions
+43 -4
View File
@@ -1,6 +1,7 @@
import asyncio
import struct
import logging
import shlex
from collections import Counter
from fastapi import WebSocket, Query
import asyncssh
@@ -10,19 +11,28 @@ from auth import get_current_user_ws
logger = logging.getLogger(__name__)
MAX_SESSIONS = 5
MAX_SESSIONS_PER_USER = 2
MAX_SESSIONS_PER_USER = 5
_session_lock = asyncio.Lock()
_active_sessions: dict[int, str] = {}
PERSISTENCE_STATUS_PREFIX = "__DASHBOARD_PERSISTENCE__:"
HOSTS = {
"nas": {"host": config.SSH_HOST, "user": config.SSH_USER, "key": config.SSH_KEY_PATH},
"vps": {"host": config.VPS_SSH_HOST, "user": config.VPS_SSH_USER, "key": config.VPS_SSH_KEY_PATH},
"caddy-vps": {"host": config.CADDY_VPS_SSH_HOST, "user": config.CADDY_VPS_SSH_USER, "key": config.VPS_SSH_KEY_PATH},
"mac": {"host": config.MAC_SSH_HOST, "user": config.MAC_SSH_USER, "key": config.MAC_SSH_KEY_PATH},
"claude-dev": {"host": config.SSH_HOST, "user": config.SSH_USER, "key": config.SSH_KEY_PATH,
"command": "/volume1/@appstore/ContainerManager/usr/bin/docker exec -it claude-dev bash"},
"claude-dev": {
"host": config.SSH_HOST,
"user": config.SSH_USER,
"key": config.SSH_KEY_PATH,
"persistent_session": {
"container": "claude-dev",
"session_name": "dashboard-terminal",
},
},
}
# Load known_hosts once at import time — fail closed if missing
_known_hosts_available = False
try:
@@ -33,6 +43,35 @@ except Exception:
logger.error("SSH known_hosts file not found at %s — terminal connections will be refused", config.SSH_KNOWN_HOSTS)
def build_host_command(host_config: dict) -> str | None:
persistent = host_config.get("persistent_session")
if not persistent:
return host_config.get("command")
docker_bin = "/volume1/@appstore/ContainerManager/usr/bin/docker"
container = persistent["container"]
session_name = persistent["session_name"]
quoted_container = shlex.quote(container)
quoted_session = shlex.quote(session_name)
quoted_marker = shlex.quote(PERSISTENCE_STATUS_PREFIX)
attach_cmd = (
f"{docker_bin} exec -it {quoted_container} "
f"tmux new-session -A -s {quoted_session}"
)
script = " ".join([
"if",
f"{docker_bin} exec {quoted_container} tmux has-session -t {quoted_session} >/dev/null 2>&1;",
"then",
f"printf '%sattached\\n' {quoted_marker};",
"else",
f"printf '%screated\\n' {quoted_marker};",
"fi;",
f"exec {attach_cmd}",
])
return f"bash -lc {shlex.quote(script)}"
async def _reserve_session(session_id: int, username: str) -> tuple[bool, str | None, int | None]:
async with _session_lock:
if len(_active_sessions) >= MAX_SESSIONS:
@@ -98,7 +137,7 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str
try:
process = await conn.create_process(
h.get("command"), term_type="xterm-256color", term_size=(80, 24),
build_host_command(h), term_type="xterm-256color", term_size=(80, 24),
encoding=None,
)
+40 -4
View File
@@ -86,8 +86,41 @@
filter !== "all" || !!ipFilter.trim() || !!startDate || !!endDate
);
const typeLabels = { auth_failure: "Auth", suspicious_request: "Request", error: "Error" };
const typeColors = { auth_failure: "text-red-400", suspicious_request: "text-amber-400", error: "text-surface-500" };
function isBlockedScannerEvent(entry) {
if (entry.type !== "unauthorized") return false;
const message = (entry.message || "").toLowerCase();
return [
"/.git/",
"phpinfo",
"debug.log",
"laravel.log",
"stripe",
"swagger.json",
"wp-config",
"credentials",
"secrets",
"config",
"@vite",
"manifest.json",
].some((needle) => message.includes(needle));
}
const logTypeFilters = ["all", "auth_failure", "suspicious_request", "unauthorized"];
const logTypeButtonLabels = {
all: "All",
auth_failure: "Auth",
suspicious_request: "Requests",
unauthorized: "Blocked",
};
const typeLabels = { auth_failure: "Auth", suspicious_request: "Request", unauthorized: "Blocked", error: "Error" };
const typeColors = {
auth_failure: "text-red-400",
suspicious_request: "text-amber-400",
unauthorized: "text-orange-400",
error: "text-surface-500"
};
</script>
<div class="space-y-6">
@@ -171,9 +204,9 @@
</div>
<div class="flex flex-wrap gap-1.5 mb-3">
{#each ["all", "auth_failure", "suspicious_request"] as f}
{#each logTypeFilters as f}
<button onclick={() => applyTypeFilter(f)} class="px-2.5 py-1 text-xs rounded-md transition-colors {filter === f ? 'bg-surface-800 text-white dark:bg-surface-200 dark:text-surface-900' : 'bg-surface-100 text-surface-500 hover:bg-surface-200 dark:bg-surface-700 dark:text-surface-300 dark:hover:bg-surface-600'}">
{f === "all" ? "All" : f === "auth_failure" ? "Auth" : "Requests"}
{logTypeButtonLabels[f]}
</button>
{/each}
</div>
@@ -236,6 +269,9 @@
{#if e.username}
<span class="text-red-400 shrink-0">{e.username}</span>
{/if}
{#if isBlockedScannerEvent(e)}
<span class="shrink-0 rounded-md bg-orange-500/15 px-1.5 py-0.5 text-[10px] font-medium text-orange-300 border border-orange-500/30">scanner</span>
{/if}
<span class="text-surface-300 truncate">{e.message}</span>
</div>
{/each}
+91 -10
View File
@@ -1,13 +1,17 @@
<script>
import { onMount, onDestroy } from "svelte";
import { VoiceSession } from "../lib/voice.js";
import { getToken } from "../lib/api.js";
const PERSISTENCE_STATUS_PREFIX = "__DASHBOARD_PERSISTENCE__:";
const PERSISTENCE_STATUS_PREFIX_BYTES = new TextEncoder().encode(PERSISTENCE_STATUS_PREFIX);
const hosts = [
{ id: "nas", label: "NAS" },
{ id: "vps", label: "VPS" },
{ id: "caddy-vps", label: "Caddy VPS" },
{ id: "mac", label: "Mac" },
{ id: "claude-dev", label: "Claude Dev" },
{ id: "claude-dev", label: "Claude Dev", persistent: true },
];
const DARK_THEME = {
@@ -38,6 +42,23 @@
return isDark ? DARK_THEME : LIGHT_THEME;
}
function hostById(hostId) {
return hosts.find((h) => h.id === hostId);
}
function openFreshSession(tab) {
const url = new URL(window.location.href);
url.searchParams.set("page", "terminal");
url.searchParams.set("host", tab.hostId);
window.open(`${url.pathname}${url.search}${url.hash}`, "_blank", "noopener");
}
function persistenceLabel(status, reconnecting) {
if (status === "created") return "created new persistent session";
if (reconnecting) return "reattached to persistent session";
return "attached to persistent session";
}
function applyThemeToTerminals() {
const theme = currentTheme();
tabData.forEach((d) => {
@@ -56,6 +77,8 @@
onMount(() => {
syncTheme();
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
const requestedHost = new URLSearchParams(window.location.search).get("host");
if (requestedHost && !tabs.length && hostById(requestedHost)) addTab(requestedHost);
});
function cleanName(name) {
@@ -111,8 +134,16 @@
function addTab(hostId) {
const id = ++tabCounter;
const label = hosts.find((h) => h.id === hostId).label;
tabs = [...tabs, { id, hostId, label, connected: false, error: "" }];
const host = hostById(hostId);
tabs = [...tabs, {
id,
hostId,
label: host.label,
connected: false,
error: "",
statusBanner: host.persistent ? "Connecting to persistent session..." : "",
persistent: !!host.persistent,
}];
activeTab = id;
showMenu = false;
}
@@ -158,10 +189,11 @@
fit.fit();
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const token = localStorage.getItem("token") || "";
const token = getToken() || "";
let heartbeat = null;
let reconnectTimer = null;
let closedManually = false;
let reconnecting = false;
function clearHeartbeat() {
if (heartbeat) {
@@ -173,7 +205,12 @@
function scheduleReconnect(reason) {
clearHeartbeat();
if (closedManually || reconnectTimer) return;
updateTab(tabId, { connected: false, error: `${reason} — reconnecting...` });
reconnecting = true;
updateTab(tabId, {
connected: false,
error: `${reason} — reconnecting...`,
statusBanner: tab.persistent ? "Reconnecting to persistent session..." : "",
});
term.write(`\r\n\x1b[90m[${reason} — reconnecting...]\x1b[0m`);
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
@@ -193,13 +230,18 @@
}
function connect() {
const token = getToken() || "";
const ws = new WebSocket(
`${proto}//${location.host}/ws/terminal?host=${encodeURIComponent(tab.hostId)}&token=${encodeURIComponent(token)}`
);
ws.binaryType = "arraybuffer";
ws.onopen = () => {
updateTab(tabId, { connected: true, error: "" });
updateTab(tabId, {
connected: true,
error: "",
statusBanner: tab.persistent ? "Connected. Waiting for persistent session status..." : "",
});
sendSize(ws);
clearHeartbeat();
heartbeat = setInterval(() => {
@@ -211,10 +253,25 @@
d.heartbeat = heartbeat;
d.reconnectTimer = reconnectTimer;
d.closedManually = closedManually;
d.reconnecting = reconnecting;
d.voice?.attach(term, ws);
}
};
ws.onmessage = (e) => term.write(new Uint8Array(e.data));
ws.onmessage = (e) => {
const data = new Uint8Array(e.data);
const isPersistenceMessage = tab.persistent
&& data.length >= PERSISTENCE_STATUS_PREFIX_BYTES.length
&& PERSISTENCE_STATUS_PREFIX_BYTES.every((byte, index) => data[index] === byte);
if (isPersistenceMessage) {
const status = new TextDecoder().decode(data.slice(PERSISTENCE_STATUS_PREFIX_BYTES.length)).trim();
updateTab(tabId, { statusBanner: persistenceLabel(status, reconnecting) });
reconnecting = false;
const d = tabData.get(tabId);
if (d) d.reconnecting = reconnecting;
return;
}
term.write(data);
};
ws.onclose = (e) => {
const reason = e.reason || `Connection closed (code ${e.code})`;
const d = tabData.get(tabId);
@@ -239,6 +296,7 @@
d.heartbeat = heartbeat;
d.reconnectTimer = reconnectTimer;
d.closedManually = closedManually;
d.reconnecting = reconnecting;
}
return ws;
}
@@ -286,7 +344,7 @@
ro.observe(el);
const voice = new VoiceSession(() => { voiceTick++; });
voice.attach(term, ws);
tabData.set(tabId, { ws, term, ro, el, voice, heartbeat, reconnectTimer, closedManually, handleDragOver, handleDrop, handlePaste });
tabData.set(tabId, { ws, term, ro, el, voice, heartbeat, reconnectTimer, closedManually, reconnecting, handleDragOver, handleDrop, handlePaste });
})();
}
@@ -327,6 +385,18 @@
<span class="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse"></span>
{/if}
{tab.label}
{#if tab.persistent}
<span class="rounded bg-indigo-100 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300">tmux</span>
{/if}
<span
class="ml-1 text-surface-500 hover:text-sky-500 cursor-pointer"
role="button"
tabindex="0"
title="Open fresh session in new browser tab"
aria-label={`Open fresh ${tab.label} session in new browser tab`}
onclick={(e) => { e.stopPropagation(); openFreshSession(tab); }}
onkeydown={(e) => e.key === 'Enter' && openFreshSession(tab)}
>↗</span>
<span
class="ml-1 text-surface-500 hover:text-rose-400 cursor-pointer"
role="button"
@@ -345,9 +415,14 @@
<div class="absolute top-full left-0 mt-1 bg-surface-100 dark:bg-surface-800 border border-surface-300 dark:border-surface-600 rounded-lg shadow-xl z-10 py-1 min-w-[140px]">
{#each hosts as h}
<button
class="block w-full text-left px-3 py-1.5 text-sm text-surface-800 dark:text-surface-200 hover:bg-surface-200 dark:hover:bg-surface-700"
class="flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm text-surface-800 dark:text-surface-200 hover:bg-surface-200 dark:hover:bg-surface-700"
onclick={() => addTab(h.id)}
>{h.label}</button>
>
<span>{h.label}</span>
{#if h.persistent}
<span class="rounded bg-indigo-100 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300">persistent</span>
{/if}
</button>
{/each}
</div>
{/if}
@@ -371,6 +446,12 @@
Tip: drag & drop or paste images into the active terminal tab to upload them to <code class="text-surface-700 dark:text-surface-400">/tmp/claude-images</code>.
</p>
{#if activeTab && tabs.find(t => t.id === activeTab)?.statusBanner}
<div class="rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2 text-sm text-indigo-800 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-200">
{tabs.find(t => t.id === activeTab)?.statusBanner}
</div>
{/if}
{#if !tabs.length}
<div class="flex items-center justify-center h-[calc(100vh-12rem)] text-surface-600 dark:text-surface-500 text-sm">
Click + to open a terminal