This commit is contained in:
2025-12-06 12:28:55 +01:00
parent 56d633e296
commit 13b3c58ec8
56 changed files with 4328 additions and 1 deletions

36
.dockerignore Normal file
View File

@@ -0,0 +1,36 @@
# Node modules
frontend/node_modules
frontend/dist
# Python
backend/__pycache__
backend/**/__pycache__
backend/**/*.pyc
backend/**/*.pyo
backend/**/*.pyd
backend/.pytest_cache
backend/**/.pytest_cache
# Données et logs
data/
logs/
*.sqlite
*.db
# IDE
.vscode/
.idea/
*.swp
*.swo
# Git
.git/
.gitignore
# Documentation
*.md
!README.md
# Divers
.env
.DS_Store

37
.gitignore vendored Normal file
View File

@@ -0,0 +1,37 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
backend/**/__pycache__/
.pytest_cache/
# Node
frontend/node_modules/
frontend/dist/
frontend/.vite/
# Environnement
.env
.venv
env/
venv/
# Données
data/
logs/
*.sqlite
*.db
# IDE
.vscode/
.idea/
*.swp
*.swo
.DS_Store
# Build
build/
dist/
*.egg-info/

122
CLAUDE.md Normal file
View File

@@ -0,0 +1,122 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
IPWatch is a network scanner web application that visualizes IP addresses, their states (online/offline), open ports, and historical data. The project consists of:
- **Backend**: FastAPI + SQLAlchemy + APScheduler for network scanning
- **Frontend**: Vue 3 + Vite + Tailwind with Monokai dark theme
- **Deployment**: Docker containerization with volumes for config and database
## Key Specification Files
Speek in french and comment in french
The project has detailed specifications that MUST be followed when implementing features:
- [prompt-claude-code.md](prompt-claude-code.md) - Overall project objectives and deliverables
- [architecture-technique.md](architecture-technique.md) - Technical architecture (backend modules, frontend structure, Docker setup)
- [modele-donnees.md](modele-donnees.md) - SQLite database schema (ip and ip_history tables with required indexes)
- [workflow-scan.md](workflow-scan.md) - 10-step scan pipeline from YAML config to WebSocket push
- [consigne-parametrage.md](consigne-parametrage.md) - Complete YAML configuration structure with all sections (app, network, ip_classes, scan, ports, locations, hosts, history, ui, colors, network_advanced, filters, database)
- [consigne-design_webui.md](consigne-design_webui.md) - UI layout (3-column design), interaction patterns, visual states
- [guidelines-css.md](guidelines-css.md) - Monokai color palette, IP cell styling rules (solid border for online, dashed for offline, animated halo for ping)
- [tests-backend.md](tests-backend.md) - Required unit and integration tests
## Architecture Principles
### Backend Structure
- FastAPI application with separate modules for network operations (ping, ARP, port scanning)
- SQLAlchemy models matching the schema in [modele-donnees.md](modele-donnees.md)
- APScheduler for periodic network scans
- WebSocket endpoint for real-time push notifications
- REST APIs for: IP management, scan operations, configuration, historical data
### Frontend Structure
- Vue 3 with Composition API
- Pinia for global state management
- WebSocket client for real-time updates
- 3-column layout: left (IP details), center (IP grid + legend), right (new detections)
- Monokai dark theme with specific color codes from [guidelines-css.md](guidelines-css.md)
### Data Flow
1. YAML configuration loads network CIDR and scan parameters
2. Scheduled scan generates IP list, performs ping (parallel), ARP lookup, port scanning
3. Results classified and stored in SQLite
4. New/changed IPs trigger WebSocket push to frontend
5. UI updates grid with appropriate visual states
## Database Schema
### ip table (PRIMARY)
- `ip` (PK): IP address
- `name`, `known` (bool), `location`, `host`: metadata
- `first_seen`, `last_seen`: timestamps
- `last_status`: current online/offline state
- `mac`, `vendor`, `hostname`: network info
- `open_ports`: JSON array
### ip_history table
- `id` (PK)
- `ip` (FK to ip.ip)
- `timestamp`, `status`, `open_ports` (JSON)
- **Required index**: timestamp for efficient historical queries
### Important Indexes
- Index on `ip.last_status` for filtering
- Index on `ip_history.timestamp` for 24h history retrieval
## Visual Design Rules
### IP Cell States
- **Online + Known**: Green (#A6E22E) with solid border
- **Online + Unknown**: Cyan (#66D9EF) with solid border
- **Offline**: Dashed border + configurable transparency
- **Ping in progress**: Animated halo using CSS keyframes
- **Free IP**: Distinct color from occupied states
### Theme Colors (Monokai)
- Background: `#272822`
- Text: `#F8F8F2`
- Accents: `#A6E22E` (green), `#F92672` (pink), `#66D9EF` (cyan)
## Configuration System
The application is driven by a YAML configuration file ([consigne-parametrage.md](consigne-parametrage.md)) with these sections:
- `network`: CIDR, gateway, DNS
- `ip_classes`: Define known IPs with metadata
- `scan`: Intervals, parallelization settings
- `ports`: Port scan ranges
- `locations`, `hosts`: Categorical data
- `history`: Retention period
- `ui`: Display preferences, transparency
- `colors`: Custom color mapping
- `network_advanced`: ARP, timeout settings
- `filters`: Default filter states
- `database`: SQLite path
## Testing Requirements
When implementing backend features, ensure tests cover ([tests-backend.md](tests-backend.md)):
- Network module unit tests: `test_ping()`, `test_port_scan()`, `test_classification()`
- SQLAlchemy models: `test_sqlalchemy_models()`
- API endpoints: `test_api_get_ip()`, `test_api_update_ip()`
- Scheduler: `test_scheduler()`
- Integration: Full network scan simulation, WebSocket notification flow
## Docker Setup
The application should run as a single Docker service:
- Combined backend + frontend container
- Volume mount for `config.yaml`
- Volume mount for `db.sqlite`
- Exposed ports for web access and WebSocket
## Implementation Notes
- **Parallelization**: Ping operations must be parallelized for performance
- **Real-time updates**: WebSocket is critical for live UI updates during scans
- **MAC vendor lookup**: Use ARP data to populate vendor information
- **Port scanning**: Respect intervals defined in YAML to avoid network overload
- **Classification logic**: Follow the 10-step workflow in [workflow-scan.md](workflow-scan.md)
- **Responsive design**: Grid layout must be fluid with collapsible columns

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

55
Dockerfile Normal file
View File

@@ -0,0 +1,55 @@
# Dockerfile multi-stage pour IPWatch
# Backend FastAPI + Frontend Vue 3
# Stage 1: Build frontend Vue
FROM node:20-alpine AS frontend-build
WORKDIR /frontend
# Copier package.json et installer dépendances
COPY frontend/package*.json ./
RUN npm install
# Copier le code source et builder
COPY frontend/ ./
RUN npm run build
# Stage 2: Image finale avec backend + frontend statique
FROM python:3.11-slim
# Variables d'environnement
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
# Installer les outils réseau nécessaires
RUN apt-get update && apt-get install -y \
iputils-ping \
net-tools \
tcpdump \
&& rm -rf /var/lib/apt/lists/*
# Créer le répertoire de travail
WORKDIR /app
# Copier et installer les dépendances Python
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copier le code backend
COPY backend/ ./backend/
# Copier le frontend buildé depuis le stage 1
COPY --from=frontend-build /frontend/dist ./frontend/dist
# Créer les dossiers pour volumes
RUN mkdir -p /app/data
# Copier config.yaml par défaut (sera écrasé par le volume)
COPY config.yaml /app/config.yaml
# Exposer le port
EXPOSE 8080
# Commande de démarrage
CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8080"]

79
Makefile Normal file
View File

@@ -0,0 +1,79 @@
# Makefile pour IPWatch
.PHONY: help build up down logs restart clean test install-backend install-frontend dev
help: ## Afficher l'aide
@echo "IPWatch - Commandes disponibles:"
@echo ""
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
# Docker
build: ## Construire l'image Docker
docker-compose build
up: ## Démarrer les conteneurs
docker-compose up -d
@echo "IPWatch démarré sur http://localhost:8000"
down: ## Arrêter les conteneurs
docker-compose down
logs: ## Afficher les logs
docker-compose logs -f
restart: ## Redémarrer les conteneurs
docker-compose restart
clean: ## Nettoyer conteneurs, images et volumes
docker-compose down -v
rm -rf data/*.sqlite logs/*
# Développement
install-backend: ## Installer dépendances backend
cd backend && pip install -r requirements.txt
install-frontend: ## Installer dépendances frontend
cd frontend && npm install
dev-backend: ## Lancer le backend en dev
cd backend && python -m backend.app.main
dev-frontend: ## Lancer le frontend en dev
cd frontend && npm run dev
dev: ## Lancer backend + frontend en dev (tmux requis)
@echo "Lancement backend et frontend..."
@tmux new-session -d -s ipwatch 'cd backend && python -m backend.app.main'
@tmux split-window -h 'cd frontend && npm run dev'
@tmux attach-session -t ipwatch
# Tests
test: ## Exécuter les tests backend
cd backend && pytest -v
test-coverage: ## Tests avec couverture
cd backend && pytest --cov=app --cov-report=html
# Utilitaires
init: ## Initialiser le projet (install + build)
make install-backend
make install-frontend
make build
setup-config: ## Créer config.yaml depuis template (si absent)
@if [ ! -f config.yaml ]; then \
echo "Création de config.yaml..."; \
cp config.yaml.example config.yaml 2>/dev/null || echo "config.yaml déjà présent"; \
else \
echo "config.yaml existe déjà"; \
fi
db-backup: ## Sauvegarder la base de données
@mkdir -p backups
@cp data/db.sqlite backups/db_$$(date +%Y%m%d_%H%M%S).sqlite
@echo "Sauvegarde créée dans backups/"
db-reset: ## Réinitialiser la base de données
@echo "⚠️ Suppression de la base de données..."
rm -f data/db.sqlite
@echo "Base de données supprimée. Elle sera recréée au prochain démarrage."

270
README.md
View File

@@ -1,2 +1,270 @@
# LANMap
# IPWatch - Scanner Réseau Temps Réel
IPWatch est une application web de scan réseau qui visualise en temps réel l'état des adresses IP, leurs ports ouverts, et l'historique des détections sur votre réseau local.
## Fonctionnalités
- 🔍 **Scan réseau automatique** : Ping, ARP lookup, et scan de ports périodiques
- 📊 **Visualisation temps réel** : Interface web avec mise à jour WebSocket
- 🎨 **Thème Monokai** : Interface sombre avec codes couleurs intuitifs
- 📝 **Gestion des IP** : Nommage, classification (connue/inconnue), métadonnées
- 📈 **Historique 24h** : Suivi de l'évolution de l'état du réseau
- 🔔 **Détection automatique** : Notification des nouvelles IP sur le réseau
- 🐳 **Déploiement Docker** : Configuration simple avec docker-compose
## Technologies
### Backend
- **FastAPI** - API REST et WebSocket
- **SQLAlchemy** - ORM pour SQLite
- **APScheduler** - Tâches planifiées
- **Scapy** - Scan ARP et réseau
### Frontend
- **Vue 3** - Framework UI avec Composition API
- **Pinia** - State management
- **Tailwind CSS** - Styles avec palette Monokai
- **Vite** - Build tool
### Infrastructure
- **Docker** - Conteneurisation
- **SQLite** - Base de données
- **WebSocket** - Communication temps réel
## Installation
### Avec Docker (recommandé)
1. **Cloner le repository**
```bash
git clone <repo-url>
cd ipwatch
```
2. **Configurer le réseau**
Éditer `config.yaml` et ajuster le CIDR de votre réseau :
```yaml
network:
cidr: "192.168.1.0/24" # Adapter à votre réseau
```
3. **Lancer avec docker-compose**
```bash
docker-compose up -d
```
4. **Accéder à l'interface**
Ouvrir votre navigateur : `http://localhost:8080`
### Installation manuelle (développement)
#### Backend
```bash
cd backend
pip install -r requirements.txt
python -m backend.app.main
```
#### Frontend
```bash
cd frontend
npm install
npm run dev
```
L'API sera accessible sur `http://localhost:8080`
Le frontend sur `http://localhost:3000`
## Configuration
Le fichier `config.yaml` permet de configurer :
- **Réseau** : CIDR, gateway, DNS
- **IPs connues** : Liste des appareils avec noms et emplacements
- **Scan** : Intervalles ping/ports, parallélisation
- **Ports** : Ports à scanner
- **Historique** : Durée de rétention
- **Interface** : Transparence, couleurs
- **Base de données** : Chemin SQLite
Exemple :
```yaml
network:
cidr: "192.168.1.0/24"
scan:
ping_interval: 60 # Scan ping toutes les 60s
port_scan_interval: 300 # Scan ports toutes les 5min
parallel_pings: 50 # 50 pings simultanés max
ports:
ranges:
- "22" # SSH
- "80" # HTTP
- "443" # HTTPS
- "3389" # RDP
ip_classes:
"192.168.1.1":
name: "Box Internet"
location: "Entrée"
host: "Routeur"
```
## Interface utilisateur
L'interface est organisée en 3 colonnes :
### Colonne gauche - Détails IP
- Informations détaillées de l'IP sélectionnée
- Formulaire d'édition (nom, localisation, type d'hôte)
- Informations réseau (MAC, vendor, hostname, ports ouverts)
### Colonne centrale - Grille d'IP
- Vue d'ensemble de toutes les IP du réseau
- Codes couleurs selon l'état :
- 🟢 **Vert** : En ligne + connue
- 🔵 **Cyan** : En ligne + inconnue
- 🔴 **Rose** : Hors ligne + connue (bordure pointillée)
- 🟣 **Violet** : Hors ligne + inconnue (bordure pointillée)
-**Gris** : IP libre
- Filtres : En ligne, Hors ligne, Connues, Inconnues, Libres
- Légende interactive
### Colonne droite - Nouvelles détections
- Liste des IP récemment découvertes
- Tri par ordre chronologique
- Indicateur temps relatif
## API REST
### Endpoints IPs
- `GET /api/ips/` - Liste toutes les IPs (avec filtres optionnels)
- `GET /api/ips/{ip}` - Détails d'une IP
- `PUT /api/ips/{ip}` - Mettre à jour une IP
- `DELETE /api/ips/{ip}` - Supprimer une IP
- `GET /api/ips/{ip}/history` - Historique d'une IP
- `GET /api/ips/stats/summary` - Statistiques globales
### Endpoints Scan
- `POST /api/scan/start` - Lancer un scan immédiat
- `POST /api/scan/cleanup-history` - Nettoyer l'historique ancien
### WebSocket
- `WS /ws` - Connexion WebSocket pour notifications temps réel
Messages WebSocket :
- `scan_start` - Début de scan
- `scan_complete` - Fin de scan avec statistiques
- `ip_update` - Changement d'état d'une IP
- `new_ip` - Nouvelle IP détectée
## Tests
Exécuter les tests backend :
```bash
cd backend
pytest
```
Tests disponibles :
- `test_network.py` - Tests modules réseau (ping, ARP, port scan)
- `test_models.py` - Tests modèles SQLAlchemy
- `test_api.py` - Tests endpoints API
- `test_scheduler.py` - Tests scheduler APScheduler
## Architecture
```
ipwatch/
├── backend/
│ ├── app/
│ │ ├── core/ # Configuration, database
│ │ ├── models/ # Modèles SQLAlchemy
│ │ ├── routers/ # Endpoints API
│ │ ├── services/ # Services réseau, scheduler, WebSocket
│ │ └── main.py # Application FastAPI
│ └── requirements.txt
├── frontend/
│ ├── src/
│ │ ├── assets/ # CSS Monokai
│ │ ├── components/ # Composants Vue
│ │ ├── stores/ # Pinia stores
│ │ └── main.js
│ └── package.json
├── tests/ # Tests backend
├── config.yaml # Configuration
├── docker-compose.yml
└── Dockerfile
```
## Workflow de scan
Le scan réseau suit ce workflow (10 étapes) :
1. Charger configuration YAML
2. Générer liste IP du CIDR
3. Ping (parallélisé)
4. ARP + MAC vendor lookup
5. Port scan selon intervalle
6. Classification état (online/offline)
7. Mise à jour SQLite
8. Détection nouvelles IP
9. Push WebSocket vers clients
10. Mise à jour UI temps réel
## Sécurité
⚠️ **Attention** : IPWatch nécessite des privilèges réseau élevés (ping, ARP).
Le conteneur Docker utilise :
- `network_mode: host` - Accès au réseau local
- `privileged: true` - Privilèges pour scan réseau
- `cap_add: NET_ADMIN, NET_RAW` - Capacités réseau
**N'exposez pas cette application sur internet** - Usage réseau local uniquement.
## Volumes Docker
Trois volumes sont montés :
- `./config.yaml` - Configuration (lecture seule)
- `./data/` - Base de données SQLite
- `./logs/` - Logs applicatifs
## Dépannage
### Le scan ne détecte aucune IP
1. Vérifier le CIDR dans `config.yaml`
2. Vérifier que Docker a accès au réseau (`network_mode: host`)
3. Vérifier les logs : `docker logs ipwatch`
### WebSocket déconnecté
- Vérifier que le port 8080 est accessible
- Vérifier les logs du navigateur (F12 → Console)
- Le WebSocket se reconnecte automatiquement après 5s
### Erreur de permissions réseau
Le conteneur nécessite `privileged: true` pour :
- Envoi de paquets ICMP (ping)
- Scan ARP
- Capture de paquets réseau
## Licence
MIT
## Auteur
Développé avec Claude Code selon les spécifications IPWatch.

260
STRUCTURE.md Normal file
View File

@@ -0,0 +1,260 @@
# Structure du Projet IPWatch
## Vue d'ensemble
```
ipwatch/
├── backend/ # Backend FastAPI
│ ├── app/
│ │ ├── core/ # Configuration et database
│ │ │ ├── config.py # Gestionnaire config YAML
│ │ │ └── database.py # Setup SQLAlchemy
│ │ ├── models/ # Modèles SQLAlchemy
│ │ │ └── ip.py # Tables IP et IPHistory
│ │ ├── routers/ # Endpoints API REST
│ │ │ ├── ips.py # CRUD IPs + historique
│ │ │ ├── scan.py # Contrôle scans
│ │ │ └── websocket.py # Endpoint WebSocket
│ │ ├── services/ # Services métier
│ │ │ ├── network.py # Scanner réseau (ping, ARP, ports)
│ │ │ ├── scheduler.py # APScheduler pour tâches périodiques
│ │ │ └── websocket.py # Gestionnaire WebSocket
│ │ └── main.py # Application FastAPI principale
│ └── requirements.txt # Dépendances Python
├── frontend/ # Frontend Vue 3
│ ├── src/
│ │ ├── assets/
│ │ │ └── main.css # Styles Monokai + animations
│ │ ├── components/
│ │ │ ├── AppHeader.vue # Header avec stats et contrôles
│ │ │ ├── IPCell.vue # Cellule IP dans la grille
│ │ │ ├── IPDetails.vue # Détails IP (colonne gauche)
│ │ │ ├── IPGrid.vue # Grille d'IP (colonne centrale)
│ │ │ └── NewDetections.vue # Nouvelles IP (colonne droite)
│ │ ├── stores/
│ │ │ └── ipStore.js # Store Pinia + WebSocket client
│ │ ├── App.vue # Layout 3 colonnes
│ │ └── main.js # Point d'entrée
│ ├── package.json # Dépendances Node
│ ├── vite.config.js # Configuration Vite
│ ├── tailwind.config.js # Configuration Tailwind (Monokai)
│ └── index.html # HTML principal
├── tests/ # Tests backend
│ ├── test_network.py # Tests modules réseau
│ ├── test_models.py # Tests modèles SQLAlchemy
│ ├── test_api.py # Tests endpoints API
│ └── test_scheduler.py # Tests APScheduler
├── config.yaml # Configuration principale
├── docker-compose.yml # Orchestration Docker
├── Dockerfile # Image multi-stage
├── Makefile # Commandes utiles
├── start.sh # Script démarrage rapide
├── pytest.ini # Configuration pytest
├── .gitignore # Exclusions Git
├── .dockerignore # Exclusions Docker
├── README.md # Documentation
├── CLAUDE.md # Guide pour Claude Code
└── STRUCTURE.md # Ce fichier
```
## Flux de données
### 1. Scan réseau (backend)
```
APScheduler (scheduler.py)
↓ déclenche périodiquement
NetworkScanner (network.py)
↓ effectue scan complet
├─→ Ping parallélisé
├─→ ARP lookup + MAC vendor
└─→ Port scan
↓ résultats
SQLAlchemy (models/ip.py)
↓ enregistre dans
SQLite (data/db.sqlite)
↓ notifie via
WebSocket Manager (services/websocket.py)
↓ broadcast vers
Clients WebSocket (frontend)
```
### 2. Interface utilisateur (frontend)
```
App.vue (layout 3 colonnes)
├─→ IPDetails.vue (gauche)
├─→ IPGrid.vue (centre)
│ └─→ IPCell.vue (x254)
└─→ NewDetections.vue (droite)
↓ tous utilisent
Pinia Store (ipStore.js)
↓ communique avec
├─→ API REST (/api/ips/*)
└─→ WebSocket (/ws)
```
### 3. Workflow complet d'un scan
```
1. Scheduler déclenche scan
2. NetworkScanner génère liste IP (CIDR)
3. Ping parallélisé (50 simultanés)
4. ARP lookup pour MAC/vendor
5. Port scan (ports configurés)
6. Classification état (online/offline)
7. Mise à jour base de données
8. Détection nouvelles IP
9. Push WebSocket vers clients
10. Mise à jour UI temps réel
```
## Composants clés
### Backend
| Fichier | Responsabilité | Lignes |
|---------|---------------|--------|
| `services/network.py` | Scan réseau (ping, ARP, ports) | ~300 |
| `services/scheduler.py` | Tâches planifiées | ~100 |
| `services/websocket.py` | Gestionnaire WebSocket | ~150 |
| `routers/ips.py` | API CRUD IPs | ~200 |
| `routers/scan.py` | API contrôle scan | ~150 |
| `models/ip.py` | Modèles SQLAlchemy | ~100 |
| `core/config.py` | Gestion config YAML | ~150 |
| `main.py` | Application FastAPI | ~150 |
### Frontend
| Fichier | Responsabilité | Lignes |
|---------|---------------|--------|
| `stores/ipStore.js` | State management + WebSocket | ~250 |
| `components/IPGrid.vue` | Grille IP + filtres | ~100 |
| `components/IPDetails.vue` | Détails + édition IP | ~200 |
| `components/IPCell.vue` | Cellule IP individuelle | ~80 |
| `components/NewDetections.vue` | Liste nouvelles IP | ~120 |
| `assets/main.css` | Styles Monokai | ~150 |
## Points d'entrée
### Développement
**Backend** :
```bash
cd backend
python -m backend.app.main
# ou
make dev-backend
```
**Frontend** :
```bash
cd frontend
npm run dev
# ou
make dev-frontend
```
### Production (Docker)
```bash
docker-compose up -d
# ou
./start.sh
# ou
make up
```
## Configuration requise
### Backend
- Python 3.11+
- Privilèges réseau (ping, ARP)
- Accès au réseau local
### Frontend
- Node.js 20+
- npm
### Docker
- Docker 20+
- docker-compose 2+
## Ports utilisés
- **8080** : API backend + frontend buildé (production)
- **3000** : Frontend dev (développement)
## Volumes Docker
- `./config.yaml``/app/config.yaml` (ro)
- `./data/``/app/data/`
- `./logs/``/app/logs/`
## Base de données
**SQLite** : `data/db.sqlite`
Tables :
- `ip` : Table principale des IP (14 colonnes)
- `ip_history` : Historique des états (5 colonnes)
Index :
- `ip.last_status`
- `ip.known`
- `ip_history.timestamp`
- `ip_history.ip`
## Tests
Lancer les tests :
```bash
pytest
# ou
make test
```
Couverture :
```bash
pytest --cov=backend.app --cov-report=html
# ou
make test-coverage
```
## Commandes utiles
Voir toutes les commandes :
```bash
make help
```
Principales commandes :
- `make build` - Construire l'image
- `make up` - Démarrer
- `make down` - Arrêter
- `make logs` - Voir les logs
- `make test` - Tests
- `make clean` - Nettoyer
- `make db-backup` - Sauvegarder DB
- `make db-reset` - Réinitialiser DB
## Dépendances principales
### Backend (Python)
- fastapi 0.109.0
- uvicorn 0.27.0
- sqlalchemy 2.0.25
- pydantic 2.5.3
- apscheduler 3.10.4
- scapy 2.5.0
- pytest 7.4.4
### Frontend (JavaScript)
- vue 3.4.15
- pinia 2.1.7
- axios 1.6.5
- vite 5.0.11
- tailwindcss 3.4.1

17
architecture-technique.md Normal file
View File

@@ -0,0 +1,17 @@
# architecture-technique.md
## Backend
- FastAPI + SQLAlchemy + APScheduler
- Modules réseau : ping, arp, port scan
- WebSocket pour push temps réel
- APIs REST pour : IP, scan, paramètres, historique
## Frontend
- Vue 3 + Vite + Tailwind
- State global (Pinia)
- WebSocket client
## Docker
- service web (backend + frontend)
- volume config.yaml
- volume db.sqlite

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
# IPWatch Backend Application

View File

@@ -0,0 +1 @@
# Core configuration modules

111
backend/app/core/config.py Normal file
View File

@@ -0,0 +1,111 @@
"""
Configuration management pour IPWatch
Charge et valide le fichier config.yaml
"""
import yaml
from pathlib import Path
from typing import Dict, Any, List, Optional
from pydantic import BaseModel, Field
class AppConfig(BaseModel):
"""Configuration de l'application"""
name: str = "IPWatch"
version: str = "1.0.0"
debug: bool = False
class NetworkConfig(BaseModel):
"""Configuration réseau"""
cidr: str
gateway: Optional[str] = None
dns: Optional[List[str]] = None
class ScanConfig(BaseModel):
"""Configuration des scans"""
ping_interval: int = 60 # secondes
port_scan_interval: int = 300 # secondes
parallel_pings: int = 50
timeout: float = 1.0
class PortsConfig(BaseModel):
"""Configuration des ports à scanner"""
ranges: List[str] = ["22", "80", "443", "3389", "8080"]
class HistoryConfig(BaseModel):
"""Configuration de l'historique"""
retention_hours: int = 24
class UIConfig(BaseModel):
"""Configuration UI"""
offline_transparency: float = 0.5
show_mac: bool = True
show_vendor: bool = True
class ColorsConfig(BaseModel):
"""Configuration des couleurs"""
free: str = "#75715E"
online_known: str = "#A6E22E"
online_unknown: str = "#66D9EF"
offline_known: str = "#F92672"
offline_unknown: str = "#AE81FF"
class DatabaseConfig(BaseModel):
"""Configuration base de données"""
path: str = "./data/db.sqlite"
class IPWatchConfig(BaseModel):
"""Configuration complète IPWatch"""
app: AppConfig = Field(default_factory=AppConfig)
network: NetworkConfig
ip_classes: Dict[str, Any] = Field(default_factory=dict)
scan: ScanConfig = Field(default_factory=ScanConfig)
ports: PortsConfig = Field(default_factory=PortsConfig)
locations: List[str] = Field(default_factory=list)
hosts: List[str] = Field(default_factory=list)
history: HistoryConfig = Field(default_factory=HistoryConfig)
ui: UIConfig = Field(default_factory=UIConfig)
colors: ColorsConfig = Field(default_factory=ColorsConfig)
database: DatabaseConfig = Field(default_factory=DatabaseConfig)
class ConfigManager:
"""Gestionnaire de configuration singleton"""
_instance: Optional['ConfigManager'] = None
_config: Optional[IPWatchConfig] = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def load_config(self, config_path: str = "./config.yaml") -> IPWatchConfig:
"""Charge la configuration depuis le fichier YAML"""
path = Path(config_path)
if not path.exists():
raise FileNotFoundError(f"Fichier de configuration non trouvé: {config_path}")
with open(path, 'r', encoding='utf-8') as f:
yaml_data = yaml.safe_load(f)
self._config = IPWatchConfig(**yaml_data)
return self._config
@property
def config(self) -> IPWatchConfig:
"""Retourne la configuration actuelle"""
if self._config is None:
raise RuntimeError("Configuration non chargée. Appelez load_config() d'abord.")
return self._config
# Instance globale
config_manager = ConfigManager()

View File

@@ -0,0 +1,47 @@
"""
Configuration de la base de données SQLAlchemy
"""
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from pathlib import Path
# Base pour les modèles SQLAlchemy
Base = declarative_base()
# Engine et session
engine = None
SessionLocal = None
def init_database(db_path: str = "./data/db.sqlite"):
"""Initialise la connexion à la base de données"""
global engine, SessionLocal
# Créer le dossier data si nécessaire
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
# Créer l'engine SQLite
database_url = f"sqlite:///{db_path}"
engine = create_engine(
database_url,
connect_args={"check_same_thread": False},
echo=False
)
# Créer la session factory
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Créer les tables
Base.metadata.create_all(bind=engine)
return engine
def get_db():
"""Dependency pour obtenir une session DB"""
db = SessionLocal()
try:
yield db
finally:
db.close()

187
backend/app/main.py Normal file
View File

@@ -0,0 +1,187 @@
"""
Application FastAPI principale pour IPWatch
Point d'entrée du backend
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from contextlib import asynccontextmanager
from pathlib import Path
from backend.app.core.config import config_manager
from backend.app.core.database import init_database, get_db
from backend.app.routers import ips_router, scan_router, websocket_router
from backend.app.services.scheduler import scan_scheduler
from backend.app.routers.scan import perform_scan
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Gestionnaire du cycle de vie de l'application
Initialise et nettoie les ressources
"""
# Startup
print("=== Démarrage IPWatch ===")
# 1. Charger la configuration
try:
config = config_manager.load_config("./config.yaml")
print(f"✓ Configuration chargée: {config.network.cidr}")
except Exception as e:
print(f"✗ Erreur chargement config: {e}")
raise
# 2. Initialiser la base de données
try:
init_database(config.database.path)
print(f"✓ Base de données initialisée: {config.database.path}")
except Exception as e:
print(f"✗ Erreur initialisation DB: {e}")
raise
# 3. Démarrer le scheduler
try:
scan_scheduler.start()
# Créer une session DB pour les scans planifiés
from backend.app.core.database import SessionLocal
async def scheduled_scan():
"""Wrapper pour scan planifié avec DB session"""
db = SessionLocal()
try:
await perform_scan(db)
finally:
db.close()
# Configurer les tâches périodiques
scan_scheduler.add_ping_scan_job(
scheduled_scan,
interval_seconds=config.scan.ping_interval
)
scan_scheduler.add_port_scan_job(
scheduled_scan,
interval_seconds=config.scan.port_scan_interval
)
# Tâche de nettoyage historique
async def cleanup_history():
"""Nettoie l'historique ancien"""
from backend.app.models.ip import IPHistory
from datetime import datetime, timedelta
db = SessionLocal()
try:
cutoff = datetime.utcnow() - timedelta(hours=config.history.retention_hours)
deleted = db.query(IPHistory).filter(IPHistory.timestamp < cutoff).delete()
db.commit()
print(f"Nettoyage historique: {deleted} entrées supprimées")
finally:
db.close()
scan_scheduler.add_cleanup_job(cleanup_history, interval_hours=1)
print("✓ Scheduler démarré")
except Exception as e:
print(f"✗ Erreur démarrage scheduler: {e}")
print("=== IPWatch prêt ===\n")
yield
# Shutdown
print("\n=== Arrêt IPWatch ===")
scan_scheduler.stop()
print("✓ Scheduler arrêté")
# Créer l'application FastAPI
app = FastAPI(
title="IPWatch API",
description="API backend pour IPWatch - Scanner réseau temps réel",
version="1.0.0",
lifespan=lifespan
)
# Configuration CORS pour le frontend
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # À restreindre en production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Enregistrer les routers API
app.include_router(ips_router)
app.include_router(scan_router)
app.include_router(websocket_router)
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {
"status": "healthy",
"scheduler": scan_scheduler.is_running
}
# Servir les fichiers statiques du frontend
frontend_dist = Path(__file__).parent.parent.parent / "frontend" / "dist"
if frontend_dist.exists():
# Monter les assets statiques
app.mount("/assets", StaticFiles(directory=str(frontend_dist / "assets")), name="assets")
# Route racine pour servir index.html
@app.get("/")
async def serve_frontend():
"""Servir le frontend Vue"""
index_file = frontend_dist / "index.html"
if index_file.exists():
return FileResponse(index_file)
return {
"name": "IPWatch API",
"version": "1.0.0",
"status": "running",
"error": "Frontend non trouvé"
}
# Catch-all pour le routing Vue (SPA)
@app.get("/{full_path:path}")
async def catch_all(full_path: str):
"""Catch-all pour le routing Vue Router"""
# Ne pas intercepter les routes API
if full_path.startswith("api/") or full_path.startswith("ws"):
return {"error": "Not found"}
# Servir index.html pour toutes les autres routes
index_file = frontend_dist / "index.html"
if index_file.exists():
return FileResponse(index_file)
return {"error": "Frontend non trouvé"}
else:
@app.get("/")
async def root():
"""Endpoint racine (mode développement sans frontend)"""
return {
"name": "IPWatch API",
"version": "1.0.0",
"status": "running",
"note": "Frontend non buildé - utilisez le mode dev"
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"backend.app.main:app",
host="0.0.0.0",
port=8080,
reload=True
)

View File

@@ -0,0 +1,6 @@
"""
Modèles SQLAlchemy pour IPWatch
"""
from .ip import IP, IPHistory
__all__ = ["IP", "IPHistory"]

82
backend/app/models/ip.py Normal file
View File

@@ -0,0 +1,82 @@
"""
Modèles de données pour les adresses IP et leur historique
Basé sur modele-donnees.md
"""
from sqlalchemy import Column, String, Boolean, DateTime, Integer, ForeignKey, Index, JSON
from sqlalchemy.orm import relationship
from datetime import datetime
from backend.app.core.database import Base
class IP(Base):
"""
Table principale des adresses IP
Stocke les informations actuelles et les métadonnées de chaque IP
"""
__tablename__ = "ip"
# Clé primaire
ip = Column(String, primary_key=True, index=True)
# Métadonnées
name = Column(String, nullable=True) # Nom donné à l'IP
known = Column(Boolean, default=False, index=True) # IP connue ou inconnue
location = Column(String, nullable=True) # Localisation (ex: "Bureau", "Serveur")
host = Column(String, nullable=True) # Type d'hôte (ex: "PC", "Imprimante")
# Timestamps
first_seen = Column(DateTime, default=datetime.utcnow) # Première détection
last_seen = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) # Dernière vue
# État réseau
last_status = Column(String, index=True) # "online", "offline", "unknown"
# Informations réseau
mac = Column(String, nullable=True) # Adresse MAC
vendor = Column(String, nullable=True) # Fabricant (lookup MAC)
hostname = Column(String, nullable=True) # Nom d'hôte réseau
# Ports ouverts (stocké en JSON)
open_ports = Column(JSON, default=list) # Liste des ports ouverts
# Relation avec l'historique
history = relationship("IPHistory", back_populates="ip_ref", cascade="all, delete-orphan")
def __repr__(self):
return f"<IP {self.ip} - {self.last_status} - {self.name or 'unnamed'}>"
class IPHistory(Base):
"""
Table d'historique des états d'IP
Stocke l'évolution dans le temps (24h par défaut)
"""
__tablename__ = "ip_history"
# Clé primaire auto-incrémentée
id = Column(Integer, primary_key=True, autoincrement=True)
# Foreign key vers la table IP
ip = Column(String, ForeignKey("ip.ip", ondelete="CASCADE"), nullable=False, index=True)
# Timestamp de l'enregistrement
timestamp = Column(DateTime, default=datetime.utcnow, index=True, nullable=False)
# État à ce moment
status = Column(String, nullable=False) # "online", "offline"
# Ports ouverts à ce moment (JSON)
open_ports = Column(JSON, default=list)
# Relation inverse vers IP
ip_ref = relationship("IP", back_populates="history")
def __repr__(self):
return f"<IPHistory {self.ip} - {self.timestamp} - {self.status}>"
# Index recommandés (déjà définis dans les colonnes avec index=True)
# Index supplémentaires si nécessaire
Index('idx_ip_last_status', IP.last_status)
Index('idx_ip_history_timestamp', IPHistory.timestamp)
Index('idx_ip_history_ip', IPHistory.ip)

View File

@@ -0,0 +1,8 @@
"""
Routers API pour IPWatch
"""
from .ips import router as ips_router
from .scan import router as scan_router
from .websocket import router as websocket_router
__all__ = ["ips_router", "scan_router", "websocket_router"]

216
backend/app/routers/ips.py Normal file
View File

@@ -0,0 +1,216 @@
"""
Endpoints API pour la gestion des IPs
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import desc
from typing import List, Optional
from datetime import datetime, timedelta
from backend.app.core.database import get_db
from backend.app.models.ip import IP, IPHistory
from pydantic import BaseModel
router = APIRouter(prefix="/api/ips", tags=["IPs"])
# Schémas Pydantic pour validation
class IPUpdate(BaseModel):
"""Schéma pour mise à jour d'IP"""
name: Optional[str] = None
known: Optional[bool] = None
location: Optional[str] = None
host: Optional[str] = None
class IPResponse(BaseModel):
"""Schéma de réponse IP"""
ip: str
name: Optional[str]
known: bool
location: Optional[str]
host: Optional[str]
first_seen: Optional[datetime]
last_seen: Optional[datetime]
last_status: Optional[str]
mac: Optional[str]
vendor: Optional[str]
hostname: Optional[str]
open_ports: List[int]
class Config:
from_attributes = True
class IPHistoryResponse(BaseModel):
"""Schéma de réponse historique"""
id: int
ip: str
timestamp: datetime
status: str
open_ports: List[int]
class Config:
from_attributes = True
@router.get("/", response_model=List[IPResponse])
async def get_all_ips(
status: Optional[str] = None,
known: Optional[bool] = None,
db: Session = Depends(get_db)
):
"""
Récupère toutes les IPs avec filtres optionnels
Args:
status: Filtrer par statut (online/offline)
known: Filtrer par IPs connues/inconnues
db: Session de base de données
Returns:
Liste des IPs
"""
query = db.query(IP)
if status:
query = query.filter(IP.last_status == status)
if known is not None:
query = query.filter(IP.known == known)
ips = query.all()
return ips
@router.get("/{ip_address}", response_model=IPResponse)
async def get_ip(ip_address: str, db: Session = Depends(get_db)):
"""
Récupère les détails d'une IP spécifique
Args:
ip_address: Adresse IP
db: Session de base de données
Returns:
Détails de l'IP
"""
ip = db.query(IP).filter(IP.ip == ip_address).first()
if not ip:
raise HTTPException(status_code=404, detail="IP non trouvée")
return ip
@router.put("/{ip_address}", response_model=IPResponse)
async def update_ip(
ip_address: str,
ip_update: IPUpdate,
db: Session = Depends(get_db)
):
"""
Met à jour les informations d'une IP
Args:
ip_address: Adresse IP
ip_update: Données à mettre à jour
db: Session de base de données
Returns:
IP mise à jour
"""
ip = db.query(IP).filter(IP.ip == ip_address).first()
if not ip:
raise HTTPException(status_code=404, detail="IP non trouvée")
# Mettre à jour les champs fournis
update_data = ip_update.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(ip, field, value)
db.commit()
db.refresh(ip)
return ip
@router.delete("/{ip_address}")
async def delete_ip(ip_address: str, db: Session = Depends(get_db)):
"""
Supprime une IP (et son historique)
Args:
ip_address: Adresse IP
db: Session de base de données
Returns:
Message de confirmation
"""
ip = db.query(IP).filter(IP.ip == ip_address).first()
if not ip:
raise HTTPException(status_code=404, detail="IP non trouvée")
db.delete(ip)
db.commit()
return {"message": f"IP {ip_address} supprimée"}
@router.get("/{ip_address}/history", response_model=List[IPHistoryResponse])
async def get_ip_history(
ip_address: str,
hours: int = 24,
db: Session = Depends(get_db)
):
"""
Récupère l'historique d'une IP
Args:
ip_address: Adresse IP
hours: Nombre d'heures d'historique (défaut: 24h)
db: Session de base de données
Returns:
Liste des événements historiques
"""
# Vérifier que l'IP existe
ip = db.query(IP).filter(IP.ip == ip_address).first()
if not ip:
raise HTTPException(status_code=404, detail="IP non trouvée")
# Calculer la date limite
since = datetime.utcnow() - timedelta(hours=hours)
# Récupérer l'historique
history = db.query(IPHistory).filter(
IPHistory.ip == ip_address,
IPHistory.timestamp >= since
).order_by(desc(IPHistory.timestamp)).all()
return history
@router.get("/stats/summary")
async def get_stats(db: Session = Depends(get_db)):
"""
Récupère les statistiques globales du réseau
Returns:
Statistiques (total, online, offline, known, unknown)
"""
total = db.query(IP).count()
online = db.query(IP).filter(IP.last_status == "online").count()
offline = db.query(IP).filter(IP.last_status == "offline").count()
known = db.query(IP).filter(IP.known == True).count()
unknown = db.query(IP).filter(IP.known == False).count()
return {
"total": total,
"online": online,
"offline": offline,
"known": known,
"unknown": unknown
}

201
backend/app/routers/scan.py Normal file
View File

@@ -0,0 +1,201 @@
"""
Endpoints API pour le contrôle des scans réseau
"""
from fastapi import APIRouter, Depends, BackgroundTasks
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
from typing import Dict, Any
from backend.app.core.database import get_db
from backend.app.core.config import config_manager
from backend.app.models.ip import IP, IPHistory
from backend.app.services.network import NetworkScanner
from backend.app.services.websocket import ws_manager
router = APIRouter(prefix="/api/scan", tags=["Scan"])
async def perform_scan(db: Session):
"""
Effectue un scan complet du réseau
Fonction asynchrone pour background task
Args:
db: Session de base de données
"""
try:
print(f"[{datetime.now()}] Début du scan réseau...")
# Notifier début du scan
try:
await ws_manager.broadcast_scan_start()
except Exception as e:
print(f"Erreur broadcast start (ignorée): {e}")
# Récupérer la config
config = config_manager.config
print(f"[{datetime.now()}] Config chargée: {config.network.cidr}")
# Initialiser le scanner
scanner = NetworkScanner(
cidr=config.network.cidr,
timeout=config.scan.timeout
)
# Convertir les ports en liste d'entiers
port_list = []
for port_range in config.ports.ranges:
if '-' in port_range:
start, end = map(int, port_range.split('-'))
port_list.extend(range(start, end + 1))
else:
port_list.append(int(port_range))
print(f"[{datetime.now()}] Ports à scanner: {port_list}")
# Récupérer les IPs connues
known_ips = config.ip_classes
print(f"[{datetime.now()}] IPs connues: {len(known_ips)}")
# Lancer le scan
print(f"[{datetime.now()}] Lancement du scan (parallélisme: {config.scan.parallel_pings})...")
scan_results = await scanner.full_scan(
known_ips=known_ips,
port_list=port_list,
max_concurrent=config.scan.parallel_pings
)
print(f"[{datetime.now()}] Scan terminé: {len(scan_results)} IPs trouvées")
# Mettre à jour la base de données
stats = {
"total": 0,
"online": 0,
"offline": 0,
"new": 0,
"updated": 0
}
for ip_address, ip_data in scan_results.items():
stats["total"] += 1
if ip_data["last_status"] == "online":
stats["online"] += 1
else:
stats["offline"] += 1
# Vérifier si l'IP existe déjà
existing_ip = db.query(IP).filter(IP.ip == ip_address).first()
if existing_ip:
# Mettre à jour l'IP existante
old_status = existing_ip.last_status
existing_ip.last_status = ip_data["last_status"]
if ip_data["last_seen"]:
existing_ip.last_seen = ip_data["last_seen"]
existing_ip.mac = ip_data.get("mac") or existing_ip.mac
existing_ip.vendor = ip_data.get("vendor") or existing_ip.vendor
existing_ip.hostname = ip_data.get("hostname") or existing_ip.hostname
existing_ip.open_ports = ip_data.get("open_ports", [])
# Si l'état a changé, notifier via WebSocket
if old_status != ip_data["last_status"]:
await ws_manager.broadcast_ip_update({
"ip": ip_address,
"old_status": old_status,
"new_status": ip_data["last_status"]
})
stats["updated"] += 1
else:
# Créer une nouvelle IP
new_ip = IP(
ip=ip_address,
name=ip_data.get("name"),
known=ip_data.get("known", False),
location=ip_data.get("location"),
host=ip_data.get("host"),
first_seen=datetime.utcnow(),
last_seen=ip_data.get("last_seen") or datetime.utcnow(),
last_status=ip_data["last_status"],
mac=ip_data.get("mac"),
vendor=ip_data.get("vendor"),
hostname=ip_data.get("hostname"),
open_ports=ip_data.get("open_ports", [])
)
db.add(new_ip)
# Notifier nouvelle IP
await ws_manager.broadcast_new_ip({
"ip": ip_address,
"status": ip_data["last_status"],
"known": ip_data.get("known", False)
})
stats["new"] += 1
# Ajouter à l'historique
history_entry = IPHistory(
ip=ip_address,
timestamp=datetime.utcnow(),
status=ip_data["last_status"],
open_ports=ip_data.get("open_ports", [])
)
db.add(history_entry)
# Commit les changements
db.commit()
# Notifier fin du scan avec stats
await ws_manager.broadcast_scan_complete(stats)
print(f"[{datetime.now()}] Scan terminé: {stats}")
except Exception as e:
print(f"Erreur lors du scan: {e}")
db.rollback()
@router.post("/start")
async def start_scan(background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
"""
Déclenche un scan réseau immédiat
Returns:
Message de confirmation
"""
# Lancer le scan en arrière-plan
background_tasks.add_task(perform_scan, db)
return {
"message": "Scan réseau démarré",
"timestamp": datetime.utcnow()
}
@router.post("/cleanup-history")
async def cleanup_history(hours: int = 24, db: Session = Depends(get_db)):
"""
Nettoie l'historique plus ancien que X heures
Args:
hours: Nombre d'heures à conserver (défaut: 24h)
db: Session de base de données
Returns:
Nombre d'entrées supprimées
"""
cutoff_date = datetime.utcnow() - timedelta(hours=hours)
deleted = db.query(IPHistory).filter(
IPHistory.timestamp < cutoff_date
).delete()
db.commit()
return {
"message": f"Historique nettoyé",
"deleted_entries": deleted,
"older_than_hours": hours
}

View File

@@ -0,0 +1,35 @@
"""
Endpoint WebSocket pour notifications temps réel
"""
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from backend.app.services.websocket import ws_manager
router = APIRouter(tags=["WebSocket"])
@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
"""
Endpoint WebSocket pour notifications temps réel
Args:
websocket: Connexion WebSocket
"""
await ws_manager.connect(websocket)
try:
# Boucle de réception (keep-alive)
while True:
# Recevoir des messages du client (heartbeat)
data = await websocket.receive_text()
# On peut gérer des commandes du client ici si nécessaire
# Pour l'instant, on fait juste un echo pour keep-alive
if data == "ping":
await ws_manager.send_personal_message("pong", websocket)
except WebSocketDisconnect:
ws_manager.disconnect(websocket)
except Exception as e:
print(f"Erreur WebSocket: {e}")
ws_manager.disconnect(websocket)

View File

@@ -0,0 +1,7 @@
"""
Services réseau pour IPWatch
"""
from .network import NetworkScanner
from .scheduler import ScanScheduler
__all__ = ["NetworkScanner", "ScanScheduler"]

View File

@@ -0,0 +1,295 @@
"""
Modules réseau pour scan d'IP, ping, ARP et port scan
Implémente le workflow de scan selon workflow-scan.md
"""
import asyncio
import ipaddress
import platform
import subprocess
import socket
from typing import List, Dict, Optional, Tuple
from datetime import datetime
import re
# Scapy pour ARP
try:
from scapy.all import ARP, Ether, srp
SCAPY_AVAILABLE = True
except ImportError:
SCAPY_AVAILABLE = False
class NetworkScanner:
"""Scanner réseau principal"""
def __init__(self, cidr: str, timeout: float = 1.0):
"""
Initialise le scanner réseau
Args:
cidr: Réseau CIDR (ex: "192.168.1.0/24")
timeout: Timeout pour ping et connexions (secondes)
"""
self.cidr = cidr
self.timeout = timeout
self.network = ipaddress.ip_network(cidr, strict=False)
def generate_ip_list(self) -> List[str]:
"""
Génère la liste complète d'IP depuis le CIDR
Returns:
Liste des adresses IP en string
"""
return [str(ip) for ip in self.network.hosts()]
async def ping(self, ip: str) -> bool:
"""
Ping une adresse IP (async)
Args:
ip: Adresse IP à pinger
Returns:
True si l'IP répond, False sinon
"""
# Détection de l'OS pour la commande ping
param = '-n' if platform.system().lower() == 'windows' else '-c'
timeout_param = '-w' if platform.system().lower() == 'windows' else '-W'
command = ['ping', param, '1', timeout_param, str(int(self.timeout * 1000) if platform.system().lower() == 'windows' else str(int(self.timeout))), ip]
try:
# Exécuter le ping de manière asynchrone
process = await asyncio.create_subprocess_exec(
*command,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL
)
await asyncio.wait_for(process.wait(), timeout=self.timeout + 1)
return process.returncode == 0
except (asyncio.TimeoutError, Exception):
return False
async def ping_parallel(self, ip_list: List[str], max_concurrent: int = 50) -> Dict[str, bool]:
"""
Ping multiple IPs en parallèle
Args:
ip_list: Liste des IPs à pinger
max_concurrent: Nombre maximum de pings simultanés
Returns:
Dictionnaire {ip: online_status}
"""
results = {}
semaphore = asyncio.Semaphore(max_concurrent)
async def ping_with_semaphore(ip: str):
async with semaphore:
results[ip] = await self.ping(ip)
# Lancer tous les pings en parallèle avec limite
await asyncio.gather(*[ping_with_semaphore(ip) for ip in ip_list])
return results
def get_arp_table(self) -> Dict[str, Tuple[str, str]]:
"""
Récupère la table ARP du système
Returns:
Dictionnaire {ip: (mac, vendor)}
"""
arp_data = {}
if SCAPY_AVAILABLE:
try:
# Utiliser Scapy pour ARP scan
answered, _ = srp(
Ether(dst="ff:ff:ff:ff:ff:ff") / ARP(pdst=self.cidr),
timeout=2,
verbose=False
)
for sent, received in answered:
ip = received.psrc
mac = received.hwsrc
vendor = self._get_mac_vendor(mac)
arp_data[ip] = (mac, vendor)
except Exception as e:
print(f"Erreur ARP scan avec Scapy: {e}")
else:
# Fallback: parser la table ARP système
try:
if platform.system().lower() == 'windows':
output = subprocess.check_output(['arp', '-a'], text=True)
pattern = r'(\d+\.\d+\.\d+\.\d+)\s+([0-9a-fA-F-:]+)'
else:
output = subprocess.check_output(['arp', '-n'], text=True)
pattern = r'(\d+\.\d+\.\d+\.\d+)\s+\w+\s+([0-9a-fA-F:]+)'
matches = re.findall(pattern, output)
for ip, mac in matches:
if ip in [str(h) for h in self.network.hosts()]:
vendor = self._get_mac_vendor(mac)
arp_data[ip] = (mac, vendor)
except Exception as e:
print(f"Erreur lecture table ARP: {e}")
return arp_data
def _get_mac_vendor(self, mac: str) -> str:
"""
Lookup du fabricant depuis l'adresse MAC
Simplifié pour l'instant - peut être étendu avec une vraie DB OUI
Args:
mac: Adresse MAC
Returns:
Nom du fabricant ou "Unknown"
"""
# TODO: Implémenter lookup OUI complet
# Pour l'instant, retourne un placeholder
mac_prefix = mac[:8].upper().replace(':', '').replace('-', '')
# Mini DB des fabricants courants
vendors = {
"00:0C:29": "VMware",
"00:50:56": "VMware",
"08:00:27": "VirtualBox",
"DC:A6:32": "Raspberry Pi",
"B8:27:EB": "Raspberry Pi",
}
for prefix, vendor in vendors.items():
if mac.upper().startswith(prefix.replace(':', '')):
return vendor
return "Unknown"
async def scan_ports(self, ip: str, ports: List[int]) -> List[int]:
"""
Scan des ports TCP sur une IP
Args:
ip: Adresse IP cible
ports: Liste des ports à scanner
Returns:
Liste des ports ouverts
"""
open_ports = []
async def check_port(port: int) -> Optional[int]:
try:
# Tentative de connexion TCP
reader, writer = await asyncio.wait_for(
asyncio.open_connection(ip, port),
timeout=self.timeout
)
writer.close()
await writer.wait_closed()
return port
except:
return None
# Scanner tous les ports en parallèle
results = await asyncio.gather(*[check_port(p) for p in ports])
open_ports = [p for p in results if p is not None]
return open_ports
def get_hostname(self, ip: str) -> Optional[str]:
"""
Résolution DNS inversée pour obtenir le hostname
Args:
ip: Adresse IP
Returns:
Hostname ou None
"""
try:
hostname, _, _ = socket.gethostbyaddr(ip)
return hostname
except:
return None
def classify_ip_status(self, is_online: bool, is_known: bool) -> str:
"""
Classification de l'état d'une IP
Args:
is_online: IP en ligne
is_known: IP connue dans la config
Returns:
État: "online", "offline"
"""
return "online" if is_online else "offline"
async def full_scan(self, known_ips: Dict[str, Dict], port_list: List[int], max_concurrent: int = 50) -> Dict[str, Dict]:
"""
Scan complet du réseau selon workflow-scan.md
Args:
known_ips: Dictionnaire des IPs connues depuis config
port_list: Liste des ports à scanner
max_concurrent: Pings simultanés max
Returns:
Dictionnaire des résultats de scan pour chaque IP
"""
results = {}
# 1. Générer liste IP du CIDR
ip_list = self.generate_ip_list()
# 2. Ping parallélisé
ping_results = await self.ping_parallel(ip_list, max_concurrent)
# 3. ARP + MAC vendor
arp_table = self.get_arp_table()
# 4. Pour chaque IP
for ip in ip_list:
is_online = ping_results.get(ip, False)
is_known = ip in known_ips
ip_data = {
"ip": ip,
"known": is_known,
"last_status": self.classify_ip_status(is_online, is_known),
"last_seen": datetime.utcnow() if is_online else None,
"mac": None,
"vendor": None,
"hostname": None,
"open_ports": [],
}
# Ajouter infos connues
if is_known:
ip_data.update(known_ips[ip])
# Infos ARP
if ip in arp_table:
mac, vendor = arp_table[ip]
ip_data["mac"] = mac
ip_data["vendor"] = vendor
# Hostname
if is_online:
hostname = self.get_hostname(ip)
if hostname:
ip_data["hostname"] = hostname
# 5. Port scan (uniquement si online)
if is_online and port_list:
open_ports = await self.scan_ports(ip, port_list)
ip_data["open_ports"] = open_ports
results[ip] = ip_data
return results

View File

@@ -0,0 +1,103 @@
"""
Scheduler APScheduler pour les scans réseau périodiques
"""
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
from datetime import datetime, timedelta
from typing import Optional, Callable
import asyncio
class ScanScheduler:
"""Gestionnaire de tâches planifiées pour les scans"""
def __init__(self):
"""Initialise le scheduler"""
self.scheduler = AsyncIOScheduler()
self.is_running = False
def start(self):
"""Démarre le scheduler"""
if not self.is_running:
self.scheduler.start()
self.is_running = True
print(f"[{datetime.now()}] Scheduler démarré")
def stop(self):
"""Arrête le scheduler"""
if self.is_running:
self.scheduler.shutdown()
self.is_running = False
print(f"[{datetime.now()}] Scheduler arrêté")
def add_ping_scan_job(self, scan_function: Callable, interval_seconds: int = 60):
"""
Ajoute une tâche de scan ping périodique
Args:
scan_function: Fonction async à exécuter
interval_seconds: Intervalle en secondes
"""
self.scheduler.add_job(
scan_function,
trigger=IntervalTrigger(seconds=interval_seconds),
id='ping_scan',
name='Scan Ping périodique',
replace_existing=True
)
print(f"Tâche ping_scan configurée: toutes les {interval_seconds}s")
def add_port_scan_job(self, scan_function: Callable, interval_seconds: int = 300):
"""
Ajoute une tâche de scan de ports périodique
Args:
scan_function: Fonction async à exécuter
interval_seconds: Intervalle en secondes
"""
self.scheduler.add_job(
scan_function,
trigger=IntervalTrigger(seconds=interval_seconds),
id='port_scan',
name='Scan ports périodique',
replace_existing=True
)
print(f"Tâche port_scan configurée: toutes les {interval_seconds}s")
def add_cleanup_job(self, cleanup_function: Callable, interval_hours: int = 1):
"""
Ajoute une tâche de nettoyage de l'historique
Args:
cleanup_function: Fonction async de nettoyage
interval_hours: Intervalle en heures
"""
self.scheduler.add_job(
cleanup_function,
trigger=IntervalTrigger(hours=interval_hours),
id='history_cleanup',
name='Nettoyage historique',
replace_existing=True
)
print(f"Tâche cleanup configurée: toutes les {interval_hours}h")
def remove_job(self, job_id: str):
"""
Supprime une tâche planifiée
Args:
job_id: ID de la tâche
"""
try:
self.scheduler.remove_job(job_id)
print(f"Tâche {job_id} supprimée")
except Exception as e:
print(f"Erreur suppression tâche {job_id}: {e}")
def get_jobs(self):
"""Retourne la liste des tâches planifiées"""
return self.scheduler.get_jobs()
# Instance globale du scheduler
scan_scheduler = ScanScheduler()

View File

@@ -0,0 +1,125 @@
"""
Gestionnaire WebSocket pour notifications temps réel
"""
from fastapi import WebSocket
from typing import List, Dict, Any
import json
from datetime import datetime
class WebSocketManager:
"""Gestionnaire de connexions WebSocket"""
def __init__(self):
"""Initialise le gestionnaire"""
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket):
"""
Accepte une nouvelle connexion WebSocket
Args:
websocket: Instance WebSocket
"""
await websocket.accept()
self.active_connections.append(websocket)
print(f"[{datetime.now()}] Nouvelle connexion WebSocket. Total: {len(self.active_connections)}")
def disconnect(self, websocket: WebSocket):
"""
Déconnecte un client WebSocket
Args:
websocket: Instance WebSocket à déconnecter
"""
if websocket in self.active_connections:
self.active_connections.remove(websocket)
print(f"[{datetime.now()}] Déconnexion WebSocket. Total: {len(self.active_connections)}")
async def send_personal_message(self, message: str, websocket: WebSocket):
"""
Envoie un message à un client spécifique
Args:
message: Message à envoyer
websocket: Client destinataire
"""
try:
await websocket.send_text(message)
except Exception as e:
print(f"Erreur envoi message personnel: {e}")
async def broadcast(self, message: Dict[str, Any]):
"""
Diffuse un message à tous les clients connectés
Args:
message: Dictionnaire du message (sera converti en JSON)
"""
# Ajouter un timestamp
message["timestamp"] = datetime.utcnow().isoformat()
json_message = json.dumps(message)
# Liste des connexions à supprimer (déconnectées)
disconnected = []
for connection in self.active_connections:
try:
await connection.send_text(json_message)
except Exception as e:
print(f"Erreur broadcast: {e}")
disconnected.append(connection)
# Nettoyer les connexions mortes
for conn in disconnected:
self.disconnect(conn)
async def broadcast_scan_start(self):
"""Notifie le début d'un scan"""
await self.broadcast({
"type": "scan_start",
"message": "Scan réseau démarré"
})
async def broadcast_scan_complete(self, stats: Dict[str, int]):
"""
Notifie la fin d'un scan avec statistiques
Args:
stats: Statistiques du scan (total, online, offline, etc.)
"""
await self.broadcast({
"type": "scan_complete",
"message": "Scan réseau terminé",
"stats": stats
})
async def broadcast_ip_update(self, ip_data: Dict[str, Any]):
"""
Notifie un changement d'état d'IP
Args:
ip_data: Données de l'IP mise à jour
"""
await self.broadcast({
"type": "ip_update",
"data": ip_data
})
async def broadcast_new_ip(self, ip_data: Dict[str, Any]):
"""
Notifie la détection d'une nouvelle IP
Args:
ip_data: Données de la nouvelle IP
"""
await self.broadcast({
"type": "new_ip",
"data": ip_data,
"message": f"Nouvelle IP détectée: {ip_data.get('ip')}"
})
# Instance globale du gestionnaire WebSocket
ws_manager = WebSocketManager()

16
backend/requirements.txt Normal file
View File

@@ -0,0 +1,16 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
sqlalchemy==2.0.25
pydantic==2.5.3
pydantic-settings==2.1.0
python-multipart==0.0.6
websockets==12.0
apscheduler==3.10.4
pyyaml==6.0.1
asyncio==3.4.3
aiosqlite==0.19.0
python-nmap==0.7.1
scapy==2.5.0
pytest==7.4.4
pytest-asyncio==0.23.3
httpx==0.26.0

89
config.yaml Normal file
View File

@@ -0,0 +1,89 @@
# Configuration IPWatch
# Basé sur consigne-parametrage.md
app:
name: "IPWatch"
version: "1.0.0"
debug: true
network:
cidr: "10.0.0.0/22"
gateway: "10.0.0.1"
dns:
- "8.8.8.8"
- "8.8.4.4"
# Sous-réseaux organisés en sections
subnets:
- name: "static_vm"
cidr: "10.0.0.0/24"
start: "10.0.0.1"
end: "10.0.0.255"
description: "Machines virtuelles statiques"
- name: "dhcp"
cidr: "10.0.1.0/24"
start: "10.0.1.1"
end: "10.0.1locations.255"
description: "DHCP"
- name: "iot"
cidr: "10.0.2.0/24"
start: "10.0.2.1"
end: "10.0.2.255"
description: "IoT"
# IPs connues avec métadonnées
ip_classes:
"10.0.0.1":
name: "Gateway"
location: "Réseau"
host: "Routeur"
scan:
ping_interval: 600 # Intervalle scan ping (secondes)
port_scan_interval: 1200 # Intervalle scan ports (secondes)
parallel_pings: 100 # Nombre de pings simultanés
timeout: 1.0 # Timeout réseau (secondes)
ports:
ranges:
- "22" # SSH
- "80" # HTTP
- "443" # HTTPS
- "3389" # RDP
- "8080" # HTTP alternatif
- "3306" # MySQL
- "5432" # PostgreSQL
locations:
- "Bureau"
- "Salon"
- "Comble"
- "Bureau RdC"
# la localisation est herité de l'host il faudrait adapter config en consequence
hosts:
- "physique"
- "elitedesk"
- "m710Q"
- "HP Proliant"
- "pve MSI"
- "HP Proxmox"
history:
retention_hours: 24 # Conserver 24h d'historique
ui:
offline_transparency: 0.5 # Transparence des IPs offline
show_mac: true
show_vendor: true
colors:
free: "#75715E" # IP libre (gris Monokai)
online_known: "#A6E22E" # En ligne + connue (vert)
online_unknown: "#66D9EF" # En ligne + inconnue (cyan)
offline_known: "#F92672" # Hors ligne + connue (rose/rouge)
offline_unknown: "#AE81FF" # Hors ligne + inconnue (violet)
database:
path: "./data/db.sqlite"

27
consigne-design_webui.md Normal file
View File

@@ -0,0 +1,27 @@
# consigne-design_webui.md
## Thème
Monokai dark, contrastes forts, bordures arrondies.
## Layout général
3 colonnes :
- gauche : détail IP
- centre : grille dIP + légende + classes
- droite : nouvelles détections
## États des IP
Couleurs, bordure pleine/hors ligne, halo ping en cours.
## Composants
- Header
- Volet gauche
- Grille IP
- Volet droit
- Onglet paramètres
## Interactions
- sélection case IP
- clic nouvelle IP
- filtres à cocher
- animation ping
- transparence offline

21
consigne-parametrage.md Normal file
View File

@@ -0,0 +1,21 @@
# consigne-parametrage.md
Ce document décrit toutes les règles du fichier YAML.
## Sections
- app
- network
- ip_classes
- scan
- ports
- locations
- hosts
- history
- ui
- colors
- network_advanced
- filters
- database
## Exemple complet
(… full YAML spec as defined previously …)

40
docker-compose.yml Normal file
View File

@@ -0,0 +1,40 @@
services:
ipwatch:
build: .
container_name: ipwatch
restart: unless-stopped
# Réseau host pour accès complet au réseau local
network_mode: host
# Privilèges pour scan réseau (ping, ARP)
privileged: true
cap_add:
- NET_ADMIN
- NET_RAW
volumes:
# Volume pour la configuration
- ./config.yaml:/app/config.yaml:ro
# Volume pour la base de données
- ./data:/app/data
# Volume pour les logs (optionnel)
- ./logs:/app/logs
environment:
- TZ=Europe/Paris
# Healthcheck
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Créer les volumes nommés si nécessaire
volumes:
ipwatch-data:
ipwatch-logs:

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<link rel="icon" type="image/svg+xml" href="/vite.svg">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IPWatch - Scanner Réseau</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

23
frontend/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "ipwatch-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.15",
"pinia": "^2.1.7",
"axios": "^1.6.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.3",
"vite": "^5.0.11",
"tailwindcss": "^3.4.1",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.33"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

48
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,48 @@
<template>
<div class="h-screen flex flex-col bg-monokai-bg">
<!-- Header -->
<AppHeader />
<!-- Layout 3 colonnes selon consigne-design_webui.md -->
<div class="flex-1 flex overflow-hidden">
<!-- Colonne gauche: Détails IP -->
<div class="w-80 flex-shrink-0">
<IPDetails />
</div>
<!-- Colonne centrale: Grille d'IP organisée en arbre -->
<div class="flex-1">
<IPGridTree />
</div>
<!-- Colonne droite: Nouvelles détections -->
<div class="w-80 flex-shrink-0">
<NewDetections />
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, onUnmounted } from 'vue'
import { useIPStore } from '@/stores/ipStore'
import AppHeader from '@/components/AppHeader.vue'
import IPDetails from '@/components/IPDetails.vue'
import IPGridTree from '@/components/IPGridTree.vue'
import NewDetections from '@/components/NewDetections.vue'
const ipStore = useIPStore()
onMounted(async () => {
// Charger les données initiales
await ipStore.fetchIPs()
// Connecter WebSocket
ipStore.connectWebSocket()
})
onUnmounted(() => {
// Déconnecter WebSocket
ipStore.disconnectWebSocket()
})
</script>

View File

@@ -0,0 +1,147 @@
/* Styles principaux IPWatch - Thème Monokai */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Variables CSS Monokai */
:root {
--monokai-bg: #272822;
--monokai-text: #F8F8F2;
--monokai-comment: #75715E;
--monokai-green: #A6E22E;
--monokai-pink: #F92672;
--monokai-cyan: #66D9EF;
--monokai-purple: #AE81FF;
--monokai-yellow: #E6DB74;
--monokai-orange: #FD971F;
}
/* Base */
body {
margin: 0;
padding: 0;
background-color: var(--monokai-bg);
color: var(--monokai-text);
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
/* Animation halo ping */
@keyframes ping-pulse {
0% {
box-shadow: 0 0 0 0 rgba(102, 217, 239, 0.7);
}
50% {
box-shadow: 0 0 20px 10px rgba(102, 217, 239, 0.3);
}
100% {
box-shadow: 0 0 0 0 rgba(102, 217, 239, 0);
}
}
.ping-animation {
animation: ping-pulse 1.5s ease-in-out infinite;
}
/* Cases IP compactes - Version minimale */
.ip-cell-compact {
@apply rounded cursor-pointer transition-all duration-200 relative;
border: 2px solid;
width: 50px;
height: 50px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 14px;
}
/* Cases IP - États selon guidelines-css.md */
.ip-cell {
@apply rounded-lg p-3 cursor-pointer transition-all duration-200;
border: 2px solid;
min-height: 80px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
/* IP libre */
.ip-cell.free,
.ip-cell-compact.free {
background-color: rgba(117, 113, 94, 0.2);
border-color: var(--monokai-comment);
color: var(--monokai-comment);
}
/* IP en ligne + connue (vert) */
.ip-cell.online-known,
.ip-cell-compact.online-known {
background-color: rgba(166, 226, 46, 0.15);
border-color: var(--monokai-green);
border-style: solid;
color: var(--monokai-text);
}
.ip-cell.online-known:hover,
.ip-cell-compact.online-known:hover {
background-color: rgba(166, 226, 46, 0.25);
}
/* IP en ligne + inconnue (cyan) */
.ip-cell.online-unknown,
.ip-cell-compact.online-unknown {
background-color: rgba(102, 217, 239, 0.15);
border-color: var(--monokai-cyan);
border-style: solid;
color: var(--monokai-text);
}
.ip-cell.online-unknown:hover,
.ip-cell-compact.online-unknown:hover {
background-color: rgba(102, 217, 239, 0.25);
}
/* IP hors ligne + connue (rose) */
.ip-cell.offline-known,
.ip-cell-compact.offline-known {
background-color: rgba(249, 38, 114, 0.1);
border-color: var(--monokai-pink);
border-style: dashed;
color: var(--monokai-text);
opacity: 0.5;
}
/* IP hors ligne + inconnue (violet) */
.ip-cell.offline-unknown,
.ip-cell-compact.offline-unknown {
background-color: rgba(174, 129, 255, 0.1);
border-color: var(--monokai-purple);
border-style: dashed;
color: var(--monokai-text);
opacity: 0.5;
}
/* Sélection */
.ip-cell.selected {
box-shadow: 0 0 20px rgba(230, 219, 116, 0.5);
border-color: var(--monokai-yellow);
}
/* Scrollbar custom Monokai */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: #1e1f1c;
}
::-webkit-scrollbar-thumb {
background: var(--monokai-comment);
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--monokai-cyan);
}

View File

@@ -0,0 +1,68 @@
<template>
<header class="bg-monokai-bg border-b-2 border-monokai-comment p-4">
<div class="flex items-center justify-between">
<!-- Logo et titre -->
<div class="flex items-center gap-4">
<h1 class="text-3xl font-bold text-monokai-green">IPWatch</h1>
<span class="text-monokai-comment">Scanner Réseau</span>
</div>
<!-- Stats et contrôles -->
<div class="flex items-center gap-6">
<!-- Statistiques -->
<div class="flex gap-4 text-sm">
<div class="flex items-center gap-2">
<span class="text-monokai-comment">Total:</span>
<span class="text-monokai-text font-bold">{{ stats.total }}</span>
</div>
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-monokai-green"></span>
<span class="text-monokai-text">{{ stats.online }}</span>
</div>
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-monokai-pink opacity-50"></span>
<span class="text-monokai-text">{{ stats.offline }}</span>
</div>
</div>
<!-- Bouton scan -->
<button
@click="triggerScan"
:disabled="loading"
class="px-4 py-2 rounded bg-monokai-cyan text-monokai-bg font-bold hover:bg-monokai-green transition-colors disabled:opacity-50"
>
{{ loading ? 'Scan en cours...' : 'Lancer Scan' }}
</button>
<!-- Indicateur WebSocket -->
<div class="flex items-center gap-2">
<div
:class="[
'w-2 h-2 rounded-full',
wsConnected ? 'bg-monokai-green' : 'bg-monokai-pink'
]"
></div>
<span class="text-sm text-monokai-comment">
{{ wsConnected ? 'Connecté' : 'Déconnecté' }}
</span>
</div>
</div>
</div>
</header>
</template>
<script setup>
import { storeToRefs } from 'pinia'
import { useIPStore } from '@/stores/ipStore'
const ipStore = useIPStore()
const { stats, loading, wsConnected } = storeToRefs(ipStore)
async function triggerScan() {
try {
await ipStore.startScan()
} catch (err) {
console.error('Erreur lancement scan:', err)
}
}
</script>

View File

@@ -0,0 +1,87 @@
<template>
<div
:class="[
'ip-cell-compact',
cellClass,
{ 'selected': isSelected },
{ 'ping-animation': isPinging }
]"
@click="selectThisIP"
:title="getTooltip"
>
<!-- Afficher seulement le dernier octet -->
<div class="font-mono font-bold text-2xl">
{{ lastOctet }}
</div>
<!-- Nom très court si connu -->
<div v-if="ip.name" class="text-xs opacity-75 truncate mt-1">
{{ ip.name }}
</div>
<!-- Indicateur ports ouverts (petit badge) -->
<div v-if="ip.open_ports && ip.open_ports.length > 0"
class="absolute top-1 right-1 w-2 h-2 rounded-full bg-monokai-cyan">
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useIPStore } from '@/stores/ipStore'
const props = defineProps({
ip: {
type: Object,
required: true
},
isPinging: {
type: Boolean,
default: false
}
})
const ipStore = useIPStore()
const { selectedIP } = storeToRefs(ipStore)
const isSelected = computed(() => {
return selectedIP.value?.ip === props.ip.ip
})
// Extraire le dernier octet de l'IP
const lastOctet = computed(() => {
const parts = props.ip.ip.split('.')
return parts[parts.length - 1]
})
// Tooltip avec infos complètes
const getTooltip = computed(() => {
let tooltip = `${props.ip.ip}`
if (props.ip.name) tooltip += ` - ${props.ip.name}`
if (props.ip.hostname) tooltip += `\nHostname: ${props.ip.hostname}`
if (props.ip.mac) tooltip += `\nMAC: ${props.ip.mac}`
if (props.ip.vendor) tooltip += ` (${props.ip.vendor})`
if (props.ip.open_ports && props.ip.open_ports.length > 0) {
tooltip += `\nPorts: ${props.ip.open_ports.join(', ')}`
}
return tooltip
})
const cellClass = computed(() => {
// Déterminer la classe selon l'état (guidelines-css.md)
if (!props.ip.last_status) {
return 'free'
}
if (props.ip.last_status === 'online') {
return props.ip.known ? 'online-known' : 'online-unknown'
} else {
return props.ip.known ? 'offline-known' : 'offline-unknown'
}
})
function selectThisIP() {
ipStore.selectIP(props.ip)
}
</script>

