Add comprehensive SSH terminal drawer feature with embedded and popout modes, integrate playbook lint results API with local cache fallback, and enhance host management UI with terminal access buttons
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:
2025-12-17 23:59:17 -05:00
parent 05087aa380
commit 493668f746
110 changed files with 22054 additions and 24 deletions
+172
View File
@@ -0,0 +1,172 @@
"""
CRUD operations for terminal sessions.
"""
from datetime import datetime, timezone
from typing import List, Optional
from sqlalchemy import select, update, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.terminal_session import TerminalSession
class TerminalSessionRepository:
"""Repository for TerminalSession CRUD operations."""
def __init__(self, session: AsyncSession):
self.session = session
async def create(
self,
id: str,
host_id: str,
host_name: str,
host_ip: str,
token_hash: str,
ttyd_port: int,
expires_at: datetime,
user_id: Optional[str] = None,
username: Optional[str] = None,
ttyd_pid: Optional[int] = None,
mode: str = "embedded",
) -> TerminalSession:
"""Create a new terminal session."""
session = TerminalSession(
id=id,
host_id=host_id,
host_name=host_name,
host_ip=host_ip,
user_id=user_id,
username=username,
token_hash=token_hash,
ttyd_port=ttyd_port,
ttyd_pid=ttyd_pid,
mode=mode,
status="active",
expires_at=expires_at,
)
self.session.add(session)
await self.session.flush()
return session
async def get(self, session_id: str) -> Optional[TerminalSession]:
"""Get a terminal session by ID."""
result = await self.session.execute(
select(TerminalSession).where(TerminalSession.id == session_id)
)
return result.scalar_one_or_none()
async def get_active_by_id(self, session_id: str) -> Optional[TerminalSession]:
"""Get an active terminal session by ID."""
now = datetime.now(timezone.utc)
result = await self.session.execute(
select(TerminalSession).where(
and_(
TerminalSession.id == session_id,
TerminalSession.status == "active",
TerminalSession.expires_at > now
)
)
)
return result.scalar_one_or_none()
async def list_active_for_user(self, user_id: str) -> List[TerminalSession]:
"""List all active sessions for a user."""
now = datetime.now(timezone.utc)
result = await self.session.execute(
select(TerminalSession).where(
and_(
TerminalSession.user_id == user_id,
TerminalSession.status == "active",
TerminalSession.expires_at > now
)
).order_by(TerminalSession.created_at.desc())
)
return list(result.scalars().all())
async def count_active_for_user(self, user_id: str) -> int:
"""Count active sessions for a user."""
sessions = await self.list_active_for_user(user_id)
return len(sessions)
async def list_expired(self) -> List[TerminalSession]:
"""List all expired but not yet closed sessions."""
now = datetime.now(timezone.utc)
result = await self.session.execute(
select(TerminalSession).where(
and_(
TerminalSession.status == "active",
TerminalSession.expires_at <= now
)
)
)
return list(result.scalars().all())
async def update_status(
self,
session_id: str,
status: str,
closed_at: Optional[datetime] = None
) -> Optional[TerminalSession]:
"""Update session status."""
session = await self.get(session_id)
if session:
session.status = status
if closed_at:
session.closed_at = closed_at
await self.session.flush()
return session
async def close_session(self, session_id: str) -> Optional[TerminalSession]:
"""Close a terminal session."""
return await self.update_status(
session_id,
status="closed",
closed_at=datetime.now(timezone.utc)
)
async def mark_expired(self, session_id: str) -> Optional[TerminalSession]:
"""Mark a session as expired."""
return await self.update_status(
session_id,
status="expired",
closed_at=datetime.now(timezone.utc)
)
async def cleanup_old_sessions(self, days: int = 7) -> int:
"""Delete sessions older than specified days."""
from datetime import timedelta
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
result = await self.session.execute(
select(TerminalSession).where(
and_(
TerminalSession.status.in_(["closed", "expired", "error"]),
TerminalSession.created_at < cutoff
)
)
)
sessions = result.scalars().all()
count = len(sessions)
for session in sessions:
await self.session.delete(session)
await self.session.flush()
return count
async def close_all_active(self) -> int:
"""Close all active sessions (for cleanup/reset)."""
now = datetime.now(timezone.utc)
result = await self.session.execute(
select(TerminalSession).where(TerminalSession.status == "active")
)
sessions = list(result.scalars().all())
count = len(sessions)
for session in sessions:
session.status = "closed"
session.closed_at = now
await self.session.flush()
return count
+199
View File
@@ -2967,6 +2967,205 @@
padding: 12px;
}
}
/* =====================================================
Terminal SSH Drawer Styles
===================================================== */
.terminal-drawer {
position: fixed;
inset: 0;
z-index: 1000;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s ease;
}
.terminal-drawer.open {
opacity: 1;
pointer-events: all;
}
.terminal-drawer-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 0;
}
.terminal-drawer-panel {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 50vw;
min-width: 400px;
max-width: 900px;
background: #0f0f1a;
border-left: 1px solid #374151;
display: flex;
flex-direction: column;
z-index: 1;
transform: translateX(100%);
transition: transform 0.3s ease;
}
.terminal-drawer.open .terminal-drawer-panel {
transform: translateX(0);
}
.terminal-drawer-header {
padding: 0.75rem 1rem;
background: #1a1a2e;
border-bottom: 1px solid #374151;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.terminal-host-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.terminal-host-name {
font-weight: 600;
color: #fff;
}
.terminal-host-ip {
color: #9ca3af;
font-size: 0.875rem;
}
.terminal-status-badge {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.terminal-status-badge.online {
background: rgba(34, 197, 94, 0.2);
color: #4ade80;
}
.terminal-status-badge.connecting {
background: rgba(251, 191, 36, 0.2);
color: #fbbf24;
}
.terminal-header-actions {
display: flex;
gap: 0.5rem;
}
.terminal-btn {
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.875rem;
border: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.375rem;
transition: all 0.15s;
}
.terminal-btn-secondary {
background: #374151;
color: #e5e7eb;
}
.terminal-btn-secondary:hover {
background: #4b5563;
}
.terminal-btn-danger {
background: #dc2626;
color: #fff;
}
.terminal-btn-danger:hover {
background: #b91c1c;
}
.terminal-drawer-body {
flex: 1;
position: relative;
overflow: hidden;
background: #000;
}
.terminal-iframe {
width: 100%;
height: 100%;
border: none;
background: #000;
}
.terminal-loading {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #0f0f1a;
}
.terminal-loading .spinner {
width: 48px;
height: 48px;
border: 3px solid #374151;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.terminal-loading .loading-text {
margin-top: 1rem;
color: #9ca3af;
}
.terminal-drawer-footer {
padding: 0.5rem 1rem;
background: #1a1a2e;
border-top: 1px solid #374151;
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.terminal-timer {
margin-left: auto;
font-size: 0.875rem;
color: #9ca3af;
font-family: monospace;
}
.terminal-timer.warning {
color: #fbbf24;
}
.terminal-timer.expired {
color: #ef4444;
}
/* Mobile responsive */
@media (max-width: 768px) {
.terminal-drawer-panel {
width: 100vw;
min-width: unset;
max-width: unset;
}
.terminal-host-ip {
display: none;
}
}
</style>
</head>
<body>
+390 -18
View File
@@ -84,6 +84,11 @@ class DashboardManager {
// WebSocket
this.ws = null;
// Terminal SSH
this.terminalSession = null;
this.terminalDrawerOpen = false;
this.terminalFeatureAvailable = false;
// Configuration: collecte métriques
this.metricsCollectionInterval = 'off';
@@ -465,6 +470,9 @@ class DashboardManager {
schedules: this.schedules.length
});
// Charger les résultats de lint depuis l'API
await this.loadPlaybookLintResults();
// Mettre à jour l'affichage
this.renderHosts();
this.renderTasks();
@@ -1035,6 +1043,24 @@ class DashboardManager {
// Initialiser le calendrier de filtrage des tâches
this.setupTaskDateCalendar();
// Event delegation for terminal buttons (avoids inline onclick issues)
document.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action="terminal"], [data-action="terminal-popout"]');
if (!btn || btn.disabled) return;
e.stopPropagation();
const action = btn.dataset.action;
const hostId = btn.dataset.hostId;
const hostName = btn.dataset.hostName;
const hostIp = btn.dataset.hostIp;
if (action === 'terminal') {
this.openTerminal(hostId, hostName, hostIp);
} else if (action === 'terminal-popout') {
this.openTerminalPopout(hostId, hostName, hostIp);
}
});
// Navigation est gérée par le script de navigation des pages dans index.html
}
@@ -1576,6 +1602,24 @@ class DashboardManager {
<button class="px-3 py-1.5 bg-teal-600 text-xs rounded-lg hover:bg-teal-500 transition-colors flex items-center" onclick="dashboard.showPlaybookModalForHost('${host.name}')" title="Exécuter un playbook sur cet hôte">
<i class="fas fa-play-circle mr-1"></i>Playbook
</button>
<button class="px-3 py-1.5 ${bootstrapOk && host.status !== 'offline' ? 'bg-cyan-600 hover:bg-cyan-500' : 'bg-gray-600 cursor-not-allowed opacity-50'} text-xs rounded-lg transition-colors flex items-center btn-terminal"
data-action="terminal"
data-host-id="${host.id}"
data-host-name="${this.escapeHtml(host.name)}"
data-host-ip="${host.ip}"
title="${!bootstrapOk ? 'Bootstrap requis pour SSH' : host.status === 'offline' ? 'Hôte hors ligne' : 'Ouvrir un terminal SSH'}"
${!bootstrapOk || host.status === 'offline' ? 'disabled' : ''}>
<i class="fas fa-terminal mr-1"></i>Terminal
</button>
<button class="px-3 py-1.5 ${bootstrapOk && host.status !== 'offline' ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-gray-600 cursor-not-allowed opacity-50'} text-xs rounded-lg transition-colors flex items-center btn-terminal-popout"
data-action="terminal-popout"
data-host-id="${host.id}"
data-host-name="${this.escapeHtml(host.name)}"
data-host-ip="${host.ip}"
title="${!bootstrapOk ? 'Bootstrap requis pour SSH' : host.status === 'offline' ? 'Hôte hors ligne' : 'Ouvrir terminal dans une nouvelle fenêtre'}"
${!bootstrapOk || host.status === 'offline' ? 'disabled' : ''}>
<i class="fas fa-external-link-alt mr-1"></i>Pop-out
</button>
</div>
`;
// Append to all containers
@@ -7319,11 +7363,58 @@ class DashboardManager {
// ===== GESTION DES PLAYBOOKS =====
// Cache des résultats lint (chargé depuis l'API backend)
playbookLintResults = {};
async loadPlaybookLintResults() {
/**
* Charge tous les résultats de lint depuis l'API backend.
* Appelé au démarrage et après chaque lint.
*/
try {
const data = await this.apiCall('/api/playbooks/results');
this.playbookLintResults = data?.results || {};
console.log('[Dashboard] Loaded lint results for', Object.keys(this.playbookLintResults).length, 'playbooks');
} catch (e) {
console.warn('[Dashboard] Failed to load lint results from API:', e);
// Fallback: essayer localStorage
this.playbookLintResults = this.getPlaybookLintResultsFromLocalStorage();
}
}
getPlaybookLintStorageKey() {
return 'playbookLintResults:v1';
}
getPlaybookLintResultsFromLocalStorage() {
try {
const raw = localStorage.getItem(this.getPlaybookLintStorageKey());
if (!raw) return {};
const map = JSON.parse(raw);
// Convertir au format attendu (result -> direct)
const converted = {};
for (const [filename, entry] of Object.entries(map)) {
if (entry?.result) {
converted[filename] = {
...entry.result,
updated_at: entry.stored_at
};
}
}
return converted;
} catch (e) {
return {};
}
}
storePlaybookLintResult(filename, lintResult) {
// Mettre à jour le cache local immédiatement
this.playbookLintResults[filename] = {
...lintResult,
updated_at: new Date().toISOString()
};
// Aussi sauvegarder en localStorage comme fallback
try {
const key = this.getPlaybookLintStorageKey();
const raw = localStorage.getItem(key);
@@ -7336,32 +7427,29 @@ class DashboardManager {
localStorage.setItem(key, JSON.stringify(map));
} catch (e) {
console.warn('Failed to store lint result:', e);
console.warn('Failed to store lint result in localStorage:', e);
}
}
getPlaybookLintResult(filename) {
try {
const raw = localStorage.getItem(this.getPlaybookLintStorageKey());
if (!raw) return null;
const map = JSON.parse(raw);
return map?.[filename] || null;
} catch (e) {
return null;
// Utiliser le cache chargé depuis l'API
const result = this.playbookLintResults?.[filename];
if (result) {
return { result, stored_at: result.updated_at };
}
return null;
}
async copyPlaybookLintToClipboard(filename) {
const entry = this.getPlaybookLintResult(filename);
if (!entry?.result) {
const lintData = this.playbookLintResults?.[filename];
if (!lintData) {
this.showNotification('Aucun résultat lint à copier (exécutez Lint d\'abord)', 'warning');
return;
}
const payload = {
filename,
stored_at: entry.stored_at,
...entry.result
...lintData
};
const text = JSON.stringify(payload, null, 2);
@@ -7484,14 +7572,14 @@ class DashboardManager {
// Calculer le temps relatif
const modifiedAgo = playbook.modified ? this.getRelativeTime(playbook.modified) : 'Date inconnue';
const lintEntry = this.getPlaybookLintResult(playbook.filename);
const lintResult = lintEntry?.result || null;
const qualityScore = typeof lintResult?.quality_score === 'number' ? lintResult.quality_score : null;
const lintSummary = lintResult?.summary || null;
const lintTooltip = lintResult ? (
// Récupérer le résultat lint depuis le cache (API backend ou localStorage)
const lintData = this.playbookLintResults?.[playbook.filename] || null;
const qualityScore = typeof lintData?.quality_score === 'number' ? lintData.quality_score : null;
const lintSummary = lintData?.summary || null;
const lintTooltip = lintData ? (
`Quality: ${qualityScore}/100\n` +
`Total: ${lintSummary?.total ?? 0} | Errors: ${lintSummary?.errors ?? 0} | Warnings: ${lintSummary?.warnings ?? 0}\n` +
`Dernier lint: ${lintEntry?.stored_at ? new Date(lintEntry.stored_at).toLocaleString('fr-FR') : ''}`
`Dernier lint: ${lintData?.updated_at ? new Date(lintData.updated_at).toLocaleString('fr-FR') : ''}`
) : 'Aucun lint exécuté';
let lintBadgeHtml = '';
@@ -10002,6 +10090,290 @@ class DashboardManager {
`;
}).join('');
}
// =====================================================
// Terminal SSH - Web Terminal Feature
// =====================================================
async checkTerminalFeatureStatus() {
try {
const data = await this.apiCall('/api/terminal/status');
this.terminalFeatureAvailable = Boolean(data && data.available);
return data;
} catch (e) {
console.warn('Terminal feature check failed:', e);
this.terminalFeatureAvailable = false;
}
return { available: false };
}
async openTerminal(hostId, hostName, hostIp) {
// Check if terminal feature is available
if (!this.terminalFeatureAvailable) {
const status = await this.checkTerminalFeatureStatus();
if (!status.available) {
this.showNotification('Terminal SSH non disponible: ttyd n\'est pas installé', 'error');
return;
}
}
// Show loading state
this.showTerminalDrawer(hostName, hostIp, true);
try {
const session = await this.apiCall(`/api/terminal/${hostId}/terminal-sessions`, {
method: 'POST',
body: JSON.stringify({ mode: 'embedded' })
});
this.terminalSession = session;
// Update drawer with terminal iframe
this.updateTerminalDrawer(session);
} catch (e) {
console.error('Failed to open terminal:', e);
this.showNotification(`Erreur terminal: ${e.message}`, 'error');
this.closeTerminalDrawer();
}
}
async openTerminalPopout(hostId, hostName, hostIp) {
// Check if terminal feature is available
if (!this.terminalFeatureAvailable) {
const status = await this.checkTerminalFeatureStatus();
if (!status.available) {
this.showNotification('Terminal SSH non disponible: ttyd n\'est pas installé', 'error');
return;
}
}
this.showNotification('Création de la session terminal...', 'info');
try {
const session = await this.apiCall(`/api/terminal/${hostId}/terminal-sessions`, {
method: 'POST',
body: JSON.stringify({ mode: 'popout' })
});
// Open in popup window
const popupUrl = session.url;
const popupFeatures = 'width=900,height=600,menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=no';
const popup = window.open(popupUrl, `terminal_${session.session_id}`, popupFeatures);
if (!popup || popup.closed) {
// Popup blocked - open in new tab instead
window.open(popupUrl, '_blank');
this.showNotification('Terminal ouvert dans un nouvel onglet (popup bloqué)', 'warning');
}
} catch (e) {
console.error('Failed to open terminal popout:', e);
this.showNotification(`Erreur terminal: ${e.message}`, 'error');
}
}
showTerminalDrawer(hostName, hostIp, loading = false) {
this.terminalDrawerOpen = true;
// Create drawer if it doesn't exist
let drawer = document.getElementById('terminalDrawer');
if (!drawer) {
drawer = document.createElement('div');
drawer.id = 'terminalDrawer';
drawer.className = 'terminal-drawer';
document.body.appendChild(drawer);
}
const loadingContent = loading ? `
<div class="terminal-loading">
<div class="spinner"></div>
<div class="loading-text">Connexion à ${this.escapeHtml(hostName)}...</div>
</div>
` : '';
drawer.innerHTML = `
<div class="terminal-drawer-backdrop" onclick="dashboard.closeTerminalDrawer()"></div>
<div class="terminal-drawer-panel" onclick="event.stopPropagation()">
<div class="terminal-drawer-header">
<div class="terminal-host-info">
<span class="terminal-host-name">${this.escapeHtml(hostName)}</span>
<span class="terminal-host-ip">${this.escapeHtml(hostIp)}</span>
<span class="terminal-status-badge online">Connexion...</span>
</div>
<div class="terminal-header-actions">
<button class="terminal-btn terminal-btn-secondary" onclick="dashboard.openCurrentTerminalPopout()" title="Ouvrir dans une nouvelle fenêtre">
<i class="fas fa-external-link-alt"></i>
</button>
<button class="terminal-btn terminal-btn-danger" onclick="dashboard.closeTerminalDrawer()" title="Fermer">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<div class="terminal-drawer-body" id="terminalDrawerBody">
${loadingContent}
</div>
<div class="terminal-drawer-footer">
<button class="terminal-btn terminal-btn-secondary" onclick="dashboard.copySSHCommand()">
<i class="fas fa-copy mr-1"></i>Copier SSH
</button>
<button class="terminal-btn terminal-btn-secondary" onclick="dashboard.reconnectTerminal()">
<i class="fas fa-redo mr-1"></i>Reconnecter
</button>
<span class="terminal-timer" id="terminalTimer"></span>
</div>
</div>
`;
// Animate in
requestAnimationFrame(() => {
drawer.classList.add('open');
});
// Add escape key handler
this._terminalEscHandler = (e) => {
if (e.key === 'Escape' && this.terminalDrawerOpen) {
this.closeTerminalDrawer();
}
};
document.addEventListener('keydown', this._terminalEscHandler);
}
updateTerminalDrawer(session) {
const body = document.getElementById('terminalDrawerBody');
if (!body) return;
const statusBadge = document.querySelector('.terminal-status-badge');
if (statusBadge) {
statusBadge.textContent = 'Connecté';
statusBadge.classList.remove('connecting');
statusBadge.classList.add('online');
}
// Create iframe for terminal
body.innerHTML = `
<iframe
id="terminalIframe"
src="${session.url}"
class="terminal-iframe"
allow="clipboard-read; clipboard-write"
onload="dashboard.onTerminalIframeLoad()"
></iframe>
`;
// Start countdown timer
this.startTerminalTimer(session.ttl_seconds);
}
onTerminalIframeLoad() {
const iframe = document.getElementById('terminalIframe');
if (iframe) {
iframe.focus();
}
}
startTerminalTimer(seconds) {
const timerEl = document.getElementById('terminalTimer');
if (!timerEl) return;
let remaining = seconds;
const updateTimer = () => {
if (remaining <= 0) {
timerEl.textContent = 'Session expirée';
timerEl.classList.add('expired');
return;
}
const mins = Math.floor(remaining / 60);
const secs = remaining % 60;
timerEl.textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
if (remaining < 60) {
timerEl.classList.add('warning');
}
remaining--;
};
updateTimer();
this._terminalTimerInterval = setInterval(updateTimer, 1000);
}
async closeTerminalDrawer() {
const drawer = document.getElementById('terminalDrawer');
if (drawer) {
drawer.classList.remove('open');
setTimeout(() => {
drawer.remove();
}, 300);
}
// Clean up timer
if (this._terminalTimerInterval) {
clearInterval(this._terminalTimerInterval);
this._terminalTimerInterval = null;
}
// Remove escape handler
if (this._terminalEscHandler) {
document.removeEventListener('keydown', this._terminalEscHandler);
this._terminalEscHandler = null;
}
// Close session on server (best effort)
if (this.terminalSession) {
try {
await this.apiCall(`/api/terminal/sessions/${this.terminalSession.session_id}`, {
method: 'DELETE'
});
} catch (e) {
console.warn('Failed to close terminal session:', e);
}
this.terminalSession = null;
}
this.terminalDrawerOpen = false;
}
openCurrentTerminalPopout() {
if (!this.terminalSession) {
this.showNotification('Aucune session terminal active', 'warning');
return;
}
const popupUrl = this.terminalSession.url;
const popupFeatures = 'width=900,height=600,menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=no';
window.open(popupUrl, `terminal_${this.terminalSession.session_id}`, popupFeatures);
}
copySSHCommand() {
if (!this.terminalSession) {
this.showNotification('Aucune session terminal active', 'warning');
return;
}
const cmd = `ssh automation@${this.terminalSession.host.ip}`;
navigator.clipboard.writeText(cmd).then(() => {
this.showNotification(`Commande copiée: ${cmd}`, 'success');
}).catch(() => {
this.showNotification('Impossible de copier dans le presse-papier', 'error');
});
}
async reconnectTerminal() {
if (!this.terminalSession) {
this.showNotification('Aucune session terminal active', 'warning');
return;
}
const { host } = this.terminalSession;
// Close current session
await this.closeTerminalDrawer();
// Reopen
await this.openTerminal(host.id, host.name, host.ip);
}
}
// Initialize dashboard when DOM is loaded
+6
View File
@@ -13,9 +13,14 @@ from .docker_container import DockerContainer
from .docker_image import DockerImage
from .docker_volume import DockerVolume
from .docker_alert import DockerAlert
from .terminal_session import TerminalSession
__all__ = [
"Base",
"get_db",
"init_db",
"async_session_maker",
"engine",
"Host",
"BootstrapStatus",
"Task",
@@ -31,4 +36,5 @@ __all__ = [
"DockerImage",
"DockerVolume",
"DockerAlert",
"TerminalSession",
]
+1
View File
@@ -124,6 +124,7 @@ async def init_db() -> None:
docker_image,
docker_volume,
docker_alert,
playbook_lint,
) # noqa: F401
async with engine.begin() as conn:
+81
View File
@@ -0,0 +1,81 @@
"""
Model for storing ansible-lint results per playbook.
"""
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import Column, DateTime, Integer, String, Text, JSON
from sqlalchemy.orm import Mapped, mapped_column
from .database import Base
class PlaybookLintResult(Base):
"""Stores the latest ansible-lint result for each playbook."""
__tablename__ = "playbook_lint_results"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# Playbook filename (unique constraint)
filename: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
# Quality score (0-100)
quality_score: Mapped[int] = mapped_column(Integer, nullable=False, default=100)
# Summary counts
total_issues: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
errors_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
warnings_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Execution time in milliseconds
execution_time_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Full issues list as JSON
issues_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Raw output from ansible-lint (for copy to clipboard)
raw_output: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc)
)
def __repr__(self) -> str:
return f"<PlaybookLintResult(filename={self.filename}, score={self.quality_score})>"
def to_dict(self) -> dict:
"""Convert to dictionary for API response."""
import json
issues = []
if self.issues_json:
try:
issues = json.loads(self.issues_json)
except (json.JSONDecodeError, TypeError):
issues = []
return {
"id": self.id,
"filename": self.filename,
"quality_score": self.quality_score,
"summary": {
"total": self.total_issues,
"errors": self.errors_count,
"warnings": self.warnings_count,
},
"issues": issues,
"execution_time_ms": self.execution_time_ms,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
+53
View File
@@ -0,0 +1,53 @@
"""
Model for terminal sessions - stores SSH terminal session metadata.
"""
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, Integer, String, text
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from .database import Base
class TerminalSession(Base):
"""
Represents an active or recent SSH terminal session.
Sessions are created when a user opens a terminal to a host,
and cleaned up after expiration or manual closure.
"""
__tablename__ = "terminal_sessions"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
host_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
host_name: Mapped[str] = mapped_column(String, nullable=False)
host_ip: Mapped[str] = mapped_column(String, nullable=False)
user_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
username: Mapped[Optional[str]] = mapped_column(String, nullable=True)
# Token hash for session authentication (never store plain token)
token_hash: Mapped[str] = mapped_column(String(128), nullable=False)
# ttyd process management
ttyd_port: Mapped[int] = mapped_column(Integer, nullable=False)
ttyd_pid: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# Session mode: 'embedded' or 'popout'
mode: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'embedded'"))
# Session status: 'active', 'closed', 'expired', 'error'
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'active'"))
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
def __repr__(self) -> str:
return f"<TerminalSession id={self.id} host={self.host_name} status={self.status}>"
+2
View File
@@ -26,6 +26,7 @@ from app.routes.server import router as server_router
from app.routes.alerts import router as alerts_router
from app.routes.docker import router as docker_router
from app.routes.lint import router as lint_router
from app.routes.terminal import router as terminal_router
# Router principal qui agrège tous les sous-routers
api_router = APIRouter()
@@ -50,6 +51,7 @@ api_router.include_router(server_router, prefix="/server", tags=["Server"])
api_router.include_router(alerts_router, prefix="/alerts", tags=["Alerts"])
api_router.include_router(docker_router, prefix="/docker", tags=["Docker"])
api_router.include_router(lint_router, prefix="/playbooks", tags=["Lint"])
api_router.include_router(terminal_router, prefix="/terminal", tags=["Terminal"])
__all__ = [
"api_router",
+83 -2
View File
@@ -9,11 +9,15 @@ import re
import tempfile
import time
from pathlib import Path
from typing import Optional
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import verify_api_key
from app.models.database import get_db
from app.models.playbook_lint import PlaybookLintResult
from app.schemas.lint import (
LintRequest,
LintResponse,
@@ -272,7 +276,8 @@ def parse_ansible_lint_text(stdout: str, original_filename: str) -> list[LintIss
async def lint_playbook(
filename: str,
request: LintRequest,
api_key_valid: bool = Depends(verify_api_key)
api_key_valid: bool = Depends(verify_api_key),
db: AsyncSession = Depends(get_db)
):
"""
Exécute ansible-lint sur le contenu d'un playbook.
@@ -384,6 +389,43 @@ async def lint_playbook(
info=sum(1 for i in issues if i.severity == "info"),
)
# Sauvegarder le résultat en base de données
try:
# Chercher un résultat existant pour ce playbook
stmt = select(PlaybookLintResult).where(PlaybookLintResult.filename == filename)
result = await db.execute(stmt)
existing = result.scalar_one_or_none()
# Sérialiser les issues en JSON
issues_json = json.dumps([i.model_dump() for i in issues])
if existing:
# Mettre à jour
existing.quality_score = quality_score
existing.total_issues = summary.total
existing.errors_count = summary.errors
existing.warnings_count = summary.warnings
existing.execution_time_ms = execution_time_ms
existing.issues_json = issues_json
else:
# Créer nouveau
lint_result = PlaybookLintResult(
filename=filename,
quality_score=quality_score,
total_issues=summary.total,
errors_count=summary.errors,
warnings_count=summary.warnings,
execution_time_ms=execution_time_ms,
issues_json=issues_json,
)
db.add(lint_result)
await db.commit()
logger.debug("Lint result saved for %s", filename)
except Exception as e:
logger.warning("Failed to save lint result: %s", e)
# Ne pas faire échouer la requête si la sauvegarde échoue
return LintResponse(
success=True,
execution_time_ms=execution_time_ms,
@@ -401,6 +443,45 @@ async def lint_playbook(
pass
@router.get("/results")
async def get_all_lint_results(
api_key_valid: bool = Depends(verify_api_key),
db: AsyncSession = Depends(get_db)
):
"""
Récupère tous les résultats de lint stockés en base de données.
Utile pour afficher les scores dans la liste des playbooks.
"""
stmt = select(PlaybookLintResult).order_by(PlaybookLintResult.updated_at.desc())
result = await db.execute(stmt)
lint_results = result.scalars().all()
return {
"results": {r.filename: r.to_dict() for r in lint_results},
"count": len(lint_results)
}
@router.get("/results/{filename}")
async def get_lint_result(
filename: str,
api_key_valid: bool = Depends(verify_api_key),
db: AsyncSession = Depends(get_db)
):
"""
Récupère le dernier résultat de lint pour un playbook spécifique.
"""
stmt = select(PlaybookLintResult).where(PlaybookLintResult.filename == filename)
result = await db.execute(stmt)
lint_result = result.scalar_one_or_none()
if not lint_result:
raise HTTPException(status_code=404, detail=f"Aucun résultat lint pour {filename}")
return lint_result.to_dict()
@router.get("/rules")
async def list_lint_rules(api_key_valid: bool = Depends(verify_api_key)):
"""
+7 -3
View File
@@ -21,12 +21,16 @@ async def get_playbook_content(
filename: str,
api_key_valid: bool = Depends(verify_api_key)
):
"""Récupère le contenu d'un playbook."""
playbook_path = ansible_service.playbooks_dir / filename
"""Récupère le contenu d'un playbook (normal ou builtin)."""
if not filename.endswith(('.yml', '.yaml')):
raise HTTPException(status_code=400, detail="Extension de fichier invalide. Utilisez .yml ou .yaml")
# Vérifier si c'est un builtin playbook (commence par _builtin_)
if filename.startswith("_builtin_"):
playbook_path = ansible_service.playbooks_dir / "builtin" / filename
else:
playbook_path = ansible_service.playbooks_dir / filename
if not playbook_path.exists():
raise HTTPException(status_code=404, detail=f"Playbook non trouvé: {filename}")
+756
View File
@@ -0,0 +1,756 @@
"""
Routes API pour les sessions terminal SSH web.
Provides endpoints for:
- Creating terminal sessions
- Listing active sessions
- Closing sessions
- Serving terminal popout page
"""
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_db, get_current_user
from app.crud.host import HostRepository
from app.crud.bootstrap_status import BootstrapStatusRepository
from app.crud.terminal_session import TerminalSessionRepository
from app.schemas.terminal import (
TerminalSessionRequest,
TerminalSessionResponse,
TerminalSessionHost,
TerminalSessionStatus,
TerminalSessionList,
)
from app.services.terminal_service import (
terminal_service,
TERMINAL_SESSION_TTL_MINUTES,
TERMINAL_MAX_SESSIONS_PER_USER,
HostNotReadyError,
SessionLimitExceededError,
TtydNotAvailableError,
)
logger = logging.getLogger(__name__)
router = APIRouter()
def _as_utc_aware(dt: Optional[datetime]) -> Optional[datetime]:
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
@router.get("/status")
async def get_terminal_feature_status():
"""
Check if terminal feature is available.
Returns availability status and configuration info.
"""
ttyd_available = terminal_service.check_ttyd_available()
return {
"available": ttyd_available,
"ttyd_installed": ttyd_available,
"max_sessions_per_user": TERMINAL_MAX_SESSIONS_PER_USER,
"session_ttl_minutes": TERMINAL_SESSION_TTL_MINUTES,
"active_sessions": terminal_service.get_active_session_count(),
}
@router.post("/{host_id}/terminal-sessions", response_model=TerminalSessionResponse)
async def create_terminal_session(
host_id: str,
request: TerminalSessionRequest = TerminalSessionRequest(),
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
):
"""
Create a new terminal session for a host.
Requires:
- Host must exist and be online
- Host must have bootstrap completed
- User must not exceed max sessions limit
Returns session URL and metadata.
"""
# Check if ttyd is available
if not terminal_service.check_ttyd_available():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Terminal feature unavailable: ttyd is not installed"
)
# Get host information
host_repo = HostRepository(db_session)
bs_repo = BootstrapStatusRepository(db_session)
session_repo = TerminalSessionRepository(db_session)
# Try to get host by ID first, then by name
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=status.HTTP_404_NOT_FOUND,
detail=f"Host '{host_id}' not found"
)
# Check host status
if host.status == "offline":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Host '{host.name}' is offline. Cannot open terminal."
)
# Check bootstrap status
bootstrap = await bs_repo.latest_for_host(host.id)
bootstrap_ok = bootstrap and bootstrap.status == "success"
if not bootstrap_ok:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Host '{host.name}' has not been bootstrapped. Run bootstrap first to configure SSH access."
)
# Get user info
user_id = current_user.get("user_id") or current_user.get("type", "api_key")
username = current_user.get("username", "api_user")
# Check session limit
active_count = await session_repo.count_active_for_user(user_id)
if active_count >= TERMINAL_MAX_SESSIONS_PER_USER:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Maximum active sessions ({TERMINAL_MAX_SESSIONS_PER_USER}) reached. Close an existing session first."
)
# Generate session credentials
session_id = terminal_service.generate_session_id()
token, token_hash = terminal_service.generate_session_token()
# Allocate port
try:
port = await terminal_service.allocate_port(session_id)
except Exception as e:
logger.error(f"Failed to allocate port: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="No available ports for terminal session"
)
# Calculate expiration
expires_at = datetime.now(timezone.utc) + timedelta(minutes=TERMINAL_SESSION_TTL_MINUTES)
# Spawn ttyd process
try:
pid = await terminal_service.spawn_ttyd(
session_id=session_id,
host_ip=host.ip_address,
port=port,
token=token,
)
if pid is None:
await terminal_service.release_port(port)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to start terminal process"
)
except TtydNotAvailableError:
await terminal_service.release_port(port)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Terminal feature unavailable: ttyd is not installed"
)
except Exception as e:
await terminal_service.release_port(port)
logger.exception(f"Failed to spawn ttyd: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to start terminal: {str(e)}"
)
# Create session in database
try:
session = await session_repo.create(
id=session_id,
host_id=host.id,
host_name=host.name,
host_ip=host.ip_address,
token_hash=token_hash,
ttyd_port=port,
ttyd_pid=pid,
expires_at=expires_at,
user_id=user_id,
username=username,
mode=request.mode,
)
await db_session.commit()
except Exception as e:
# Cleanup on failure
await terminal_service.terminate_session(session_id)
await terminal_service.release_port(port)
logger.exception(f"Failed to create session in DB: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create terminal session"
)
# Log audit event
logger.info(f"Terminal session created: user={username} host={host.name} session={session_id[:8]}...")
connect_path = f"/api/terminal/connect/{session_id}?token={token}"
popout_path = f"/api/terminal/popout/{session_id}?token={token}"
session_url = popout_path if request.mode == "popout" else connect_path
# Build response
return TerminalSessionResponse(
session_id=session_id,
url=session_url,
websocket_url=f"ws://localhost:{port}/ws",
expires_at=expires_at,
ttl_seconds=TERMINAL_SESSION_TTL_MINUTES * 60,
mode=request.mode,
host=TerminalSessionHost(
id=host.id,
name=host.name,
ip=host.ip_address,
status=host.status or "unknown",
bootstrap_ok=bootstrap_ok,
),
)
@router.get("/sessions", response_model=TerminalSessionList)
async def list_terminal_sessions(
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
):
"""
List active terminal sessions for the current user.
"""
user_id = current_user.get("user_id") or current_user.get("type", "api_key")
session_repo = TerminalSessionRepository(db_session)
sessions = await session_repo.list_active_for_user(user_id)
now = datetime.now(timezone.utc)
session_list = []
for session in sessions:
expires_at = _as_utc_aware(session.expires_at)
created_at = _as_utc_aware(session.created_at)
remaining = max(0, int(((expires_at or now) - now).total_seconds()))
session_list.append(TerminalSessionStatus(
session_id=session.id,
status=session.status,
host_name=session.host_name,
created_at=created_at,
expires_at=expires_at,
remaining_seconds=remaining,
))
return TerminalSessionList(
sessions=session_list,
total=len(session_list),
max_per_user=TERMINAL_MAX_SESSIONS_PER_USER,
)
@router.delete("/sessions/{session_id}")
async def close_terminal_session(
session_id: str,
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
):
"""
Close a terminal session.
Terminates the ttyd process and marks session as closed.
"""
user_id = current_user.get("user_id") or current_user.get("type", "api_key")
username = current_user.get("username", "api_user")
session_repo = TerminalSessionRepository(db_session)
session = await session_repo.get(session_id)
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
# Check ownership (unless admin)
if session.user_id != user_id and current_user.get("role") != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot close another user's session"
)
# Terminate ttyd process
await terminal_service.terminate_session(session_id)
await terminal_service.release_port(session.ttyd_port)
# Update database
await session_repo.close_session(session_id)
await db_session.commit()
logger.info(f"Terminal session closed: user={username} host={session.host_name} session={session_id[:8]}...")
return {"message": "Session closed", "session_id": session_id}
@router.post("/cleanup")
async def cleanup_terminal_sessions(
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
):
"""
Force cleanup of all terminal sessions.
Closes all active sessions in DB and terminates any orphaned ttyd processes.
Useful when sessions get stuck or to reset the terminal feature.
"""
session_repo = TerminalSessionRepository(db_session)
# Get all active sessions to terminate their processes
active_sessions = await session_repo.list_active_for_user(
current_user.get("user_id") or current_user.get("type", "api_key")
)
terminated = 0
for session in active_sessions:
try:
await terminal_service.terminate_session(session.id)
await terminal_service.release_port(session.ttyd_port)
terminated += 1
except Exception as e:
logger.warning(f"Failed to terminate session {session.id[:8]}: {e}")
# Close all active sessions in DB
closed = await session_repo.close_all_active()
await db_session.commit()
# Also cleanup expired sessions
expired = await session_repo.list_expired()
for session in expired:
await session_repo.mark_expired(session.id)
await db_session.commit()
logger.info(f"Terminal cleanup: {terminated} processes terminated, {closed} sessions closed")
return {
"message": "Cleanup completed",
"processes_terminated": terminated,
"sessions_closed": closed,
"expired_marked": len(expired),
}
@router.get("/connect/{session_id}")
async def get_terminal_connect_page(
session_id: str,
token: str,
db_session: AsyncSession = Depends(get_db),
):
"""
Serve the terminal connection page.
This page embeds the ttyd terminal in an iframe or directly connects.
Token is verified before serving the page.
"""
session_repo = TerminalSessionRepository(db_session)
session = await session_repo.get_active_by_id(session_id)
if not session:
return HTMLResponse(
content="""
<!DOCTYPE html>
<html>
<head>
<title>Session Expirée</title>
<style>
body { font-family: system-ui; background: #1a1a2e; color: #fff;
display: flex; align-items: center; justify-content: center;
height: 100vh; margin: 0; }
.error { text-align: center; padding: 2rem; }
.error h1 { color: #ef4444; }
.error a { color: #60a5fa; }
</style>
</head>
<body>
<div class="error">
<h1>Session Expirée ou Invalide</h1>
<p>Cette session terminal n'existe pas ou a expiré.</p>
<p><a href="/">Retour au Dashboard</a></p>
</div>
</body>
</html>
""",
status_code=404
)
# Verify token
if not terminal_service.verify_token(token, session.token_hash):
return HTMLResponse(
content="""
<!DOCTYPE html>
<html>
<head>
<title>Accès Refusé</title>
<style>
body { font-family: system-ui; background: #1a1a2e; color: #fff;
display: flex; align-items: center; justify-content: center;
height: 100vh; margin: 0; }
.error { text-align: center; padding: 2rem; }
.error h1 { color: #ef4444; }
</style>
</head>
<body>
<div class="error">
<h1>Accès Refusé</h1>
<p>Token de session invalide.</p>
</div>
</body>
</html>
""",
status_code=403
)
# Calculate remaining time
now = datetime.now(timezone.utc)
expires_at = _as_utc_aware(session.expires_at)
remaining_seconds = max(0, int(((expires_at or now) - now).total_seconds()))
# Generate the terminal page
html_content = f"""
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Terminal - {session.host_name}</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
font-family: 'Segoe UI', system-ui, sans-serif;
background: #0f0f1a;
color: #e5e7eb;
height: 100vh;
display: flex;
flex-direction: column;
}}
.terminal-header {{
background: #1a1a2e;
padding: 0.5rem 1rem;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #374151;
flex-shrink: 0;
}}
.terminal-header .host-info {{
display: flex;
align-items: center;
gap: 0.75rem;
}}
.terminal-header .host-name {{
font-weight: 600;
color: #fff;
}}
.terminal-header .host-ip {{
color: #9ca3af;
font-size: 0.875rem;
}}
.terminal-header .status-badge {{
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}}
.terminal-header .status-badge.online {{
background: rgba(34, 197, 94, 0.2);
color: #4ade80;
}}
.terminal-header .session-timer {{
display: flex;
align-items: center;
gap: 0.5rem;
color: #9ca3af;
font-size: 0.875rem;
}}
.terminal-header .session-timer.warning {{
color: #fbbf24;
}}
.terminal-header .session-timer.critical {{
color: #ef4444;
}}
.terminal-header .actions {{
display: flex;
gap: 0.5rem;
}}
.terminal-header .btn {{
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.875rem;
border: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.375rem;
transition: all 0.15s;
}}
.terminal-header .btn-secondary {{
background: #374151;
color: #e5e7eb;
}}
.terminal-header .btn-secondary:hover {{
background: #4b5563;
}}
.terminal-header .btn-danger {{
background: #dc2626;
color: #fff;
}}
.terminal-header .btn-danger:hover {{
background: #b91c1c;
}}
.terminal-container {{
flex: 1;
position: relative;
overflow: hidden;
}}
.terminal-container iframe {{
width: 100%;
height: 100%;
border: none;
background: #000;
}}
.terminal-loading {{
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: #0f0f1a;
}}
.terminal-loading .spinner {{
width: 48px;
height: 48px;
border: 3px solid #374151;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}}
@keyframes spin {{
to {{ transform: rotate(360deg); }}
}}
.terminal-loading .loading-text {{
margin-top: 1rem;
color: #9ca3af;
}}
.pwa-hint {{
background: #1e3a5f;
color: #93c5fd;
padding: 0.5rem 1rem;
font-size: 0.75rem;
display: flex;
align-items: center;
justify-content: space-between;
}}
.pwa-hint button {{
background: none;
border: none;
color: #60a5fa;
cursor: pointer;
font-size: 0.75rem;
}}
.hidden {{ display: none !important; }}
</style>
</head>
<body>
<div class="pwa-hint" id="pwaHint">
<span>💡 Pour une expérience sans barre d'outils : installez en PWA ou utilisez <code>chrome --app=URL</code></span>
<button onclick="dismissPwaHint()">✕ Fermer</button>
</div>
<div class="terminal-header">
<div class="host-info">
<span class="host-name">{session.host_name}</span>
<span class="host-ip">{session.host_ip}</span>
<span class="status-badge online">Connecté</span>
</div>
<div class="session-timer" id="sessionTimer">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71V3.5z"/>
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/>
</svg>
<span id="timerDisplay">{remaining_seconds // 60}:{remaining_seconds % 60:02d}</span>
</div>
<div class="actions">
<button class="btn btn-secondary" onclick="copySSHCommand()" title="Copier la commande SSH">
<svg width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3z"/>
</svg>
SSH
</button>
<button class="btn btn-secondary" onclick="reconnect()" title="Reconnecter">
<svg width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
</svg>
Reconnecter
</button>
<button class="btn btn-secondary" onclick="goToDashboard()" title="Retour au Dashboard">
<svg width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8z"/>
</svg>
Dashboard
</button>
<button class="btn btn-danger" onclick="closeSession()" title="Fermer la session">
<svg width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8 2.146 2.854Z"/>
</svg>
Fermer
</button>
</div>
</div>
<div class="terminal-container">
<div class="terminal-loading" id="terminalLoading">
<div style="text-align: center;">
<div class="spinner"></div>
<div class="loading-text">Connexion au terminal...</div>
</div>
</div>
<iframe
id="terminalFrame"
src="http://localhost:{session.ttyd_port}/"
onload="onTerminalLoad()"
allow="clipboard-read; clipboard-write"
></iframe>
</div>
<script>
const SESSION_ID = '{session_id}';
const TOKEN = '{token}';
const HOST_NAME = '{session.host_name}';
const HOST_IP = '{session.host_ip}';
let remainingSeconds = {remaining_seconds};
// Timer countdown
function updateTimer() {{
remainingSeconds--;
if (remainingSeconds <= 0) {{
document.getElementById('timerDisplay').textContent = 'Expiré';
document.getElementById('sessionTimer').classList.add('critical');
return;
}}
const minutes = Math.floor(remainingSeconds / 60);
const seconds = remainingSeconds % 60;
document.getElementById('timerDisplay').textContent =
minutes + ':' + seconds.toString().padStart(2, '0');
const timer = document.getElementById('sessionTimer');
if (remainingSeconds < 60) {{
timer.classList.add('critical');
timer.classList.remove('warning');
}} else if (remainingSeconds < 300) {{
timer.classList.add('warning');
}}
}}
setInterval(updateTimer, 1000);
function onTerminalLoad() {{
document.getElementById('terminalLoading').classList.add('hidden');
// Focus the iframe
document.getElementById('terminalFrame').focus();
}}
function copySSHCommand() {{
const cmd = 'ssh automation@' + HOST_IP;
navigator.clipboard.writeText(cmd).then(() => {{
alert('Commande copiée: ' + cmd);
}});
}}
function reconnect() {{
document.getElementById('terminalLoading').classList.remove('hidden');
document.getElementById('terminalFrame').src =
document.getElementById('terminalFrame').src;
}}
function goToDashboard() {{
if (window.opener) {{
window.close();
}} else {{
window.location.href = '/';
}}
}}
function closeSession() {{
if (confirm('Fermer cette session terminal?')) {{
fetch('/api/terminal/sessions/' + SESSION_ID, {{
method: 'DELETE',
headers: {{
'Authorization': 'Bearer ' + localStorage.getItem('token')
}}
}}).then(() => {{
goToDashboard();
}}).catch(() => {{
goToDashboard();
}});
}}
}}
function dismissPwaHint() {{
document.getElementById('pwaHint').classList.add('hidden');
localStorage.setItem('pwaHintDismissed', 'true');
}}
// Check if PWA hint should be hidden
if (localStorage.getItem('pwaHintDismissed') === 'true' ||
window.matchMedia('(display-mode: standalone)').matches) {{
document.getElementById('pwaHint').classList.add('hidden');
}}
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {{
if (e.key === 'Escape') {{
goToDashboard();
}}
}});
</script>
</body>
</html>
"""
return HTMLResponse(content=html_content)
@router.get("/popout/{session_id}")
async def get_terminal_popout_page(
session_id: str,
token: str,
db_session: AsyncSession = Depends(get_db),
):
"""
Serve the terminal popout page (fullscreen, minimal UI).
This is designed to be opened in a popup window or PWA.
"""
# Reuse the same connect page logic but with minimal header
return await get_terminal_connect_page(session_id, token, db_session)
+52
View File
@@ -0,0 +1,52 @@
"""
Pydantic schemas for terminal session API.
"""
from datetime import datetime
from typing import Optional, Literal
from pydantic import BaseModel, Field
class TerminalSessionRequest(BaseModel):
"""Request to create a new terminal session."""
mode: Literal["embedded", "popout"] = Field(
default="embedded",
description="Terminal display mode: embedded in drawer or popout window"
)
class TerminalSessionHost(BaseModel):
"""Host information included in session response."""
id: str
name: str
ip: str
status: str
bootstrap_ok: bool
class TerminalSessionResponse(BaseModel):
"""Response after creating a terminal session."""
session_id: str
url: str
websocket_url: str
expires_at: datetime
ttl_seconds: int
mode: str
host: TerminalSessionHost
class TerminalSessionStatus(BaseModel):
"""Current status of a terminal session."""
session_id: str
status: str
host_name: str
created_at: datetime
expires_at: datetime
remaining_seconds: int
class TerminalSessionList(BaseModel):
"""List of active terminal sessions."""
sessions: list[TerminalSessionStatus]
total: int
max_per_user: int
+368
View File
@@ -0,0 +1,368 @@
"""
Terminal Service - Manages SSH terminal sessions via ttyd.
This service handles:
- Creating terminal sessions with unique tokens
- Spawning ttyd processes for SSH connections
- Managing session lifecycle (creation, expiration, cleanup)
- Port allocation for ttyd instances
"""
import asyncio
import hashlib
import logging
import os
import secrets
import shutil
import signal
import socket
import subprocess
from datetime import datetime, timedelta, timezone
from typing import Dict, Optional, Tuple
from app.core.config import settings
logger = logging.getLogger(__name__)
# Configuration
TERMINAL_SESSION_TTL_MINUTES = int(os.environ.get("TERMINAL_SESSION_TTL_MINUTES", "30"))
TERMINAL_MAX_SESSIONS_PER_USER = int(os.environ.get("TERMINAL_MAX_SESSIONS_PER_USER", "3"))
TERMINAL_PORT_RANGE_START = int(os.environ.get("TERMINAL_PORT_RANGE_START", "7680"))
TERMINAL_PORT_RANGE_END = int(os.environ.get("TERMINAL_PORT_RANGE_END", "7700"))
SSH_USER = os.environ.get("TERMINAL_SSH_USER", "automation")
TTYD_PATH = os.environ.get("TTYD_PATH", "ttyd")
class TerminalServiceError(Exception):
"""Base exception for terminal service errors."""
pass
class HostNotReadyError(TerminalServiceError):
"""Host is not ready for terminal connection."""
pass
class SessionLimitExceededError(TerminalServiceError):
"""User has too many active sessions."""
pass
class TtydNotAvailableError(TerminalServiceError):
"""ttyd binary is not available."""
pass
class TerminalService:
"""
Manages terminal sessions and ttyd processes.
This service is responsible for:
- Generating secure session tokens
- Allocating ports for ttyd instances
- Spawning and managing ttyd processes
- Cleaning up expired sessions
"""
def __init__(self):
# Track active ttyd processes: session_id -> subprocess.Popen
self._processes: Dict[str, subprocess.Popen] = {}
# Track allocated ports: port -> session_id
self._allocated_ports: Dict[int, str] = {}
# Lock for thread-safe operations
self._lock = asyncio.Lock()
# Check if ttyd is available
self._ttyd_available: Optional[bool] = None
def check_ttyd_available(self) -> bool:
"""Check if ttyd binary is available."""
if self._ttyd_available is not None:
return self._ttyd_available
self._ttyd_available = shutil.which(TTYD_PATH) is not None
if not self._ttyd_available:
logger.warning(f"ttyd not found at '{TTYD_PATH}'. Terminal feature will be unavailable.")
else:
logger.info(f"ttyd found at: {shutil.which(TTYD_PATH)}")
return self._ttyd_available
def generate_session_id(self) -> str:
"""Generate a unique session ID."""
return secrets.token_hex(32)
def generate_session_token(self) -> Tuple[str, str]:
"""
Generate a session token and its hash.
Returns:
Tuple of (plain_token, token_hash)
"""
token = secrets.token_urlsafe(48)
token_hash = hashlib.sha256(token.encode()).hexdigest()
return token, token_hash
def verify_token(self, token: str, token_hash: str) -> bool:
"""Verify a token against its hash."""
computed_hash = hashlib.sha256(token.encode()).hexdigest()
return secrets.compare_digest(computed_hash, token_hash)
def _is_port_available(self, port: int) -> bool:
"""Check if a TCP port is available for binding."""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0)
sock.bind(("0.0.0.0", port))
return True
except OSError:
return False
finally:
try:
sock.close()
except Exception:
pass
async def allocate_port(self, session_id: str) -> int:
"""
Allocate an available port for a ttyd instance.
Returns:
Available port number
Raises:
TerminalServiceError if no ports available
"""
async with self._lock:
for port in range(TERMINAL_PORT_RANGE_START, TERMINAL_PORT_RANGE_END + 1):
if port in self._allocated_ports:
continue
if not self._is_port_available(port):
continue
if port not in self._allocated_ports:
self._allocated_ports[port] = session_id
logger.debug(f"Allocated port {port} for session {session_id[:8]}...")
return port
raise TerminalServiceError("No available ports for terminal session")
async def release_port(self, port: int) -> None:
"""Release an allocated port."""
async with self._lock:
if port in self._allocated_ports:
session_id = self._allocated_ports.pop(port)
logger.debug(f"Released port {port} from session {session_id[:8]}...")
async def spawn_ttyd(
self,
session_id: str,
host_ip: str,
port: int,
token: str,
) -> Optional[int]:
"""
Spawn a ttyd process for SSH connection.
Args:
session_id: Unique session identifier
host_ip: Target host IP address
port: Port to run ttyd on
token: Session token for authentication
Returns:
Process ID of the spawned ttyd, or None if failed
"""
if not self.check_ttyd_available():
raise TtydNotAvailableError("ttyd is not installed or not in PATH")
key_candidates = []
env_key = os.environ.get("TERMINAL_SSH_KEY_PATH")
if env_key:
key_candidates.append(env_key)
if getattr(settings, "ssh_key_path", None):
key_candidates.append(settings.ssh_key_path)
key_candidates.append(str(os.path.expanduser("~/.ssh/id_automation_ansible")))
key_candidates.append(str(os.path.expanduser("~/.ssh/id_rsa")))
ssh_key_path = next((p for p in key_candidates if p and os.path.exists(p)), None)
if not ssh_key_path:
raise TerminalServiceError(
"No SSH key found for terminal sessions. Set SSH_KEY_PATH or TERMINAL_SSH_KEY_PATH (expected ~/.ssh/id_automation_ansible)."
)
# Build ttyd command
# --once: Exit after single client disconnects
# --credential: Basic auth with session token
# --port: Listen port
# -W: Write-only mode disabled (allow input)
# The command executed is SSH to the target host
ssh_cmd = [
"ssh",
"-i",
ssh_key_path,
"-o",
"BatchMode=no",
"-o",
"PreferredAuthentications=publickey,keyboard-interactive,password",
"-o",
"ConnectTimeout=10",
"-o",
"ServerAliveInterval=30",
"-o",
"ServerAliveCountMax=2",
"-o",
"StrictHostKeyChecking=accept-new",
"-o",
"UserKnownHostsFile=/dev/null",
f"{SSH_USER}@{host_ip}",
]
# Note: --credential removed to avoid HTTP basic auth popup
# Security is handled by session token validation in the connect/popout routes
cmd = [
TTYD_PATH,
"--once",
f"--port={port}",
"--writable",
*ssh_cmd,
]
logger.info(
f"Spawning ttyd for session {session_id[:8]}... on port {port} -> {SSH_USER}@{host_ip} (key={ssh_key_path})"
)
try:
# Spawn the process
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True, # Detach from parent process group
)
# Store the process
async with self._lock:
self._processes[session_id] = process
# Wait briefly to check if process started successfully
await asyncio.sleep(0.5)
if process.poll() is not None:
# Process exited immediately - likely an error
stdout, stderr = process.communicate(timeout=1)
error_msg = stderr.decode() if stderr else stdout.decode() if stdout else "Unknown error"
logger.error(f"ttyd failed to start: {error_msg}")
await self.release_port(port)
raise TerminalServiceError(f"ttyd failed to start: {error_msg.strip()}")
logger.info(f"ttyd started with PID {process.pid} for session {session_id[:8]}...")
return process.pid
except TerminalServiceError:
# Preserve detailed error message for API layer
raise
except Exception as e:
logger.exception(f"Failed to spawn ttyd: {e}")
await self.release_port(port)
raise TerminalServiceError(str(e))
async def terminate_session(self, session_id: str) -> bool:
"""
Terminate a terminal session and its ttyd process.
Returns:
True if session was terminated, False if not found
"""
async with self._lock:
process = self._processes.pop(session_id, None)
if process is None:
return False
try:
# Try graceful termination first
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
# Force kill if graceful termination failed
process.kill()
process.wait(timeout=2)
logger.info(f"Terminated ttyd process for session {session_id[:8]}...")
return True
except Exception as e:
logger.exception(f"Error terminating ttyd process: {e}")
return False
async def cleanup_expired_sessions(
self,
get_expired_sessions_func,
mark_expired_func,
db_session
) -> int:
"""
Clean up expired terminal sessions.
Args:
get_expired_sessions_func: Async function to get expired sessions from DB
mark_expired_func: Async function to mark session as expired in DB
db_session: Database session
Returns:
Number of sessions cleaned up
"""
from app.crud.terminal_session import TerminalSessionRepository
repo = TerminalSessionRepository(db_session)
expired = await repo.list_expired()
count = 0
for session in expired:
# Terminate the ttyd process
await self.terminate_session(session.id)
# Release the port
await self.release_port(session.ttyd_port)
# Mark as expired in DB
await repo.mark_expired(session.id)
count += 1
logger.info(f"Cleaned up expired session {session.id[:8]}... for host {session.host_name}")
return count
def get_active_session_count(self) -> int:
"""Get the number of active ttyd processes."""
return len(self._processes)
def get_session_url(self, port: int, token: str, base_url: str = "") -> str:
"""
Get the URL to access a terminal session.
Args:
port: ttyd port
token: Session token
base_url: Optional base URL prefix
Returns:
Full URL to access the terminal
"""
# If we have a reverse proxy, use the proxied URL
# Otherwise, direct access to ttyd port
if base_url:
return f"{base_url}/terminal/proxy/{port}?token={token}"
return f"http://localhost:{port}/"
def get_websocket_url(self, port: int) -> str:
"""Get the WebSocket URL for terminal connection."""
return f"ws://localhost:{port}/ws"
# Global service instance
terminal_service = TerminalService()
def get_terminal_service() -> TerminalService:
"""Get the global terminal service instance."""
return terminal_service