feat: Implement web-based terminal with session management, command history, and dedicated UI.
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled

This commit is contained in:
2026-03-03 11:51:57 -05:00
parent 88742892d0
commit d29eefcef4
12 changed files with 638 additions and 189 deletions
+287 -117
View File
@@ -18,6 +18,8 @@ from urllib.parse import urlencode, urlparse, parse_qs
from datetime import datetime, timedelta, timezone
from typing import Optional
from pydantic import BaseModel
from app.services.shell_history_service import shell_history_service, ShellHistoryError
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect, status
@@ -26,7 +28,7 @@ from fastapi.templating import Jinja2Templates
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.dependencies import get_db, get_current_user, require_debug_mode
from app.core.dependencies import get_db, get_current_user, get_current_user_optional, require_debug_mode
_templates = Jinja2Templates(directory=str(settings.base_dir / "templates"))
from app.crud.host import HostRepository
@@ -181,15 +183,46 @@ def _get_session_token_from_request(request: Request, session_id: str, token: Op
referer = request.headers.get("referer")
if referer:
try:
referer_qs = parse_qs(urlparse(referer).query)
referer_token = referer_qs.get("token", [None])[0]
if referer_token:
return referer_token
parsed = urlparse(referer)
qs = parse_qs(parsed.query)
if "token" in qs:
return qs["token"][0]
except Exception:
pass
return None
async def _verify_history_access(
host_id: str,
request: Request,
db_session: AsyncSession,
current_user: Optional[dict] = None
) -> bool:
"""Verify access to history for a specific host (JWT or Session Token)."""
if current_user:
return True
token = _get_session_token_from_request(request, host_id, token=request.query_params.get("token"))
if not token:
return False
session_repo = TerminalSessionRepository(db_session)
# Try by ID first
sessions = await session_repo.list_active_for_host(host_id)
# Try by name if no sessions found (host_id might be a name)
if not sessions:
host_repo = HostRepository(db_session)
host = await host_repo.get_by_name(host_id)
if host:
sessions = await session_repo.list_active_for_host(host.id)
for s in sessions:
if terminal_service.verify_token(token, s.token_hash):
return True
return False
def _get_session_token_from_websocket(websocket: WebSocket, session_id: str) -> Optional[str]:
token = websocket.query_params.get("token")
if token:
@@ -884,54 +917,76 @@ async def cleanup_terminal_sessions(
"expired_marked": 0,
}
class LogCommandRequest(BaseModel):
command: str
@router.post("/sessions/{session_id}/command")
async def log_terminal_command(
session_id: str,
command_data: dict,
payload: LogCommandRequest,
request: Request,
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
):
session_repo = TerminalSessionRepository(db_session)
session = await session_repo.get(session_id)
try:
session_repo = TerminalSessionRepository(db_session)
session = await session_repo.get_active_by_id(session_id)
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
if not session:
raise HTTPException(status_code=404, detail="Session not found")
# Verify session token
actual_token = _get_session_token_from_request(request, session_id, token=request.query_params.get("token"))
if not actual_token or not terminal_service.verify_token(actual_token, session.token_hash):
raise HTTPException(status_code=403, detail="Invalid session token")
cmd = payload.command.strip()
if not cmd:
return {"status": "ignored", "reason": "empty_command"}
if len(cmd) > _MAX_TERMINAL_COMMAND_LENGTH:
cmd = cmd[:_MAX_TERMINAL_COMMAND_LENGTH]
try:
from app.security.command_policy import get_command_policy
policy = get_command_policy()
result = policy.evaluate(cmd)
# If not allowed and not blocked, we ignore it (UNKNOWN)
if not result.should_log and not result.is_blocked:
return {"status": "ignored", "reason": "not_allowed_to_log"}
is_blocked = result.is_blocked
reason = result.reason
masked_cmd = result.masked_command or ("[BLOCKED]" if is_blocked else cmd)
cmd_hash = result.command_hash or hashlib.sha256(masked_cmd.encode('utf-8')).hexdigest()
except Exception as pe:
logger.warning(f"Policy evaluation failure: {pe}")
is_blocked = False
reason = str(pe)
masked_cmd = cmd
cmd_hash = hashlib.sha256(cmd.encode('utf-8')).hexdigest()
cmd_repo = TerminalCommandLogRepository(db_session)
await cmd_repo.create(
host_id=session.host_id,
host_name=session.host_name,
user_id=session.user_id,
username=session.username,
terminal_session_id=session.id,
command=masked_cmd,
command_hash=cmd_hash,
source="terminal_dynamic",
is_blocked=is_blocked,
blocked_reason=reason,
)
token = _get_session_token_from_request(request, session_id, token=request.query_params.get("token"))
if not token or not terminal_service.verify_token(token, session.token_hash):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid session token"
)
command = command_data.get("command")
if not command:
return {"status": "ignored", "reason": "empty_command"}
if len(command) > _MAX_TERMINAL_COMMAND_LENGTH:
command = command[:_MAX_TERMINAL_COMMAND_LENGTH]
cmd_repo = TerminalCommandLogRepository(db_session)
command_hash = hashlib.sha256(command.encode()).hexdigest()
await cmd_repo.create(
host_id=session.host_id,
host_name=session.host_name,
user_id=session.user_id,
username=session.username,
terminal_session_id=session.id,
command=command,
command_hash=command_hash,
source="terminal_interactive"
)
await db_session.commit()
return {"status": "logged", "command_len": len(command)}
await db_session.commit()
return {"status": "success", "blocked": is_blocked}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error logging terminal command: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/connect/{session_id}")
async def get_terminal_connect_page(
@@ -1049,20 +1104,24 @@ async def get_terminal_connect_page(
)
history_js = """
let historyPanelOpen = false, historySelectedIndex = -1, historySearchQuery = '', historyTimeFilter = 'all';
async function toggleHistory() {
// ---- History panel state ----
let historyPanelOpen = false, historyPanelPinned = false;
let historySelectedIndex = -1, historySearchQuery = '', historyTimeFilter = 'all';
let historyPinnedOnly = false;
// ---- Panel open/close ----
function toggleHistory() {
if (historyPanelOpen && !historyPanelPinned) { closeHistoryPanel(); return; }
const panel = document.getElementById('terminalHistoryPanel');
const btn = document.getElementById('btnHistory');
if (historyPanelOpen) { closeHistoryPanel(); }
else {
panel.style.display = 'flex'; panel.classList.add('open'); btn.classList.add('active');
historyPanelOpen = true; historySelectedIndex = -1;
const si = document.getElementById('terminalHistorySearch');
if (si) { si.focus(); si.select(); }
if (historyData.length === 0) loadHistory();
}
panel.style.display = 'flex'; panel.classList.add('open'); btn.classList.add('active');
historyPanelOpen = true; historySelectedIndex = -1;
const si = document.getElementById('terminalHistorySearch');
if (si) { si.focus(); si.select(); }
if (historyData.length === 0) loadHistory();
}
function closeHistoryPanel() {
if (historyPanelPinned) return;
const panel = document.getElementById('terminalHistoryPanel');
const btn = document.getElementById('btnHistory');
panel.classList.remove('open'); btn.classList.remove('active');
@@ -1070,15 +1129,33 @@ async def get_terminal_connect_page(
setTimeout(() => { try { panel.style.display = 'none'; } catch(e){} }, 200);
document.getElementById('terminalFrame').focus();
}
function toggleHistoryPin() {
historyPanelPinned = !historyPanelPinned;
const btn = document.getElementById('btnHistoryPin');
const panel = document.getElementById('terminalHistoryPanel');
btn?.classList.toggle('active', historyPanelPinned);
panel?.classList.toggle('docked', historyPanelPinned);
}
function togglePinnedOnly() {
historyPinnedOnly = !historyPinnedOnly;
const btn = document.getElementById('btnPinnedOnly');
btn?.classList.toggle('active', historyPinnedOnly);
historySelectedIndex = -1; loadHistory();
}
// ---- Data loading ----
async function loadHistory() {
const list = document.getElementById('terminalHistoryList');
list.innerHTML = '<div class="terminal-history-loading"><i class="fas fa-spinner fa-spin"></i> Chargement...</div>';
const allHosts = document.getElementById('terminalHistoryAllHosts')?.checked || false;
const query = historySearchQuery;
let ep = allHosts ? '/api/terminal/command-history?limit=100' : `/api/terminal/${HOST_ID}/shell-history?limit=100`;
if (query) ep += (ep.includes('?') ? '&' : '?') + 'query=' + encodeURIComponent(query);
let ep = allHosts
? `/api/terminal/command-history?limit=100&token=${encodeURIComponent(TOKEN)}`
: `/api/terminal/${HOST_ID}/command-history/unique?limit=100&token=${encodeURIComponent(TOKEN)}`;
if (query) ep += '&query=' + encodeURIComponent(query);
try {
const res = await fetch(ep, { headers: { 'Authorization': 'Bearer ' + TOKEN } });
if (!res.ok) { list.innerHTML = `<div class="terminal-history-empty"><i class="fas fa-exclamation-circle"></i> Erreur ${res.status}</div>`; return; }
const data = await res.json();
let cmds = data.commands || [];
if (historyTimeFilter !== 'all') {
@@ -1089,38 +1166,82 @@ async def get_terminal_connect_page(
else if (historyTimeFilter === 'month') cutoff = new Date(now.getTime() - 30*86400000);
if (cutoff) cmds = cmds.filter(c => new Date(c.last_used||c.created_at) >= cutoff);
}
if (historyPinnedOnly) cmds = cmds.filter(c => c.is_pinned);
historyData = cmds; historySelectedIndex = -1; renderHistory();
} catch(e) { list.innerHTML = '<div class="terminal-history-empty"><i class="fas fa-exclamation-circle"></i> Erreur</div>'; }
} catch(e) { list.innerHTML = '<div class="terminal-history-empty"><i class="fas fa-exclamation-circle"></i> Erreur réseau</div>'; }
}
async function togglePinH(i) {
const cmd = historyData[i];
if (!cmd || !cmd.command_hash) return;
const newPinned = !cmd.is_pinned;
try {
const res = await fetch(`/api/terminal/${HOST_ID}/command-history/${cmd.command_hash}/pin?token=${encodeURIComponent(TOKEN)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + TOKEN },
body: JSON.stringify({ is_pinned: newPinned })
});
if (res.ok) { cmd.is_pinned = newPinned; renderHistory(); }
} catch(e) {}
}
// ---- Helpers ----
function escH(t){return (t||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#039;');}
function escRE(s){return s.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');}
function escRE(s){return s.replace(/[.*+?^${}()|[\\\\]\\\\\\\\]/g, '\\\\\\\\$&');}
function relTime(d){if(!d)return'';const dt=new Date(d),now=new Date(),ds=Math.floor((now-dt)/1000);if(ds<60)return'À l instant';const dm=Math.floor(ds/60),dh=Math.floor(dm/60),dd=Math.floor(dh/24);if(dm<60)return`Il y a ${dm}min`;if(dh<24)return`Il y a ${dh}h`;if(dd<7)return`Il y a ${dd}j`;return dt.toLocaleDateString('fr-FR',{day:'numeric',month:'short'});}
// ---- Execution ----
function execH(i){
const cmd = historyData[i];
if(cmd && cmd.command) {
const fr = document.getElementById('terminalFrame');
const cw = fr ? fr.contentWindow : null;
if(cw && cw.term) {
cw.term.focus();
cw.term.paste(cmd.command + '\\r');
closeHistoryPanel();
} else if (cw) {
cw.postMessage({ type: 'terminal:paste', text: cmd.command + '\\r' }, '*');
closeHistoryPanel();
} else {
copyH(i);
}
}
}
function copyH(i){const c=historyData[i];if(c)copyTextToClipboard(c.command).catch(()=>{});}
// ---- Rendering ----
function renderHistory(){
const list=document.getElementById('terminalHistoryList'),q=historySearchQuery||'';
if(!historyData.length){list.innerHTML=`<div class="terminal-history-empty"><i class="fas fa-terminal"></i><span>${q?`Aucun résultat pour "${escH(q)}"`:'Aucune commande'}</span></div>`;return;}
if(!historyData.length){list.innerHTML=`<div class="terminal-history-empty"><i class="fas fa-terminal"></i><span>${q?`Aucun résultat pour "${escH(q)}"`:historyPinnedOnly?'Aucune commande épinglée':'Aucune commande'}</span></div>`;return;}
list.innerHTML=historyData.map((cmd,i)=>{
const c=cmd.command||'',ta=relTime(cmd.last_used||cmd.created_at),ec=cmd.execution_count||1,sel=i===historySelectedIndex;
let dc=escH(c.length>60?c.substring(0,60)+'...':c);
const c=cmd.command||'',ta=relTime(cmd.last_used||cmd.created_at),ec=cmd.execution_count||1,sel=i===historySelectedIndex,pinned=cmd.is_pinned;
let dc=escH(c.length>80?c.substring(0,80)+'...':c);
if(q)dc=dc.replace(new RegExp(`(${escRE(q)})`,'gi'),'<mark>$1</mark>');
return `<div class="terminal-history-item${sel?' selected':''}" data-index="${i}" onclick="selectH(${i})" ondblclick="execH(${i})" title="${escH(c)}"><div class="terminal-history-cmd"><code>${dc}</code></div><div class="terminal-history-meta"><span class="terminal-history-time">${ta}</span>${ec>1?`<span class="terminal-history-count">×${ec}</span>`:''}</div><div class="terminal-history-actions-inline"><button class="terminal-history-action" onclick="event.stopPropagation();copyH(${i})" title="Copier"><i class="fas fa-copy"></i></button><button class="terminal-history-action terminal-history-action-execute" onclick="event.stopPropagation();execH(${i})" title="Exécuter"><i class="fas fa-play"></i></button></div></div>`;
return `<div class="terminal-history-item${sel?' selected':''}" data-index="${i}" onclick="execH(${i})" title="${escH(c)}"><div class="terminal-history-cmd"><code>${pinned?'<i class="fas fa-thumbtack" style="color:#fbbf24;margin-right:4px;"></i>':''}${dc}</code></div><div class="terminal-history-meta"><span class="terminal-history-time">${ta}</span>${ec>1?`<span class="terminal-history-count">×${ec}</span>`:''}</div><div class="terminal-history-actions-inline"><button class="terminal-history-action" onclick="event.stopPropagation();togglePinH(${i})" title="${pinned?'Désépingler':'Épingler'}"><i class="fas fa-thumbtack" style="${pinned?'color:#fbbf24':''}"></i></button><button class="terminal-history-action" onclick="event.stopPropagation();copyH(${i})" title="Copier"><i class="fas fa-copy"></i></button><button class="terminal-history-action terminal-history-action-execute" onclick="event.stopPropagation();execH(${i})" title="Exécuter"><i class="fas fa-play"></i></button></div></div>`;
}).join('');
if(historySelectedIndex>=0){const s=list.querySelector('.selected');if(s)s.scrollIntoView({block:'nearest',behavior:'smooth'});}
}
function handleHistoryKeydown(e){const k=e.key,l=historyData.length;if(k==='ArrowDown'){e.preventDefault();if(l>0){historySelectedIndex=Math.min(historySelectedIndex+1,l-1);renderHistory();}}else if(k==='ArrowUp'){e.preventDefault();if(l>0){historySelectedIndex=Math.max(historySelectedIndex-1,0);renderHistory();}}else if(k==='Enter'){e.preventDefault();if(historySelectedIndex>=0)selectH(historySelectedIndex);else if(l>0)selectH(0);}else if(k==='Escape'){e.preventDefault();closeHistoryPanel();}}
function handleHistoryKeydown(e){const k=e.key,l=historyData.length;if(k==='ArrowDown'){e.preventDefault();if(l>0){historySelectedIndex=Math.min(historySelectedIndex+1,l-1);renderHistory();}}else if(k==='ArrowUp'){e.preventDefault();if(l>0){historySelectedIndex=Math.max(historySelectedIndex-1,0);renderHistory();}}else if(k==='Enter'){e.preventDefault();if(historySelectedIndex>=0)execH(historySelectedIndex);else if(l>0)execH(0);}else if(k==='Escape'){e.preventDefault();closeHistoryPanel();}}
let _st=null;
function searchHistory(q){historySearchQuery=q;historySelectedIndex=-1;if(_st)clearTimeout(_st);_st=setTimeout(()=>loadHistory(),250);}
function clearHistorySearch(){const i=document.getElementById('terminalHistorySearch');if(i){i.value='';i.focus();}historySearchQuery='';historySelectedIndex=-1;loadHistory();}
function setHistoryTimeFilter(v){historyTimeFilter=v;historySelectedIndex=-1;loadHistory();}
function toggleHistoryScope(){historySelectedIndex=-1;loadHistory();}
function selectH(i){historySelectedIndex=i;renderHistory();}
function execH(i){selectH(i);closeHistoryPanel();}
function copyH(i){const c=historyData[i];if(c)copyTextToClipboard(c.command).catch(()=>{});}
document.addEventListener('keydown',(e)=>{
if(e.key==='Escape'&&historyPanelOpen)closeHistoryPanel();
if(e.key==='Escape'&&historyPanelOpen&&!historyPanelPinned)closeHistoryPanel();
if((e.ctrlKey||e.metaKey)&&e.key==='r'){e.preventDefault();toggleHistory();}
});
// postMessage handler so parent can paste into this terminal
window.addEventListener('message', (ev) => {
if (ev.data && ev.data.type === 'terminal:paste') {
const fr = document.getElementById('terminalFrame');
const cw = fr ? fr.contentWindow : null;
if (cw && cw.term) { cw.term.focus(); cw.term.paste(ev.data.text); }
}
});
"""
script_block = (
"<script>\n"
f" const SESSION_ID = {js_session_id};\n"
@@ -1179,7 +1300,32 @@ async def get_terminal_connect_page(
" const frame = document.getElementById('terminalFrame');\n"
" const loading = document.getElementById('terminalLoading');\n"
" if (!frame) return;\n"
" frame.addEventListener('load', () => { iframeLoaded = true; if (loading) loading.classList.add('hidden'); });\n"
" frame.addEventListener('load', () => { \n"
" iframeLoaded = true; if (loading) loading.classList.add('hidden');\n"
" setTimeout(() => { \n"
" try { \n"
" const cw = frame.contentWindow; \n"
" if (cw && cw.term) { \n"
" let cmdBuf = ''; \n"
" cw.term.onData(data => { \n"
" if (data === '\\r') { \n"
" const c = cmdBuf.trim(); \n"
" if (c) { \n"
" fetch('/api/terminal/sessions/' + SESSION_ID + '/command', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + TOKEN }, body: JSON.stringify({ command: c }) }).catch(()=>{}); \n"
" } \n"
" cmdBuf = ''; \n"
" } else if (data === '\\x7f') { \n"
" cmdBuf = cmdBuf.slice(0, -1); \n"
" } else if (data >= ' ' && data <= '~') { \n"
" cmdBuf += data; \n"
" } else if (data === '\\u0003') { \n"
" cmdBuf = ''; \n"
" } \n"
" }); \n"
" } \n"
" } catch(e) {} \n"
" }, 1500); \n"
" });\n"
" const qs = new URLSearchParams();\n"
" qs.set('token', TOKEN);\n"
" ttydUrl = '/api/terminal/proxy/' + encodeURIComponent(SESSION_ID) + '/?' + qs.toString();\n"
@@ -1238,25 +1384,16 @@ async def get_terminal_popout_page(
@router.get("/{host_id}/command-history", response_model=CommandHistoryResponse)
async def get_host_command_history(
host_id: str,
request: Request,
query: Optional[str] = None,
limit: int = 50,
offset: int = 0,
current_user: dict = Depends(get_current_user),
current_user: Optional[dict] = Depends(get_current_user_optional),
db_session: AsyncSession = Depends(get_db),
debug_enabled: bool = Depends(require_debug_mode),
):
"""
Get command history for a specific host.
Args:
host_id: Host ID to get history for
query: Optional search query to filter commands
limit: Maximum number of results (default 50)
offset: Number of results to skip for pagination
Returns:
List of commands with timestamps and metadata
"""
"""Get command history for a specific host."""
if not await _verify_history_access(host_id, request, db_session, current_user):
raise HTTPException(status_code=401, detail="Authentication required")
# Verify host exists
host_repo = HostRepository(db_session)
host = await host_repo.get(host_id)
@@ -1301,26 +1438,17 @@ async def get_host_command_history(
@router.get("/{host_id}/shell-history")
async def get_host_shell_history(
host_id: str,
request: Request,
query: Optional[str] = None,
limit: int = 100,
current_user: dict = Depends(get_current_user),
current_user: Optional[dict] = Depends(get_current_user_optional),
db_session: AsyncSession = Depends(get_db),
debug_enabled: bool = Depends(require_debug_mode),
):
"""
Get shell history directly from the remote host via SSH.
This fetches the actual ~/.bash_history file from the host,
providing real-time command history.
Args:
host_id: Host ID to get history for
query: Optional search query to filter commands
limit: Maximum number of results (default 100)
Returns:
List of commands from the remote shell history
"""
if not await _verify_history_access(host_id, request, db_session, current_user):
raise HTTPException(status_code=401, detail="Authentication required")
# Verify host exists
host_repo = HostRepository(db_session)
host = await host_repo.get(host_id)
@@ -1376,18 +1504,15 @@ async def get_host_shell_history(
@router.get("/{host_id}/command-history/unique", response_model=UniqueCommandsResponse)
async def get_host_unique_commands(
host_id: str,
request: Request,
query: Optional[str] = None,
limit: int = 50,
current_user: dict = Depends(get_current_user),
current_user: Optional[dict] = Depends(get_current_user_optional),
db_session: AsyncSession = Depends(get_db),
debug_enabled: bool = Depends(require_debug_mode),
):
"""
Get unique commands for a host (deduplicated).
Returns each unique command once with execution count and last used time.
Useful for command suggestions/autocomplete.
"""
"""Get unique commands for a host (deduplicated)."""
if not await _verify_history_access(host_id, request, db_session, current_user):
raise HTTPException(status_code=401, detail="Authentication required")
# Verify host exists
host_repo = HostRepository(db_session)
host = await host_repo.get(host_id)
@@ -1413,6 +1538,7 @@ async def get_host_unique_commands(
command_hash=cmd["command_hash"],
last_used=cmd["last_used"],
execution_count=cmd["execution_count"],
is_pinned=cmd.get("is_pinned", False),
)
for cmd in unique_cmds
]
@@ -1424,30 +1550,76 @@ async def get_host_unique_commands(
)
class TogglePinRequest(BaseModel):
is_pinned: bool
@router.post("/{host_id}/command-history/{command_hash}/pin")
async def toggle_command_pin(
host_id: str,
command_hash: str,
payload: TogglePinRequest,
request: Request,
current_user: Optional[dict] = Depends(get_current_user_optional),
db_session: AsyncSession = Depends(get_db),
):
"""
Toggle the pinned status of a specific command across history.
"""
if not await _verify_history_access(host_id, request, db_session, current_user):
raise HTTPException(status_code=401, detail="Authentication required")
from sqlalchemy import update
from app.models.terminal_command_log import TerminalCommandLog
# Verify host
host_repo = HostRepository(db_session)
host = await host_repo.get(host_id)
if not host:
host = await host_repo.get_by_name(host_id)
if not host:
raise HTTPException(status_code=404, detail=f"Host '{host_id}' not found")
# Update pinned state for all occurrences of this command on this host
stmt = (
update(TerminalCommandLog)
.where(
TerminalCommandLog.host_id == host.id,
TerminalCommandLog.command_hash == command_hash
)
.values(is_pinned=payload.is_pinned)
)
result = await db_session.execute(stmt)
await db_session.commit()
return {"status": "success", "is_pinned": payload.is_pinned, "updated_count": result.rowcount}
@router.get("/command-history", response_model=CommandHistoryResponse)
async def get_global_command_history(
request: Request,
query: Optional[str] = None,
host_id: Optional[str] = None,
limit: int = 50,
offset: int = 0,
current_user: dict = Depends(get_current_user),
current_user: Optional[dict] = Depends(get_current_user_optional),
db_session: AsyncSession = Depends(get_db),
debug_enabled: bool = Depends(require_debug_mode),
):
"""
Get command history globally (across all hosts).
Args:
query: Optional search query to filter commands
host_id: Optional host ID to filter by
limit: Maximum number of results (default 50)
offset: Number of results to skip for pagination
Returns:
List of commands with timestamps and metadata
Requires JWT auth OR a valid terminal session token.
"""
user_id = current_user.get("user_id") or current_user.get("type", "api_key")
# Allow access with a session token (for pop-out windows)
if not current_user:
token = _get_session_token_from_request(request, "", token=request.query_params.get("token"))
if not token:
raise HTTPException(status_code=401, detail="Authentication required")
# Verify against any active session
session_repo = TerminalSessionRepository(db_session)
sessions = await session_repo.list_all_active()
verified = any(terminal_service.verify_token(token, s.token_hash) for s in sessions)
if not verified:
raise HTTPException(status_code=401, detail="Invalid session token")
user_id = current_user.get("user_id") or current_user.get("type", "api_key") if current_user else None
cmd_repo = TerminalCommandLogRepository(db_session)
logs = await cmd_repo.list_global(
query=query,
@@ -1481,7 +1653,6 @@ async def clear_host_command_history(
host_id: str,
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
debug_enabled: bool = Depends(require_debug_mode),
):
"""
Clear command history for a specific host.
@@ -1520,7 +1691,6 @@ async def purge_old_command_history(
days: int = 30,
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
debug_enabled: bool = Depends(require_debug_mode),
):
"""
Purge command history older than specified days.