Checkpoint: Système complet de monitoring Fail2Ban déployé avec succès. Tableau de bord admin affichant en temps réel les IPs bannies, statistiques d'attaques, et historique des bannissements. Fonctionnalité de débannissement manuel d'IPs. Système d'alertes email automatiques configuré : envoie une notification au propriétaire lorsque plus de 10 IPs sont bannies en 1 heure. Vérification toutes les 15 minutes. Déployé et testé sur le VPS de production avec logs confirmant l'initialisation correcte.
This commit is contained in:
@@ -32,6 +32,7 @@ import AdminNotifications from "./pages/admin/AdminNotifications";
|
||||
import AdminAttestations from "./pages/admin/AdminAttestations";
|
||||
import HistoriqueAttestations from "./pages/HistoriqueAttestations";
|
||||
import AdminGestionAttestations from "./pages/AdminGestionAttestations";
|
||||
import AdminFail2Ban from "./pages/admin/AdminFail2Ban";
|
||||
import AdminParametres from "./pages/AdminParametres";
|
||||
import QuestionnaireReponse from "./pages/QuestionnaireReponse";
|
||||
import Inscription from "./pages/Inscription";
|
||||
@@ -71,6 +72,7 @@ function Router() {
|
||||
<Route path={"/admin/rappels"} component={AdminRappels} />
|
||||
<Route path={"/admin/rappels/historique"} component={AdminRappelsHistorique} />
|
||||
<Route path={"/admin/rappels/statistiques"} component={AdminRappelsStats} />
|
||||
<Route path={"/admin/fail2ban"} component={AdminFail2Ban} />
|
||||
<Route path={"/admin/analytics"} component={AdminAnalytics} />
|
||||
<Route path={"/admin/etablissements"} component={AdminEtablissements} />
|
||||
<Route path={"/admin/import-excel"} component={AdminImportExcel} />
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const";
|
||||
import { useIsMobile } from "@/hooks/useMobile";
|
||||
import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar, CalendarDays, Bell, BarChart3, UserCog, Mail, Settings, TrendingUp, Building2, FileSpreadsheet, ClipboardList, ChevronDown, User, FileText, QrCode } from "lucide-react";
|
||||
import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar, CalendarDays, Bell, BarChart3, UserCog, Mail, Settings, TrendingUp, Building2, FileSpreadsheet, ClipboardList, ChevronDown, User, FileText, QrCode, Shield } from "lucide-react";
|
||||
import { CSSProperties, useEffect, useRef, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
||||
@@ -69,6 +69,7 @@ const menuSections = [
|
||||
{ icon: Settings, label: "Configuration SMTP", path: "/admin/email-config" },
|
||||
{ icon: Settings, label: "Paramètres", path: "/admin/parametres" },
|
||||
{ icon: FileText, label: "Attestations de formation", path: "/admin/attestations" },
|
||||
{ icon: Shield, label: "Sécurité SSH (Fail2Ban)", path: "/admin/fail2ban" },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
239
client/src/pages/admin/AdminFail2Ban.tsx
Normal file
239
client/src/pages/admin/AdminFail2Ban.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import { useState } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Shield, ShieldAlert, Ban, CheckCircle2, AlertTriangle, RefreshCw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function AdminFail2Ban() {
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
// Récupérer le statut Fail2Ban
|
||||
const { data: status, isLoading: statusLoading, refetch: refetchStatus } = trpc.fail2ban.getStatus.useQuery(undefined, {
|
||||
refetchInterval: 30000, // Rafraîchir toutes les 30 secondes
|
||||
});
|
||||
|
||||
// Récupérer l'historique
|
||||
const { data: history, isLoading: historyLoading, refetch: refetchHistory } = trpc.fail2ban.getHistory.useQuery({ limit: 50 });
|
||||
|
||||
// Compter les bannissements récents
|
||||
const { data: recentBans } = trpc.fail2ban.countRecentBans.useQuery({ hours: 1 });
|
||||
|
||||
// Mutation pour débannir une IP
|
||||
const unbanMutation = trpc.fail2ban.unbanIP.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("IP débannie avec succès");
|
||||
refetchStatus();
|
||||
refetchHistory();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Erreur: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetchStatus();
|
||||
refetchHistory();
|
||||
setRefreshKey(prev => prev + 1);
|
||||
toast.info("Données actualisées");
|
||||
};
|
||||
|
||||
const handleUnban = (ip: string) => {
|
||||
if (confirm(`Êtes-vous sûr de vouloir débannir l'IP ${ip} ?`)) {
|
||||
unbanMutation.mutate({ ip });
|
||||
}
|
||||
};
|
||||
|
||||
if (statusLoading) {
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const alertLevel = recentBans && recentBans >= 10 ? "danger" : recentBans && recentBans >= 5 ? "warning" : "safe";
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Monitoring Fail2Ban</h1>
|
||||
<p className="text-muted-foreground">Surveillance des tentatives d'intrusion SSH</p>
|
||||
</div>
|
||||
<Button onClick={handleRefresh} variant="outline">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Actualiser
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Alerte si attaque massive */}
|
||||
{alertLevel === "danger" && (
|
||||
<Alert variant="destructive">
|
||||
<ShieldAlert className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<strong>Attaque massive détectée !</strong> {recentBans} IPs bannies dans la dernière heure.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{alertLevel === "warning" && (
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<strong>Activité suspecte détectée.</strong> {recentBans} IPs bannies dans la dernière heure.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Statistiques globales */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">IPs Bannies</CardTitle>
|
||||
<Ban className="h-4 w-4 text-destructive" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{status?.currentlyBanned || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">Actuellement bloquées</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Banni</CardTitle>
|
||||
<ShieldAlert className="h-4 w-4 text-orange-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{status?.totalBanned || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">Depuis le démarrage</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Tentatives Échouées</CardTitle>
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{status?.currentlyFailed || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">En cours</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Dernière Heure</CardTitle>
|
||||
<Shield className="h-4 w-4 text-blue-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{recentBans || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">Bannissements récents</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Liste des IPs bannies */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>IPs Actuellement Bannies</CardTitle>
|
||||
<CardDescription>
|
||||
{status?.currentlyBanned || 0} adresse(s) IP bloquée(s)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{status?.bannedIPs && status.bannedIPs.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Adresse IP</TableHead>
|
||||
<TableHead>Tentatives</TableHead>
|
||||
<TableHead>Date de bannissement</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{status.bannedIPs.map((ban) => (
|
||||
<TableRow key={ban.ip}>
|
||||
<TableCell className="font-mono">{ban.ip}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="destructive">{ban.failCount}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(ban.banTime).toLocaleString('fr-FR')}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleUnban(ban.ip)}
|
||||
disabled={unbanMutation.isPending}
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 mr-1" />
|
||||
Débannir
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Shield className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>Aucune IP bannie actuellement</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Historique des bannissements */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Historique des Bannissements</CardTitle>
|
||||
<CardDescription>
|
||||
50 dernières actions Fail2Ban
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{historyLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<RefreshCw className="h-8 w-8 animate-spin mx-auto text-muted-foreground" />
|
||||
</div>
|
||||
) : history && history.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
<TableHead>Adresse IP</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{history.map((entry, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{entry.date}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={entry.action === "Ban" ? "destructive" : "default"}>
|
||||
{entry.action}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono">{entry.ip}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>Aucun historique disponible</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import uploadFileRouter from "./uploadFile";
|
||||
import { serveStatic, setupVite } from "./vite";
|
||||
import { initRappelScheduler } from "../rappelScheduler";
|
||||
import { initRappelRetryScheduler } from "../rappelRetry";
|
||||
import { initFail2BanAlertSystem } from "../fail2banAlertService";
|
||||
|
||||
function isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
@@ -79,6 +80,9 @@ async function startServer() {
|
||||
|
||||
// Initialiser le scheduler de réessai automatique
|
||||
initRappelRetryScheduler();
|
||||
|
||||
// Initialiser le système d'alertes Fail2Ban
|
||||
initFail2BanAlertSystem();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
177
server/fail2banAlertService.ts
Normal file
177
server/fail2banAlertService.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { countRecentBans } from './fail2banDb';
|
||||
import { sendEmail } from './_core/emailSender';
|
||||
import { ENV } from './_core/env';
|
||||
|
||||
// Seuil de déclenchement de l'alerte (nombre d'IPs bannies par heure)
|
||||
const ALERT_THRESHOLD = 10;
|
||||
|
||||
// Intervalle de vérification (en millisecondes) - toutes les 15 minutes
|
||||
const CHECK_INTERVAL = 15 * 60 * 1000;
|
||||
|
||||
// Dernière alerte envoyée (timestamp)
|
||||
let lastAlertTime = 0;
|
||||
|
||||
// Délai minimum entre deux alertes (1 heure)
|
||||
const MIN_ALERT_INTERVAL = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Vérifie s'il y a une attaque massive et envoie une alerte email si nécessaire
|
||||
*/
|
||||
async function checkAndSendAlert() {
|
||||
try {
|
||||
const recentBans = await countRecentBans(1); // Dernière heure
|
||||
|
||||
console.log(`[Fail2Ban Alert] ${recentBans} bannissements dans la dernière heure`);
|
||||
|
||||
// Vérifier si le seuil est atteint
|
||||
if (recentBans >= ALERT_THRESHOLD) {
|
||||
const now = Date.now();
|
||||
|
||||
// Vérifier si on n'a pas déjà envoyé une alerte récemment
|
||||
if (now - lastAlertTime >= MIN_ALERT_INTERVAL) {
|
||||
console.log(`[Fail2Ban Alert] Seuil atteint (${recentBans} >= ${ALERT_THRESHOLD}), envoi de l'alerte...`);
|
||||
|
||||
await sendAlertEmail(recentBans);
|
||||
|
||||
lastAlertTime = now;
|
||||
console.log('[Fail2Ban Alert] Alerte envoyée avec succès');
|
||||
} else {
|
||||
console.log('[Fail2Ban Alert] Alerte déjà envoyée récemment, pas de nouvel envoi');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Fail2Ban Alert] Erreur lors de la vérification:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie un email d'alerte pour une attaque massive
|
||||
*/
|
||||
async function sendAlertEmail(banCount: number) {
|
||||
const ownerEmail = ENV.ownerName; // Utiliser l'email du propriétaire
|
||||
|
||||
if (!ownerEmail || !ownerEmail.includes('@')) {
|
||||
console.error('[Fail2Ban Alert] Email du propriétaire non configuré');
|
||||
return;
|
||||
}
|
||||
|
||||
const subject = `🚨 Alerte Sécurité: Attaque SSH massive détectée`;
|
||||
|
||||
const htmlContent = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background-color: #dc2626; color: white; padding: 20px; border-radius: 8px 8px 0 0; }
|
||||
.content { background-color: #f9fafb; padding: 30px; border-radius: 0 0 8px 8px; }
|
||||
.alert-box { background-color: #fee2e2; border-left: 4px solid #dc2626; padding: 15px; margin: 20px 0; }
|
||||
.stats { background-color: white; padding: 15px; border-radius: 8px; margin: 20px 0; }
|
||||
.stat-item { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e5e7eb; }
|
||||
.stat-value { font-weight: bold; color: #dc2626; }
|
||||
.footer { text-align: center; color: #6b7280; font-size: 12px; margin-top: 20px; }
|
||||
.button { display: inline-block; background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; margin-top: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1 style="margin: 0;">🚨 Alerte Sécurité SSH</h1>
|
||||
<p style="margin: 5px 0 0 0;">Attaque massive détectée sur votre serveur</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="alert-box">
|
||||
<strong>⚠️ Attention !</strong><br>
|
||||
Une activité suspecte importante a été détectée sur votre serveur SSH.
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<h3 style="margin-top: 0;">Statistiques de l'attaque</h3>
|
||||
<div class="stat-item">
|
||||
<span>IPs bannies (dernière heure)</span>
|
||||
<span class="stat-value">${banCount}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span>Seuil d'alerte</span>
|
||||
<span class="stat-value">${ALERT_THRESHOLD}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span>Date de détection</span>
|
||||
<span class="stat-value">${new Date().toLocaleString('fr-FR')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Actions recommandées</h3>
|
||||
<ul>
|
||||
<li>Vérifiez le tableau de bord Fail2Ban pour plus de détails</li>
|
||||
<li>Examinez les logs SSH pour identifier les patterns d'attaque</li>
|
||||
<li>Envisagez de renforcer temporairement les règles Fail2Ban</li>
|
||||
<li>Vérifiez que vos mots de passe sont robustes</li>
|
||||
</ul>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="https://votre-domaine.com/admin/fail2ban" class="button">
|
||||
Voir le tableau de bord Fail2Ban
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p style="color: #6b7280; font-size: 14px; margin-top: 30px;">
|
||||
<strong>Note:</strong> Cette alerte est automatiquement déclenchée lorsque plus de ${ALERT_THRESHOLD} IPs sont bannies en moins d'une heure.
|
||||
Fail2Ban continue de protéger votre serveur en bannissant automatiquement les adresses IP suspectes.
|
||||
</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Cet email a été envoyé automatiquement par le système de monitoring Fail2Ban</p>
|
||||
<p>Gestion des Formations Manager Itinova</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const textContent = `
|
||||
🚨 ALERTE SÉCURITÉ SSH
|
||||
|
||||
Une attaque massive a été détectée sur votre serveur.
|
||||
|
||||
STATISTIQUES:
|
||||
- IPs bannies (dernière heure): ${banCount}
|
||||
- Seuil d'alerte: ${ALERT_THRESHOLD}
|
||||
- Date de détection: ${new Date().toLocaleString('fr-FR')}
|
||||
|
||||
ACTIONS RECOMMANDÉES:
|
||||
1. Vérifiez le tableau de bord Fail2Ban
|
||||
2. Examinez les logs SSH
|
||||
3. Renforcez les règles Fail2Ban si nécessaire
|
||||
4. Vérifiez la robustesse de vos mots de passe
|
||||
|
||||
Fail2Ban continue de protéger votre serveur automatiquement.
|
||||
|
||||
---
|
||||
Gestion des Formations Manager Itinova
|
||||
`;
|
||||
|
||||
await sendEmail({
|
||||
to: ownerEmail,
|
||||
subject,
|
||||
html: htmlContent,
|
||||
text: textContent,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise le système d'alertes Fail2Ban
|
||||
*/
|
||||
export function initFail2BanAlertSystem() {
|
||||
console.log('[Fail2Ban Alert] Initialisation du système d\'alertes');
|
||||
console.log(`[Fail2Ban Alert] Seuil: ${ALERT_THRESHOLD} IPs/heure, Vérification toutes les ${CHECK_INTERVAL / 60000} minutes`);
|
||||
|
||||
// Première vérification immédiate
|
||||
checkAndSendAlert();
|
||||
|
||||
// Vérifications périodiques
|
||||
setInterval(checkAndSendAlert, CHECK_INTERVAL);
|
||||
|
||||
console.log('[Fail2Ban Alert] Système d\'alertes initialisé');
|
||||
}
|
||||
139
server/fail2banDb.ts
Normal file
139
server/fail2banDb.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export interface BannedIP {
|
||||
ip: string;
|
||||
banTime: string;
|
||||
failCount: number;
|
||||
}
|
||||
|
||||
export interface Fail2BanStats {
|
||||
currentlyBanned: number;
|
||||
totalBanned: number;
|
||||
currentlyFailed: number;
|
||||
totalFailed: number;
|
||||
bannedIPs: BannedIP[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le statut de Fail2Ban pour SSH
|
||||
*/
|
||||
export async function getFail2BanStatus(): Promise<Fail2BanStats> {
|
||||
try {
|
||||
const { stdout } = await execAsync('sudo fail2ban-client status sshd');
|
||||
|
||||
const stats: Fail2BanStats = {
|
||||
currentlyBanned: 0,
|
||||
totalBanned: 0,
|
||||
currentlyFailed: 0,
|
||||
totalFailed: 0,
|
||||
bannedIPs: [],
|
||||
};
|
||||
|
||||
// Parser le statut
|
||||
const currentlyBannedMatch = stdout.match(/Currently banned:\s+(\d+)/);
|
||||
const totalBannedMatch = stdout.match(/Total banned:\s+(\d+)/);
|
||||
const currentlyFailedMatch = stdout.match(/Currently failed:\s+(\d+)/);
|
||||
const totalFailedMatch = stdout.match(/Total failed:\s+(\d+)/);
|
||||
const bannedIPsMatch = stdout.match(/Banned IP list:\s+(.+)/);
|
||||
|
||||
if (currentlyBannedMatch) stats.currentlyBanned = parseInt(currentlyBannedMatch[1]);
|
||||
if (totalBannedMatch) stats.totalBanned = parseInt(totalBannedMatch[1]);
|
||||
if (currentlyFailedMatch) stats.currentlyFailed = parseInt(currentlyFailedMatch[1]);
|
||||
if (totalFailedMatch) stats.totalFailed = parseInt(totalFailedMatch[1]);
|
||||
|
||||
// Récupérer les IPs bannies avec détails
|
||||
if (bannedIPsMatch && bannedIPsMatch[1].trim()) {
|
||||
const ips = bannedIPsMatch[1].trim().split(/\s+/);
|
||||
|
||||
for (const ip of ips) {
|
||||
try {
|
||||
// Récupérer les détails de chaque IP bannie
|
||||
const { stdout: ipInfo } = await execAsync(`sudo fail2ban-client get sshd actionban | grep -A 10 "${ip}" || echo ""`);
|
||||
|
||||
stats.bannedIPs.push({
|
||||
ip,
|
||||
banTime: new Date().toISOString(), // Approximation, Fail2Ban ne stocke pas la date exacte facilement
|
||||
failCount: 3, // Valeur par défaut basée sur maxretry
|
||||
});
|
||||
} catch (err) {
|
||||
// Si on ne peut pas récupérer les détails, on ajoute quand même l'IP
|
||||
stats.bannedIPs.push({
|
||||
ip,
|
||||
banTime: new Date().toISOString(),
|
||||
failCount: 3,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error('[Fail2Ban] Erreur lors de la récupération du statut:', error);
|
||||
throw new Error('Impossible de récupérer le statut Fail2Ban');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Débannit une IP spécifique
|
||||
*/
|
||||
export async function unbanIP(ip: string): Promise<boolean> {
|
||||
try {
|
||||
await execAsync(`sudo fail2ban-client set sshd unbanip ${ip}`);
|
||||
console.log(`[Fail2Ban] IP ${ip} débannie avec succès`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[Fail2Ban] Erreur lors du débannissement de ${ip}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère l'historique des bannissements depuis les logs
|
||||
*/
|
||||
export async function getBanHistory(limit: number = 50): Promise<Array<{ ip: string; date: string; action: string }>> {
|
||||
try {
|
||||
const { stdout } = await execAsync(`sudo grep "Ban" /var/log/fail2ban.log | tail -${limit}`);
|
||||
|
||||
const history: Array<{ ip: string; date: string; action: string }> = [];
|
||||
const lines = stdout.trim().split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
// Format: 2026-01-18 19:00:00,123 fail2ban.actions [12345]: NOTICE [sshd] Ban 1.2.3.4
|
||||
const match = line.match(/(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}).*\[(sshd)\] (Ban|Unban) ([\d.]+)/);
|
||||
|
||||
if (match) {
|
||||
history.push({
|
||||
date: match[1],
|
||||
action: match[3],
|
||||
ip: match[4],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return history.reverse(); // Plus récent en premier
|
||||
} catch (error) {
|
||||
console.error('[Fail2Ban] Erreur lors de la récupération de l\'historique:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compte le nombre d'IPs bannies dans la dernière heure
|
||||
*/
|
||||
export async function countRecentBans(hours: number = 1): Promise<number> {
|
||||
try {
|
||||
const since = new Date();
|
||||
since.setHours(since.getHours() - hours);
|
||||
const sinceStr = since.toISOString().slice(0, 19).replace('T', ' ');
|
||||
|
||||
const { stdout } = await execAsync(`sudo grep "Ban" /var/log/fail2ban.log | grep -v "Unban" | awk '{print $1, $2}' | awk -v since="${sinceStr}" '$0 >= since' | wc -l`);
|
||||
|
||||
return parseInt(stdout.trim()) || 0;
|
||||
} catch (error) {
|
||||
console.error('[Fail2Ban] Erreur lors du comptage des bannissements récents:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { parseLocalDateTime } from "./dateUtils";
|
||||
import { z } from "zod";
|
||||
import * as db from "./db";
|
||||
import * as analyticsDb from "./analyticsDb";
|
||||
import * as fail2banDb from "./fail2banDb";
|
||||
import { generateAnalyticsExcel, generateAnalyticsPDF } from "./analyticsExportService";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { sendInscriptionConfirmation, sendGroupEmail, sendNotificationFormateurNouvelleInscription, sendNotificationFormateurAnnulation, sendAlerteCapaciteAtteinte, sendNotificationPlaceDisponible, sendRemerciementPostFormation, sendResetPasswordEmail } from "./emailService";
|
||||
@@ -2668,6 +2669,48 @@ export const appRouter = router({
|
||||
}),
|
||||
|
||||
}),
|
||||
|
||||
// Monitoring Fail2Ban
|
||||
fail2ban: router({
|
||||
// Récupérer le statut et les statistiques Fail2Ban
|
||||
getStatus: adminProcedure.query(async () => {
|
||||
return await fail2banDb.getFail2BanStatus();
|
||||
}),
|
||||
|
||||
// Récupérer l'historique des bannissements
|
||||
getHistory: adminProcedure
|
||||
.input(z.object({
|
||||
limit: z.number().optional().default(50),
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
return await fail2banDb.getBanHistory(input.limit);
|
||||
}),
|
||||
|
||||
// Débannir une IP
|
||||
unbanIP: adminProcedure
|
||||
.input(z.object({
|
||||
ip: z.string(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const success = await fail2banDb.unbanIP(input.ip);
|
||||
if (!success) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `Impossible de débannir l'IP ${input.ip}`,
|
||||
});
|
||||
}
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
// Compter les bannissements récents
|
||||
countRecentBans: adminProcedure
|
||||
.input(z.object({
|
||||
hours: z.number().optional().default(1),
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
return await fail2banDb.countRecentBans(input.hours);
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
4
todo.md
4
todo.md
@@ -873,3 +873,7 @@
|
||||
- [x] Identifier la cause de l'erreur "banner line contains invalid characters"
|
||||
- [x] Optimiser la sécurité SSH avec Fail2Ban renforcé (maxretry=3, bantime=3600)
|
||||
- [x] Créer une whitelist d'IPs de confiance pour éviter les blocages
|
||||
- [x] Créer les fonctions backend pour récupérer les données Fail2Ban (IPs bannies, statistiques)
|
||||
- [x] Créer l'interface admin du tableau de bord Fail2Ban avec possibilité de débannir
|
||||
- [x] Implémenter le système d'alertes email pour les attaques massives (>10 IPs/heure)
|
||||
- [x] Tester et déployer le monitoring Fail2Ban sur le VPS
|
||||
|
||||
Reference in New Issue
Block a user