View File

@@ -0,0 +1,207 @@
<template>
<div class="h-full flex flex-col bg-monokai-bg border-r border-monokai-comment">
<!-- Header -->
<div class="p-4 border-b border-monokai-comment">
<h2 class="text-xl font-bold text-monokai-cyan">Détails IP</h2>
</div>
<!-- Contenu -->
<div class="flex-1 overflow-auto p-4">
<div v-if="selectedIP" class="space-y-4">
<!-- Adresse IP -->
<div>
<div class="text-sm text-monokai-comment mb-1">Adresse IP</div>
<div class="text-2xl font-mono font-bold text-monokai-green">
{{ selectedIP.ip }}
</div>
</div>
<!-- État -->
<div>
<div class="text-sm text-monokai-comment mb-1">État</div>
<div class="flex items-center gap-2">
<div
:class="[
'w-3 h-3 rounded-full',
selectedIP.last_status === 'online' ? 'bg-monokai-green' : 'bg-monokai-pink'
]"
></div>
<span class="text-monokai-text capitalize">
{{ selectedIP.last_status || 'Inconnu' }}
</span>
</div>
</div>
<!-- Formulaire d'édition -->
<div class="space-y-3 pt-4 border-t border-monokai-comment">
<!-- Nom -->
<div>
<label class="text-sm text-monokai-comment mb-1 block">Nom</label>
<input
v-model="formData.name"
type="text"
class="w-full px-3 py-2 bg-monokai-bg border border-monokai-comment rounded text-monokai-text"
placeholder="Ex: Serveur Principal"
/>
</div>
<!-- Connue -->
<div>
<label class="flex items-center gap-2 cursor-pointer">
<input
v-model="formData.known"
type="checkbox"
class="form-checkbox"
/>
<span class="text-sm text-monokai-text">IP connue</span>
</label>
</div>
<!-- Localisation -->
<div>
<label class="text-sm text-monokai-comment mb-1 block">Localisation</label>
<input
v-model="formData.location"
type="text"
class="w-full px-3 py-2 bg-monokai-bg border border-monokai-comment rounded text-monokai-text"
placeholder="Ex: Bureau"
/>
</div>
<!-- Type d'hôte -->
<div>
<label class="text-sm text-monokai-comment mb-1 block">Type d'hôte</label>
<input
v-model="formData.host"
type="text"
class="w-full px-3 py-2 bg-monokai-bg border border-monokai-comment rounded text-monokai-text"
placeholder="Ex: PC, Serveur, Imprimante"
/>
</div>
<!-- Boutons -->
<div class="flex gap-2 pt-2">
<button
@click="saveChanges"
class="px-4 py-2 bg-monokai-green text-monokai-bg rounded font-bold hover:bg-monokai-cyan transition-colors"
>
Enregistrer
</button>
<button
@click="resetForm"
class="px-4 py-2 bg-monokai-comment text-monokai-bg rounded hover:bg-monokai-text transition-colors"
>
Annuler
</button>
</div>
</div>
<!-- Informations réseau -->
<div class="pt-4 border-t border-monokai-comment space-y-2">
<h3 class="text-monokai-cyan font-bold mb-2">Informations réseau</h3>
<div v-if="selectedIP.mac">
<div class="text-sm text-monokai-comment">MAC</div>
<div class="text-monokai-text font-mono">{{ selectedIP.mac }}</div>
</div>
<div v-if="selectedIP.vendor">
<div class="text-sm text-monokai-comment">Fabricant</div>
<div class="text-monokai-text">{{ selectedIP.vendor }}</div>
</div>
<div v-if="selectedIP.hostname">
<div class="text-sm text-monokai-comment">Hostname</div>
<div class="text-monokai-text font-mono">{{ selectedIP.hostname }}</div>
</div>
<div v-if="selectedIP.open_ports && selectedIP.open_ports.length > 0">
<div class="text-sm text-monokai-comment">Ports ouverts</div>
<div class="flex flex-wrap gap-1 mt-1">
<span
v-for="port in selectedIP.open_ports"
:key="port"
class="px-2 py-1 bg-monokai-cyan/20 text-monokai-cyan rounded text-xs font-mono"
>
{{ port }}
</span>
</div>
</div>
</div>
<!-- Timestamps -->
<div class="pt-4 border-t border-monokai-comment space-y-2 text-sm">
<div v-if="selectedIP.first_seen">
<span class="text-monokai-comment">Première vue:</span>
<span class="text-monokai-text ml-2">{{ formatDate(selectedIP.first_seen) }}</span>
</div>
<div v-if="selectedIP.last_seen">
<span class="text-monokai-comment">Dernière vue:</span>
<span class="text-monokai-text ml-2">{{ formatDate(selectedIP.last_seen) }}</span>
</div>
</div>
</div>
<!-- Placeholder si rien sélectionné -->
<div v-else class="text-center text-monokai-comment mt-10">
<p>Sélectionnez une IP pour voir les détails</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useIPStore } from '@/stores/ipStore'
const ipStore = useIPStore()
const { selectedIP } = storeToRefs(ipStore)
const formData = ref({
name: '',
known: false,
location: '',
host: ''
})
// Mettre à jour le formulaire quand l'IP change
watch(selectedIP, (newIP) => {
if (newIP) {
formData.value = {
name: newIP.name || '',
known: newIP.known || false,
location: newIP.location || '',
host: newIP.host || ''
}
}
}, { immediate: true })
function resetForm() {
if (selectedIP.value) {
formData.value = {
name: selectedIP.value.name || '',
known: selectedIP.value.known || false,
location: selectedIP.value.location || '',
host: selectedIP.value.host || ''
}
}
}
async function saveChanges() {
if (!selectedIP.value) return
try {
await ipStore.updateIP(selectedIP.value.ip, formData.value)
console.log('IP mise à jour')
} catch (err) {
console.error('Erreur mise à jour IP:', err)
}
}
function formatDate(dateString) {
if (!dateString) return 'N/A'
const date = new Date(dateString)
return date.toLocaleString('fr-FR')
}
</script>

