diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..67637eb --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d0e7fe --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..dbd45d6 --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/Capture d’écran du 2025-12-06 04-55-12.png b/Capture d’écran du 2025-12-06 04-55-12.png new file mode 100644 index 0000000..bc0e48b Binary files /dev/null and b/Capture d’écran du 2025-12-06 04-55-12.png differ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4981b05 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..737907e --- /dev/null +++ b/Makefile @@ -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." diff --git a/README.md b/README.md index 3ca65a7..620c5c0 100644 --- a/README.md +++ b/README.md @@ -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 +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. diff --git a/STRUCTURE.md b/STRUCTURE.md new file mode 100644 index 0000000..85486e5 --- /dev/null +++ b/STRUCTURE.md @@ -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 diff --git a/architecture-technique.md b/architecture-technique.md new file mode 100644 index 0000000..6e39658 --- /dev/null +++ b/architecture-technique.md @@ -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 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..f0dd6dd --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# IPWatch Backend Application diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..f340f00 --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1 @@ +# Core configuration modules diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..17e372a --- /dev/null +++ b/backend/app/core/config.py @@ -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() diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..605585b --- /dev/null +++ b/backend/app/core/database.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..d15c4c2 --- /dev/null +++ b/backend/app/main.py @@ -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 + ) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..4df00b5 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,6 @@ +""" +Modèles SQLAlchemy pour IPWatch +""" +from .ip import IP, IPHistory + +__all__ = ["IP", "IPHistory"] diff --git a/backend/app/models/ip.py b/backend/app/models/ip.py new file mode 100644 index 0000000..e325984 --- /dev/null +++ b/backend/app/models/ip.py @@ -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"" + + +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"" + + +# 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) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..a4083fe --- /dev/null +++ b/backend/app/routers/__init__.py @@ -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"] diff --git a/backend/app/routers/ips.py b/backend/app/routers/ips.py new file mode 100644 index 0000000..3c8269f --- /dev/null +++ b/backend/app/routers/ips.py @@ -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 + } diff --git a/backend/app/routers/scan.py b/backend/app/routers/scan.py new file mode 100644 index 0000000..193b33a --- /dev/null +++ b/backend/app/routers/scan.py @@ -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 + } diff --git a/backend/app/routers/websocket.py b/backend/app/routers/websocket.py new file mode 100644 index 0000000..7c6ee50 --- /dev/null +++ b/backend/app/routers/websocket.py @@ -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) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..58d3f97 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1,7 @@ +""" +Services réseau pour IPWatch +""" +from .network import NetworkScanner +from .scheduler import ScanScheduler + +__all__ = ["NetworkScanner", "ScanScheduler"] diff --git a/backend/app/services/network.py b/backend/app/services/network.py new file mode 100644 index 0000000..b3ddf43 --- /dev/null +++ b/backend/app/services/network.py @@ -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 diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py new file mode 100644 index 0000000..855ee76 --- /dev/null +++ b/backend/app/services/scheduler.py @@ -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() diff --git a/backend/app/services/websocket.py b/backend/app/services/websocket.py new file mode 100644 index 0000000..25ea2cb --- /dev/null +++ b/backend/app/services/websocket.py @@ -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() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..204a37c --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..2b11070 --- /dev/null +++ b/config.yaml @@ -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" diff --git a/consigne-design_webui.md b/consigne-design_webui.md new file mode 100644 index 0000000..d6c30d2 --- /dev/null +++ b/consigne-design_webui.md @@ -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 d’IP + 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 diff --git a/consigne-parametrage.md b/consigne-parametrage.md new file mode 100644 index 0000000..3666fea --- /dev/null +++ b/consigne-parametrage.md @@ -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 …) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8f8a17e --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..afb17b7 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + IPWatch - Scanner Réseau + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..aa2e93a --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..8dedbc9 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,48 @@ + + + diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css new file mode 100644 index 0000000..8b93bb3 --- /dev/null +++ b/frontend/src/assets/main.css @@ -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); +} diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue new file mode 100644 index 0000000..65ac69c --- /dev/null +++ b/frontend/src/components/AppHeader.vue @@ -0,0 +1,68 @@ + + + diff --git a/frontend/src/components/IPCell.vue b/frontend/src/components/IPCell.vue new file mode 100644 index 0000000..2bd282a --- /dev/null +++ b/frontend/src/components/IPCell.vue @@ -0,0 +1,87 @@ + + + diff --git a/frontend/src/components/IPDetails.vue b/frontend/src/components/IPDetails.vue new file mode 100644 index 0000000..b7031a1 --- /dev/null +++ b/frontend/src/components/IPDetails.vue @@ -0,0 +1,207 @@ + + + diff --git a/frontend/src/components/IPGrid.vue b/frontend/src/components/IPGrid.vue new file mode 100644 index 0000000..6509575 --- /dev/null +++ b/frontend/src/components/IPGrid.vue @@ -0,0 +1,79 @@ + + + diff --git a/frontend/src/components/IPGridTree.vue b/frontend/src/components/IPGridTree.vue new file mode 100644 index 0000000..4510f0a --- /dev/null +++ b/frontend/src/components/IPGridTree.vue @@ -0,0 +1,129 @@ + + + diff --git a/frontend/src/components/NewDetections.vue b/frontend/src/components/NewDetections.vue new file mode 100644 index 0000000..41bbffc --- /dev/null +++ b/frontend/src/components/NewDetections.vue @@ -0,0 +1,119 @@ + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..414797f --- /dev/null +++ b/frontend/src/main.js @@ -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') diff --git a/frontend/src/stores/ipStore.js b/frontend/src/stores/ipStore.js new file mode 100644 index 0000000..20e9771 --- /dev/null +++ b/frontend/src/stores/ipStore.js @@ -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 + } +}) diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..4b6ee94 --- /dev/null +++ b/frontend/tailwind.config.js @@ -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: [], +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..eca67bb --- /dev/null +++ b/frontend/vite.config.js @@ -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 + } + } + } +}) diff --git a/guidelines-css.md b/guidelines-css.md new file mode 100644 index 0000000..a96f11a --- /dev/null +++ b/guidelines-css.md @@ -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 diff --git a/modele-donnees.md b/modele-donnees.md new file mode 100644 index 0000000..d2d1d26 --- /dev/null +++ b/modele-donnees.md @@ -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 diff --git a/prompt-claude-code.md b/prompt-claude-code.md new file mode 100644 index 0000000..a4b0cf3 --- /dev/null +++ b/prompt-claude-code.md @@ -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 l’application 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 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..0d005ec --- /dev/null +++ b/pytest.ini @@ -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 diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..4914fb3 --- /dev/null +++ b/start.sh @@ -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 diff --git a/tests-backend.md b/tests-backend.md new file mode 100644 index 0000000..9d8f848 --- /dev/null +++ b/tests-backend.md @@ -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 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..a9c5a9b --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests IPWatch diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..4cc82ea --- /dev/null +++ b/tests/test_api.py @@ -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 diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..2fcde53 --- /dev/null +++ b/tests/test_models.py @@ -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 diff --git a/tests/test_network.py b/tests/test_network.py new file mode 100644 index 0000000..8353cab --- /dev/null +++ b/tests/test_network.py @@ -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" diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py new file mode 100644 index 0000000..e5519e2 --- /dev/null +++ b/tests/test_scheduler.py @@ -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 diff --git a/workflow-scan.md b/workflow-scan.md new file mode 100644 index 0000000..593c759 --- /dev/null +++ b/workflow-scan.md @@ -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