View File

@@ -0,0 +1,79 @@
<template>
<div class="flex flex-col h-full">
<!-- Filtres -->
<div class="bg-monokai-bg border-b border-monokai-comment p-3 flex gap-4 flex-wrap">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="filters.showOnline" class="form-checkbox" />
<span class="text-sm text-monokai-text">En ligne</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="filters.showOffline" class="form-checkbox" />
<span class="text-sm text-monokai-text">Hors ligne</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="filters.showKnown" class="form-checkbox" />
<span class="text-sm text-monokai-text">Connues</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="filters.showUnknown" class="form-checkbox" />
<span class="text-sm text-monokai-text">Inconnues</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="filters.showFree" class="form-checkbox" />
<span class="text-sm text-monokai-text">Libres</span>
</label>
</div>
<!-- Grille d'IPs -->
<div class="flex-1 overflow-auto p-4">
<div class="grid grid-cols-4 gap-3">
<IPCell
v-for="ip in filteredIPs"
:key="ip.ip"
:ip="ip"
/>
</div>
<!-- Message si vide -->
<div v-if="filteredIPs.length === 0" class="text-center text-monokai-comment mt-10">
<p>Aucune IP à afficher</p>
<p class="text-sm mt-2">Ajustez les filtres ou lancez un scan</p>
</div>
</div>
<!-- Légende -->
<div class="bg-monokai-bg border-t border-monokai-comment p-3">
<div class="flex gap-6 text-xs">
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded border-2 border-monokai-green bg-monokai-green/15"></div>
<span class="text-monokai-text">En ligne (connue)</span>
</div>
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded border-2 border-monokai-cyan bg-monokai-cyan/15"></div>
<span class="text-monokai-text">En ligne (inconnue)</span>
</div>
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded border-2 border-dashed border-monokai-pink bg-monokai-pink/10 opacity-50"></div>
<span class="text-monokai-text">Hors ligne (connue)</span>
</div>
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded border-2 border-dashed border-monokai-purple bg-monokai-purple/10 opacity-50"></div>
<span class="text-monokai-text">Hors ligne (inconnue)</span>
</div>
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded border-2 border-monokai-comment bg-monokai-comment/20"></div>
<span class="text-monokai-text">Libre</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { storeToRefs } from 'pinia'
import { useIPStore } from '@/stores/ipStore'
import IPCell from './IPCell.vue'
const ipStore = useIPStore()
const { filteredIPs, filters } = storeToRefs(ipStore)
</script>

View File

@@ -0,0 +1,129 @@
<template>
<div class="flex flex-col h-full">
<!-- Filtres -->
<div class="bg-monokai-bg border-b border-monokai-comment p-3 flex gap-4 flex-wrap">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="filters.showOnline" class="form-checkbox" />
<span class="text-sm text-monokai-text">En ligne</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="filters.showOffline" class="form-checkbox" />
<span class="text-sm text-monokai-text">Hors ligne</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="filters.showKnown" class="form-checkbox" />
<span class="text-sm text-monokai-text">Connues</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="filters.showUnknown" class="form-checkbox" />
<span class="text-sm text-monokai-text">Inconnues</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="filters.showFree" class="form-checkbox" />
<span class="text-sm text-monokai-text">Libres</span>
</label>
</div>
<!-- Organisation en arbre par sous-réseaux -->
<div class="flex-1 overflow-auto p-4">
<div v-for="subnet in organizedSubnets" :key="subnet.name" class="mb-6">
<!-- Header du sous-réseau (style tree) -->
<div class="flex items-center gap-2 mb-3 text-monokai-cyan border-l-4 border-monokai-cyan pl-3">
<span class="font-bold text-lg">{{ subnet.name }}</span>
<span class="text-sm text-monokai-comment">{{ subnet.cidr }}</span>
<span class="text-sm text-monokai-comment ml-auto">
{{ subnet.ips.length }} IPs
({{ subnet.stats.online }} en ligne)
</span>
</div>
<!-- Grille compacte des IPs du sous-réseau -->
<div class="flex flex-wrap gap-2 pl-8">
<IPCell
v-for="ip in subnet.ips"
:key="ip.ip"
:ip="ip"
/>
</div>
</div>
<!-- Message si vide -->
<div v-if="organizedSubnets.length === 0" class="text-center text-monokai-comment mt-10">
<p>Aucune IP à afficher</p>
<p class="text-sm mt-2">Ajustez les filtres ou lancez un scan</p>
</div>
</div>
<!-- Légende -->
<div class="bg-monokai-bg border-t border-monokai-comment p-3">
<div class="flex gap-6 text-xs">
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded border-2 border-monokai-green bg-monokai-green/15"></div>
<span class="text-monokai-text">En ligne (connue)</span>
</div>
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded border-2 border-monokai-cyan bg-monokai-cyan/15"></div>
<span class="text-monokai-text">En ligne (inconnue)</span>
</div>
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded border-2 border-dashed border-monokai-pink bg-monokai-pink/10 opacity-50"></div>
<span class="text-monokai-text">Hors ligne (connue)</span>
</div>
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded border-2 border-dashed border-monokai-purple bg-monokai-purple/10 opacity-50"></div>
<span class="text-monokai-text">Hors ligne (inconnue)</span>
</div>
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded border-2 border-monokai-comment bg-monokai-comment/20"></div>
<span class="text-monokai-text">Libre</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useIPStore } from '@/stores/ipStore'
import IPCell from './IPCell.vue'
const ipStore = useIPStore()
const { filteredIPs, filters } = storeToRefs(ipStore)
// Définition des sous-réseaux (devrait venir de la config mais en dur pour l'instant)
const subnets = [
{ name: 'static_vm', cidr: '10.0.0.0/24', description: 'Machines virtuelles statiques' },
{ name: 'dhcp', cidr: '10.0.1.0/24', description: 'DHCP' },
{ name: 'iot', cidr: '10.0.2.0/24', description: 'IoT' }
]
// Organiser les IPs par sous-réseau
const organizedSubnets = computed(() => {
return subnets.map(subnet => {
// Extraire le préfixe du sous-réseau (ex: "10.0.0" pour 10.0.0.0/24)
const [oct1, oct2, oct3] = subnet.cidr.split('/')[0].split('.')
const prefix = `${oct1}.${oct2}.${oct3}`
// Filtrer les IPs qui appartiennent à ce sous-réseau
const subnetIPs = filteredIPs.value.filter(ip => {
return ip.ip.startsWith(prefix + '.')
})
// Calculer les stats
const stats = {
total: subnetIPs.length,
online: subnetIPs.filter(ip => ip.last_status === 'online').length,
offline: subnetIPs.filter(ip => ip.last_status === 'offline').length
}
return {
name: subnet.name,
cidr: subnet.cidr,
description: subnet.description,
ips: subnetIPs,
stats
}
}).filter(subnet => subnet.ips.length > 0) // Ne montrer que les sous-réseaux avec des IPs
})
</script>

View File

@@ -0,0 +1,119 @@
<template>
<div class="h-full flex flex-col bg-monokai-bg border-l border-monokai-comment">
<!-- Header -->
<div class="p-4 border-b border-monokai-comment">
<h2 class="text-xl font-bold text-monokai-pink">Nouvelles Détections</h2>
</div>
<!-- Liste -->
<div class="flex-1 overflow-auto p-4">
<div v-if="newIPs.length > 0" class="space-y-3">
<div
v-for="ip in newIPs"
:key="ip.ip"
@click="selectIP(ip)"
class="p-3 rounded border-2 border-monokai-pink bg-monokai-pink/10 cursor-pointer hover:bg-monokai-pink/20 transition-colors"
>
<!-- IP -->
<div class="font-mono font-bold text-monokai-text">
{{ ip.ip }}
</div>
<!-- État -->
<div class="text-sm mt-1">
<span
:class="[
'px-2 py-1 rounded text-xs',
ip.last_status === 'online'
? 'bg-monokai-green/20 text-monokai-green'
: 'bg-monokai-comment/20 text-monokai-comment'
]"
>
{{ ip.last_status || 'Inconnu' }}
</span>
<span
v-if="!ip.known"
class="ml-2 px-2 py-1 rounded text-xs bg-monokai-purple/20 text-monokai-purple"
>
Inconnue
</span>
</div>
<!-- MAC et Vendor -->
<div v-if="ip.mac" class="text-xs text-monokai-comment mt-2 font-mono">
{{ ip.mac }}
<span v-if="ip.vendor" class="ml-1">({{ ip.vendor }})</span>
</div>
<!-- Timestamp -->
<div class="text-xs text-monokai-comment mt-2">
{{ formatTime(ip.first_seen) }}
</div>
</div>
</div>
<!-- Placeholder -->
<div v-else class="text-center text-monokai-comment mt-10">
<p>Aucune nouvelle IP détectée</p>
<p class="text-sm mt-2">Les nouvelles IPs apparaîtront ici</p>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useIPStore } from '@/stores/ipStore'
const ipStore = useIPStore()
const { ips } = storeToRefs(ipStore)
// IPs nouvellement détectées (dans les dernières 24h)
const newIPs = computed(() => {
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
return ips.value
.filter(ip => {
if (!ip.first_seen) return false
const firstSeen = new Date(ip.first_seen)
return firstSeen > oneDayAgo
})
.sort((a, b) => {
const dateA = new Date(a.first_seen)
const dateB = new Date(b.first_seen)
return dateB - dateA // Plus récent en premier
})
.slice(0, 20) // Limiter à 20
})
function selectIP(ip) {
ipStore.selectIP(ip)
}
function formatTime(dateString) {
if (!dateString) return ''
const date = new Date(dateString)
const now = new Date()
const diff = now - date
// Moins d'une minute
if (diff < 60000) {
return 'À l\'instant'
}
// Moins d'une heure
if (diff < 3600000) {
const minutes = Math.floor(diff / 60000)
return `Il y a ${minutes} min`
}
// Moins de 24h
if (diff < 86400000) {
const hours = Math.floor(diff / 3600000)
return `Il y a ${hours}h`
}
return date.toLocaleString('fr-FR')
}
</script>

10
frontend/src/main.js Normal file
View File

@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import './assets/main.css'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.mount('#app')

View File

@@ -0,0 +1,230 @@
/**
* Store Pinia pour la gestion des IPs
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import axios from 'axios'
export const useIPStore = defineStore('ip', () => {
// État
const ips = ref([])
const selectedIP = ref(null)
const loading = ref(false)
const error = ref(null)
const stats = ref({
total: 0,
online: 0,
offline: 0,
known: 0,
unknown: 0
})
// Filtres
const filters = ref({
showOnline: true,
showOffline: true,
showKnown: true,
showUnknown: true,
showFree: true
})
// WebSocket
const ws = ref(null)
const wsConnected = ref(false)
// Computed
const filteredIPs = computed(() => {
return ips.value.filter(ip => {
// Filtrer par statut
if (ip.last_status === 'online' && !filters.value.showOnline) return false
if (ip.last_status === 'offline' && !filters.value.showOffline) return false
// Filtrer par connu/inconnu
if (ip.known && !filters.value.showKnown) return false
if (!ip.known && !filters.value.showUnknown) return false
// Filtrer IP libres (pas de last_status)
if (!ip.last_status && !filters.value.showFree) return false
return true
})
})
// Actions
async function fetchIPs() {
loading.value = true
error.value = null
try {
const response = await axios.get('/api/ips/')
ips.value = response.data
await fetchStats()
} catch (err) {
error.value = err.message
console.error('Erreur chargement IPs:', err)
} finally {
loading.value = false
}
}
async function fetchStats() {
try {
const response = await axios.get('/api/ips/stats/summary')
stats.value = response.data
} catch (err) {
console.error('Erreur chargement stats:', err)
}
}
async function updateIP(ipAddress, data) {
try {
const response = await axios.put(`/api/ips/${ipAddress}`, data)
// Mettre à jour dans le store
const index = ips.value.findIndex(ip => ip.ip === ipAddress)
if (index !== -1) {
ips.value[index] = response.data
}
if (selectedIP.value?.ip === ipAddress) {
selectedIP.value = response.data
}
return response.data
} catch (err) {
error.value = err.message
throw err
}
}
async function getIPHistory(ipAddress, hours = 24) {
try {
const response = await axios.get(`/api/ips/${ipAddress}/history?hours=${hours}`)
return response.data
} catch (err) {
console.error('Erreur chargement historique:', err)
throw err
}
}
async function startScan() {
try {
await axios.post('/api/scan/start')
} catch (err) {
error.value = err.message
throw err
}
}
function selectIP(ip) {
selectedIP.value = ip
}
function clearSelection() {
selectedIP.value = null
}
// WebSocket
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${protocol}//${window.location.host}/ws`
ws.value = new WebSocket(wsUrl)
ws.value.onopen = () => {
console.log('WebSocket connecté')
wsConnected.value = true
// Heartbeat toutes les 30s
setInterval(() => {
if (ws.value?.readyState === WebSocket.OPEN) {
ws.value.send('ping')
}
}, 30000)
}
ws.value.onmessage = (event) => {
try {
const message = JSON.parse(event.data)
handleWebSocketMessage(message)
} catch (err) {
console.error('Erreur parsing WebSocket:', err)
}
}
ws.value.onerror = (error) => {
console.error('Erreur WebSocket:', error)
wsConnected.value = false
}
ws.value.onclose = () => {
console.log('WebSocket déconnecté')
wsConnected.value = false
// Reconnexion après 5s
setTimeout(connectWebSocket, 5000)
}
}
function handleWebSocketMessage(message) {
console.log('Message WebSocket:', message)
switch (message.type) {
case 'scan_start':
// Notification début de scan
break
case 'scan_complete':
// Rafraîchir les données après scan
fetchIPs()
stats.value = message.stats
break
case 'ip_update':
// Mise à jour d'une IP
const updatedIP = ips.value.find(ip => ip.ip === message.data.ip)
if (updatedIP) {
Object.assign(updatedIP, message.data)
}
break
case 'new_ip':
// Nouvelle IP détectée
fetchIPs() // Recharger pour être sûr
break
}
}
function disconnectWebSocket() {
if (ws.value) {
ws.value.close()
ws.value = null
wsConnected.value = false
}
}
return {
// État
ips,
selectedIP,
loading,
error,
stats,
filters,
wsConnected,
// Computed
filteredIPs,
// Actions
fetchIPs,
fetchStats,
updateIP,
getIPHistory,
startScan,
selectIP,
clearSelection,
connectWebSocket,
disconnectWebSocket
}
})

View File

@@ -0,0 +1,26 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
// Palette Monokai (guidelines-css.md)
monokai: {
bg: '#272822',
text: '#F8F8F2',
comment: '#75715E',
green: '#A6E22E',
pink: '#F92672',
cyan: '#66D9EF',
purple: '#AE81FF',
yellow: '#E6DB74',
orange: '#FD971F',
},
},
},
},
plugins: [],
}

25
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
},
'/ws': {
target: 'ws://localhost:8080',
ws: true
}
}
}
})

17
guidelines-css.md Normal file
View File

@@ -0,0 +1,17 @@
# guidelines-css.md
## Palette Monokai
- backgrounds : #272822
- text : #F8F8F2
- accents : #A6E22E, #F92672, #66D9EF
## Cases IP
- Couleurs selon état
- Bordure en ligne : solid
- Bordure hors ligne : dashed
- Transparency offline configurable
- Halo ping animé (CSS keyframes)
## Responsive
- grille fluide
- colonnes collapsibles

28
modele-donnees.md Normal file
View File

@@ -0,0 +1,28 @@
# modele-donnees.md
## Tables SQLite
### Table ip
- ip (PK)
- name
- known (bool)
- location
- host
- first_seen
- last_seen
- last_status
- mac
- vendor
- hostname
- open_ports (JSON)
### Table ip_history
- id
- ip (FK)
- timestamp
- status
- open_ports (JSON)
## Index recommandés
- index sur last_status
- index sur ip_history.timestamp

24
prompt-claude-code.md Normal file
View File

@@ -0,0 +1,24 @@
# prompt-claude-code.md
## Rôle
Tu es Claude Code. Tu génères un projet complet backend (FastAPI), frontend (Vue 3), Docker, basé sur les spécifications fournies dans:
- consigne-parametrage.md
- consigne-design_webui.md
- modele-donnees.md
- architecture-technique.md
- workflow-scan.md
- guidelines-css.md
- tests-backend.md
## Objectif
Créer lapplication IPWatch : un scanner réseau WebUI permettant de visualiser les IP libres, connues, inconnues, états réseau, ports ouverts, historique 24h, configuration YAML.
## Livrables
1. Structure complète du projet
2. Code backend FastAPI
3. Modèles SQLAlchemy
4. Tâches de scan (ping, ARP, ports)
5. WebSockets + API REST
6. Frontend Vue 3 + Tailwind
7. Dockerfile + docker-compose
8. Tests backend

20
pytest.ini Normal file
View File

@@ -0,0 +1,20 @@
[pytest]
# Configuration pytest pour IPWatch
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
# Options par défaut
addopts =
-v
--tb=short
--strict-markers
# Markers
markers =
asyncio: marque les tests asynchrones
integration: marque les tests d'intégration
unit: marque les tests unitaires
asyncio_mode = auto

69
start.sh Normal file
View File

@@ -0,0 +1,69 @@
#!/bin/bash
# Script de démarrage rapide IPWatch
set -e
echo "========================================="
echo " IPWatch - Scanner Réseau Temps Réel"
echo "========================================="
echo ""
# Vérifier si Docker est installé
if ! command -v docker &> /dev/null; then
echo "❌ Docker n'est pas installé"
echo "Installez Docker depuis: https://docs.docker.com/get-docker/"
exit 1
fi
# Vérifier si docker-compose est installé
if ! command -v docker compose &> /dev/null; then
echo "❌ docker-compose n'est pas installé"
echo "Installez docker-compose depuis: https://docs.docker.com/compose/install/"
exit 1
fi
# Créer les dossiers nécessaires
echo "📁 Création des dossiers..."
mkdir -p data logs
# Vérifier la config
if [ ! -f config.yaml ]; then
echo "⚠️ config.yaml non trouvé"
echo "Veuillez créer un fichier config.yaml avec votre configuration réseau"
exit 1
fi
# Build de l'image
echo ""
echo "🔨 Construction de l'image Docker..."
docker compose build
# Démarrage
echo ""
echo "🚀 Démarrage d'IPWatch..."
docker compose up -d
# Attendre que le service soit prêt
echo ""
echo "⏳ Attente du démarrage du service..."
sleep 5
# Vérifier l'état
if docker-compose ps | grep -q "Up"; then
echo ""
echo "✅ IPWatch est démarré avec succès!"
echo ""
echo "📊 Accédez à l'interface web:"
echo " 👉 http://localhost:8080"
echo ""
echo "📝 Commandes utiles:"
echo " - Logs: docker-compose logs -f"
echo " - Arrêter: docker-compose down"
echo " - Redémarrer: docker-compose restart"
echo ""
else
echo ""
echo "❌ Erreur lors du démarrage"
echo "Consultez les logs: docker-compose logs"
exit 1
fi

14
tests-backend.md Normal file
View File

@@ -0,0 +1,14 @@
# tests-backend.md
## Tests unitaires
- test_ping()
- test_port_scan()
- test_classification()
- test_sqlalchemy_models()
- test_api_get_ip()
- test_api_update_ip()
- test_scheduler()
## Tests d'intégration
- scan complet réseau simulé
- WebSocket notifications

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Tests IPWatch

123
tests/test_api.py Normal file
View File

@@ -0,0 +1,123 @@
"""
Tests pour les endpoints API
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from backend.app.main import app
from backend.app.core.database import Base, get_db
from backend.app.models.ip import IP
# Setup DB de test
@pytest.fixture
def test_db():
"""Fixture base de données de test"""
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
TestingSessionLocal = sessionmaker(bind=engine)
return TestingSessionLocal
@pytest.fixture
def client(test_db):
"""Fixture client de test"""
def override_get_db():
db = test_db()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
return TestClient(app)
class TestAPIEndpoints:
"""Tests pour les endpoints API"""
def test_root_endpoint(self, client):
"""Test endpoint racine"""
response = client.get("/")
assert response.status_code == 200
data = response.json()
assert "name" in data
assert data["name"] == "IPWatch API"
def test_health_check(self, client):
"""Test health check"""
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert "status" in data
assert data["status"] == "healthy"
def test_get_all_ips_empty(self, client):
"""Test récupération IPs (vide)"""
response = client.get("/api/ips/")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert len(data) == 0
def test_get_stats_empty(self, client):
"""Test stats avec DB vide"""
response = client.get("/api/ips/stats/summary")
assert response.status_code == 200
data = response.json()
assert data["total"] == 0
assert data["online"] == 0
assert data["offline"] == 0
def test_get_ip_not_found(self, client):
"""Test récupération IP inexistante"""
response = client.get("/api/ips/192.168.1.100")
assert response.status_code == 404
def test_update_ip(self, client, test_db):
"""Test mise à jour IP"""
# Créer d'abord une IP
db = test_db()
ip = IP(
ip="192.168.1.100",
name="Test",
known=False,
last_status="online"
)
db.add(ip)
db.commit()
db.close()
# Mettre à jour via API
update_data = {
"name": "Updated Name",
"known": True,
"location": "Bureau"
}
response = client.put("/api/ips/192.168.1.100", json=update_data)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Updated Name"
assert data["known"] is True
assert data["location"] == "Bureau"
def test_delete_ip(self, client, test_db):
"""Test suppression IP"""
# Créer une IP
db = test_db()
ip = IP(ip="192.168.1.101", last_status="online")
db.add(ip)
db.commit()
db.close()
# Supprimer via API
response = client.delete("/api/ips/192.168.1.101")
assert response.status_code == 200
# Vérifier suppression
response = client.get("/api/ips/192.168.1.101")
assert response.status_code == 404

134
tests/test_models.py Normal file
View File

@@ -0,0 +1,134 @@
"""
Tests pour les modèles SQLAlchemy
"""
import pytest
from datetime import datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from backend.app.core.database import Base
from backend.app.models.ip import IP, IPHistory
class TestSQLAlchemyModels:
"""Tests pour les modèles de données"""
@pytest.fixture
def db_session(self):
"""Fixture session DB en mémoire"""
# Créer une DB SQLite en mémoire
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
def test_create_ip(self, db_session):
"""Test création d'une IP"""
ip = IP(
ip="192.168.1.100",
name="Test Server",
known=True,
location="Bureau",
host="Serveur",
last_status="online",
mac="00:11:22:33:44:55",
vendor="Dell",
open_ports=[22, 80, 443]
)
db_session.add(ip)
db_session.commit()
# Vérifier la création
retrieved = db_session.query(IP).filter(IP.ip == "192.168.1.100").first()
assert retrieved is not None
assert retrieved.name == "Test Server"
assert retrieved.known is True
assert retrieved.last_status == "online"
assert len(retrieved.open_ports) == 3
def test_create_ip_history(self, db_session):
"""Test création d'historique IP"""
# Créer d'abord une IP
ip = IP(
ip="192.168.1.101",
last_status="online"
)
db_session.add(ip)
db_session.commit()
# Créer entrée historique
history = IPHistory(
ip="192.168.1.101",
timestamp=datetime.utcnow(),
status="online",
open_ports=[80, 443]
)
db_session.add(history)
db_session.commit()
# Vérifier
retrieved = db_session.query(IPHistory).filter(
IPHistory.ip == "192.168.1.101"
).first()
assert retrieved is not None
assert retrieved.status == "online"
assert len(retrieved.open_ports) == 2
def test_ip_history_relationship(self, db_session):
"""Test relation IP <-> IPHistory"""
# Créer une IP
ip = IP(
ip="192.168.1.102",
last_status="online"
)
db_session.add(ip)
db_session.commit()
# Créer plusieurs entrées historiques
for i in range(5):
history = IPHistory(
ip="192.168.1.102",
status="online" if i % 2 == 0 else "offline",
open_ports=[]
)
db_session.add(history)
db_session.commit()
# Vérifier la relation
ip = db_session.query(IP).filter(IP.ip == "192.168.1.102").first()
assert len(ip.history) == 5
def test_cascade_delete(self, db_session):
"""Test suppression en cascade"""
# Créer IP + historique
ip = IP(ip="192.168.1.103", last_status="online")
db_session.add(ip)
db_session.commit()
history = IPHistory(
ip="192.168.1.103",
status="online",
open_ports=[]
)
db_session.add(history)
db_session.commit()
# Supprimer l'IP
db_session.delete(ip)
db_session.commit()
# Vérifier que l'historique est supprimé aussi
history_count = db_session.query(IPHistory).filter(
IPHistory.ip == "192.168.1.103"
).count()
assert history_count == 0

98
tests/test_network.py Normal file
View File

@@ -0,0 +1,98 @@
"""
Tests unitaires pour les modules réseau
Basé sur tests-backend.md
"""
import pytest
import asyncio
from backend.app.services.network import NetworkScanner
class TestNetworkScanner:
"""Tests pour le scanner réseau"""
@pytest.fixture
def scanner(self):
"""Fixture scanner avec réseau de test"""
return NetworkScanner(cidr="192.168.1.0/24", timeout=1.0)
def test_generate_ip_list(self, scanner):
"""Test génération liste IP depuis CIDR"""
ip_list = scanner.generate_ip_list()
# Vérifier le nombre d'IPs (254 pour un /24)
assert len(ip_list) == 254
# Vérifier format
assert "192.168.1.1" in ip_list
assert "192.168.1.254" in ip_list
assert "192.168.1.0" not in ip_list # Adresse réseau exclue
assert "192.168.1.255" not in ip_list # Broadcast exclu
@pytest.mark.asyncio
async def test_ping(self, scanner):
"""Test fonction ping"""
# Ping localhost (devrait marcher)
result = await scanner.ping("127.0.0.1")
assert result is True
# Ping IP improbable (devrait échouer rapidement)
result = await scanner.ping("192.0.2.1")
assert result is False
@pytest.mark.asyncio
async def test_ping_parallel(self, scanner):
"""Test ping parallélisé"""
ip_list = ["127.0.0.1", "192.0.2.1", "192.0.2.2"]
results = await scanner.ping_parallel(ip_list, max_concurrent=10)
# Vérifier que tous les résultats sont présents
assert len(results) == 3
assert "127.0.0.1" in results
assert results["127.0.0.1"] is True
def test_classification(self, scanner):
"""Test classification d'état IP"""
# IP en ligne + connue
status = scanner.classify_ip_status(is_online=True, is_known=True)
assert status == "online"
# IP hors ligne + connue
status = scanner.classify_ip_status(is_online=False, is_known=True)
assert status == "offline"
# IP en ligne + inconnue
status = scanner.classify_ip_status(is_online=True, is_known=False)
assert status == "online"
# IP hors ligne + inconnue
status = scanner.classify_ip_status(is_online=False, is_known=False)
assert status == "offline"
@pytest.mark.asyncio
async def test_port_scan(self, scanner):
"""Test scan de ports"""
# Scanner des ports communs sur localhost
ports = [22, 80, 443, 9999] # 9999 probablement fermé
open_ports = await scanner.scan_ports("127.0.0.1", ports)
# Au moins vérifier que la fonction retourne une liste
assert isinstance(open_ports, list)
# Tous les ports retournés doivent être dans la liste demandée
for port in open_ports:
assert port in ports
def test_get_mac_vendor(self, scanner):
"""Test lookup fabricant MAC"""
# Tester avec des MACs connus
vendor = scanner._get_mac_vendor("00:0C:29:XX:XX:XX")
assert vendor == "VMware"
vendor = scanner._get_mac_vendor("B8:27:EB:XX:XX:XX")
assert vendor == "Raspberry Pi"
# MAC inconnu
vendor = scanner._get_mac_vendor("AA:BB:CC:DD:EE:FF")
assert vendor == "Unknown"

76
tests/test_scheduler.py Normal file
View File

@@ -0,0 +1,76 @@
"""
Tests pour le scheduler APScheduler
"""
import pytest
import asyncio
from backend.app.services.scheduler import ScanScheduler
class TestScheduler:
"""Tests pour le scheduler"""
@pytest.fixture
def scheduler(self):
"""Fixture scheduler"""
sched = ScanScheduler()
yield sched
if sched.is_running:
sched.stop()
def test_scheduler_start_stop(self, scheduler):
"""Test démarrage/arrêt du scheduler"""
assert scheduler.is_running is False
scheduler.start()
assert scheduler.is_running is True
scheduler.stop()
assert scheduler.is_running is False
def test_add_ping_scan_job(self, scheduler):
"""Test ajout tâche ping scan"""
scheduler.start()
async def dummy_scan():
pass
scheduler.add_ping_scan_job(dummy_scan, interval_seconds=60)
jobs = scheduler.get_jobs()
job_ids = [job.id for job in jobs]
assert 'ping_scan' in job_ids
def test_add_port_scan_job(self, scheduler):
"""Test ajout tâche port scan"""
scheduler.start()
async def dummy_scan():
pass
scheduler.add_port_scan_job(dummy_scan, interval_seconds=300)
jobs = scheduler.get_jobs()
job_ids = [job.id for job in jobs]
assert 'port_scan' in job_ids
def test_remove_job(self, scheduler):
"""Test suppression de tâche"""
scheduler.start()
async def dummy_scan():
pass
scheduler.add_ping_scan_job(dummy_scan, interval_seconds=60)
# Vérifier présence
jobs = scheduler.get_jobs()
assert len(jobs) == 1
# Supprimer
scheduler.remove_job('ping_scan')
# Vérifier absence
jobs = scheduler.get_jobs()
assert len(jobs) == 0

14
workflow-scan.md Normal file
View File

@@ -0,0 +1,14 @@
# workflow-scan.md
## Pipeline
1. Charger configuration YAML
2. Générer liste IP du CIDR
3. Ping (parallélisé)
4. ARP + MAC vendor
5. Port scan selon intervalle
6. Classification état
7. Mise à jour SQLite
8. Détection nouvelles IP
9. Push WebSocket
10. Mise à jour UI