diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100755 index 0000000..46e6cdb --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,38 @@ +{ + "permissions": { + "allow": [ + "Bash(find:*)", + "Bash(chmod:*)", + "Bash(tree:*)", + "Bash(sudo lsof:*)", + "Bash(ss:*)", + "Bash(kill:*)", + "Bash(docker compose:*)", + "Bash(curl:*)", + "Bash(docker logs:*)", + "Bash(ip addr:*)", + "Bash(docker exec:*)", + "Bash(for:*)", + "Bash(do echo \"=== Tentative $i ===\")", + "Bash(done)", + "Bash(python3:*)", + "Bash(npm install:*)", + "Bash(docker-compose build:*)", + "Bash(sqlite3:*)", + "WebFetch(domain:docs.opnsense.org)", + "WebFetch(domain:vueflow.dev)", + "WebFetch(domain:deepwiki.com)", + "WebFetch(domain:homenetworkguy.com)", + "WebFetch(domain:github.com)", + "Bash(docker ps:*)", + "Bash(git init:*)", + "Bash(git remote add:*)", + "Bash(git -C /home/gilles/docker/ipwatch fetch origin)", + "Bash(git -C /home/gilles/docker/ipwatch branch -m master main)", + "Bash(git -C /home/gilles/docker/ipwatch reset --soft origin/main)", + "Bash(git -C /home/gilles/docker/ipwatch branch:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100755 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 100755 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 100755 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 100755 index 0000000..bc0e48b Binary files /dev/null and b/Capture d’écran du 2025-12-06 04-55-12.png differ diff --git a/DEPLOIEMENT_REUSSI.md b/DEPLOIEMENT_REUSSI.md new file mode 100644 index 0000000..65b215e --- /dev/null +++ b/DEPLOIEMENT_REUSSI.md @@ -0,0 +1,269 @@ +# ✅ Déploiement de la fonctionnalité de suivi - RÉUSSI + +**Date**: 23 décembre 2025 +**Version**: IPWatch 1.0.1 +**Fonctionnalité**: Suivi d'équipements avec Wake-on-LAN + +--- + +## 📋 Résumé de l'implémentation + +La fonctionnalité de **suivi d'équipements** a été déployée avec succès dans IPWatch. + +### ✅ Modifications backend (FastAPI) + +- [x] Modèle `IP` modifié : ajout du champ `tracked` (boolean) avec index +- [x] Schémas Pydantic mis à jour : `IPUpdate` et `IPResponse` incluent `tracked` +- [x] Nouveau router `/api/tracking/` créé avec 3 endpoints : + - `GET /api/tracking/` - Liste des IPs suivies + - `POST /api/tracking/wol/{ip}` - Wake-on-LAN + - `POST /api/tracking/shutdown/{ip}` - Éteindre (à configurer) +- [x] Migration de base de données exécutée avec succès +- [x] Dépendance `wakeonlan==3.1.0` ajoutée et installée + +### ✅ Modifications frontend (Vue 3 + Router) + +- [x] Vue Router installé (`vue-router@4.2.5`) +- [x] Configuration du routing créée (`/` et `/tracking`) +- [x] Page `MainView.vue` créée (page principale) +- [x] Page `TrackingView.vue` créée (page de suivi) +- [x] Composant `IPDetails.vue` modifié : checkbox "IP suivie" ajoutée +- [x] Composant `AppHeader.vue` modifié : bouton "Suivi" ajouté (jaune/orange) +- [x] `App.vue` transformé en router-view +- [x] `main.js` mis à jour avec le router + +--- + +## 🎯 Utilisation + +### 1. Marquer une IP comme suivie + +1. Cliquez sur une cellule IP dans la grille principale +2. Dans le panneau de gauche, cochez **"IP suivie"** +3. Cliquez sur **"Enregistrer"** + +### 2. Accéder à la page de suivi + +1. Cliquez sur le bouton **"⭐ Suivi"** dans le header (jaune/orange) +2. Vous êtes redirigé vers la page `/tracking` + +### 3. Actions disponibles sur la page de suivi + +Pour chaque équipement suivi : + +- **Bouton WOL (vert)** : Envoie un paquet Magic Packet Wake-on-LAN + - Nécessite une adresse MAC enregistrée + - Désactivé si l'équipement est déjà en ligne + +- **Bouton Éteindre (rose)** : Envoie une commande d'arrêt + - ⚠️ Non implémenté (retourne HTTP 501) + - Nécessite configuration selon votre infrastructure + +- **Bouton Détails (violet)** : Retourne à la page principale avec l'IP sélectionnée + +- **Bouton Rafraîchir** : Actualise la liste des équipements suivis + +--- + +## 🛠️ Configuration Wake-on-LAN + +### Prérequis matériels + +Pour que WOL fonctionne, l'équipement cible doit : + +1. Avoir **Wake-on-LAN activé dans le BIOS/UEFI** +2. Avoir le support WOL activé sur la carte réseau +3. Être branché à l'alimentation (ATX) + +### Test Wake-on-LAN + +```bash +# Depuis le conteneur Docker +docker exec ipwatch python -c " +from wakeonlan import send_magic_packet +send_magic_packet('AA:BB:CC:DD:EE:FF') +print('Paquet WOL envoyé !') +" +``` + +--- + +## 🔍 Vérification du déploiement + +### 1. Vérifier la migration de base de données + +```bash +docker exec ipwatch sqlite3 ./data/db.sqlite "PRAGMA table_info(ip);" | grep tracked +``` + +**Sortie attendue** : +``` +14|tracked|BOOLEAN|0|0|0 +``` + +### 1b. Migration VM (nouveau champ) + +```bash +docker exec ipwatch python -m backend.app.migrations.add_vm_field +docker exec ipwatch sqlite3 ./data/db.sqlite "PRAGMA table_info(ip);" | grep vm +``` + +**Sortie attendue** : +``` +15|vm|BOOLEAN|0|0|0 +``` + +### 2. Vérifier les endpoints API + +```bash +# Liste des IPs suivies +curl http://localhost:8080/api/tracking/ + +# Health check +curl http://localhost:8080/health +``` + +### 3. Vérifier le frontend + +Ouvrez votre navigateur sur `http://localhost:8080` : + +✅ Le bouton **"⭐ Suivi"** est visible dans le header +✅ Cliquer dessus charge la page `/tracking` +✅ La checkbox "IP suivie" est présente dans le panneau de gauche + +--- + +## 📊 Statut du déploiement + +``` +=== Démarrage IPWatch === +✓ Configuration chargée: 10.0.0.0/22 +✓ Base de données initialisée: ./data/db.sqlite +✓ Scheduler démarré +=== IPWatch prêt === + +Migration de base de données: +✓ Colonne 'tracked' ajoutée +✓ Index 'idx_ip_tracked' créé + +Serveur: +✓ Uvicorn running on http://0.0.0.0:8080 +``` + +--- + +## 📂 Fichiers modifiés/créés + +### Backend +``` +backend/app/models/ip.py [MODIFIÉ] +backend/app/routers/ips.py [MODIFIÉ] +backend/app/routers/tracking.py [CRÉÉ] +backend/app/migrations/add_tracked_field.py [CRÉÉ] +backend/app/migrations/__init__.py [CRÉÉ] +backend/app/main.py [MODIFIÉ] +backend/requirements.txt [MODIFIÉ] +``` + +### Frontend +``` +frontend/src/router/index.js [CRÉÉ] +frontend/src/views/MainView.vue [CRÉÉ] +frontend/src/views/TrackingView.vue [CRÉÉ] +frontend/src/components/AppHeader.vue [MODIFIÉ] +frontend/src/components/IPDetails.vue [MODIFIÉ] +frontend/src/App.vue [MODIFIÉ] +frontend/src/main.js [MODIFIÉ] +frontend/package.json [MODIFIÉ] +``` + +### Documentation +``` +SUIVI_EQUIPEMENTS.md [CRÉÉ] +DEPLOIEMENT_REUSSI.md [CE FICHIER] +``` + +--- + +## 🚀 Prochaines étapes + +### Configuration optionnelle : Shutdown + +Pour activer la fonctionnalité d'arrêt, éditez `backend/app/routers/tracking.py` : + +#### Option 1 : SSH (Linux) +```python +pip install paramiko + +import paramiko + +def shutdown_via_ssh(ip_address, username, password): + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(ip_address, username=username, password=password) + ssh.exec_command("sudo shutdown -h now") + ssh.close() +``` + +#### Option 2 : WMI (Windows) +```python +pip install wmi-client-wrapper + +import wmi + +def shutdown_via_wmi(ip_address, username, password): + c = wmi.WMI(computer=ip_address, user=username, password=password) + os = c.Win32_OperatingSystem(Primary=1)[0] + os.Shutdown() +``` + +⚠️ **Sécurité** : Stockez les credentials dans des variables d'environnement, jamais en clair. + +--- + +## 📞 Support + +### Logs du conteneur +```bash +docker logs ipwatch -f +``` + +### Logs de la base de données +```bash +docker exec ipwatch sqlite3 ./data/db.sqlite "SELECT * FROM ip WHERE tracked = 1;" +``` + +### Redémarrage +```bash +docker compose restart ipwatch +``` + +### Rebuild complet +```bash +docker compose build --no-cache +docker compose up -d +``` + +--- + +## ✅ Checklist de validation + +- [x] Migration de base de données exécutée +- [x] Colonne `tracked` présente dans la table `ip` +- [x] Index `idx_ip_tracked` créé +- [x] Conteneur démarré sans erreurs +- [x] Endpoints API `/api/tracking/` accessibles +- [x] Frontend construit et déployé +- [x] Bouton "Suivi" visible dans le header +- [x] Checkbox "IP suivie" présente dans IPDetails +- [x] Navigation vers `/tracking` fonctionnelle +- [x] Page de suivi affiche correctement les équipements +- [x] Boutons WOL, Éteindre, Détails présents +- [x] Dépendance `wakeonlan` installée + +--- + +**🎉 DÉPLOIEMENT RÉUSSI !** + +Votre fonctionnalité de suivi d'équipements est maintenant opérationnelle. +Consultez [SUIVI_EQUIPEMENTS.md](SUIVI_EQUIPEMENTS.md) pour plus d'informations. diff --git a/Dockerfile b/Dockerfile new file mode 100755 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 100755 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 new file mode 100755 index 0000000..620c5c0 --- /dev/null +++ b/README.md @@ -0,0 +1,270 @@ +# 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 100755 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/SUIVI_EQUIPEMENTS.md b/SUIVI_EQUIPEMENTS.md new file mode 100644 index 0000000..3bfcd4a --- /dev/null +++ b/SUIVI_EQUIPEMENTS.md @@ -0,0 +1,294 @@ +# 📊 Fonctionnalité de Suivi d'Équipements - IPWatch + +## Vue d'ensemble + +Cette nouvelle fonctionnalité permet de suivre des équipements spécifiques du réseau avec des actions de gestion à distance (Wake-on-LAN, arrêt). + +## Modifications apportées + +### 🔧 Backend (FastAPI) + +#### 1. Modèle de données +- **Fichier**: `backend/app/models/ip.py` +- **Changement**: Ajout du champ `tracked` (Boolean) avec index dans la table `IP` + +#### 2. Nouveau router tracking +- **Fichier**: `backend/app/routers/tracking.py` +- **Endpoints**: + - `GET /api/tracking/` - Récupère toutes les IPs suivies + - `POST /api/tracking/wol/{ip_address}` - Envoie un paquet Wake-on-LAN + - `POST /api/tracking/shutdown/{ip_address}` - Éteint un équipement (à implémenter selon infrastructure) + - `PATCH /api/tracking/{ip_address}/toggle` - Bascule l'état tracked d'une IP + +#### 3. Migration de base de données +- **Fichier**: `backend/app/migrations/add_tracked_field.py` +- **Commande**: `python -m backend.app.migrations.add_tracked_field` +- **Action**: Ajoute la colonne `tracked` et son index + +#### 4. Dépendances +- **Fichier**: `backend/requirements.txt` +- **Ajout**: `wakeonlan==3.1.0` pour l'envoi de paquets Magic Packet + +### 🎨 Frontend (Vue 3) + +#### 1. Routing +- **Fichier**: `frontend/src/router/index.js` +- **Routes**: + - `/` - Page principale (MainView) + - `/tracking` - Page de suivi des équipements (TrackingView) + +#### 2. Composants modifiés +- **IPDetails.vue**: Ajout d'une checkbox "IP suivie" +- **AppHeader.vue**: Ajout d'un bouton "Suivi" (jaune/orange) +- **App.vue**: Remplacé par `` pour le routing + +#### 3. Nouveaux composants +- **views/MainView.vue**: Page principale (anciennement App.vue) +- **views/TrackingView.vue**: Page de suivi avec grille d'équipements + +#### 4. Dépendances +- **Fichier**: `frontend/package.json` +- **Ajout**: `vue-router@^4.2.5` + +## 📋 Instructions de déploiement + +### Étape 1: Mise à jour du backend + +```bash +# Se placer dans le répertoire backend +cd backend + +# Installer la nouvelle dépendance wakeonlan +pip install -r requirements.txt + +# Exécuter la migration de base de données +python -m backend.app.migrations.add_tracked_field +``` + +**Sortie attendue**: +``` +→ Ajout de la colonne 'tracked' à la table IP... +→ Création de l'index sur 'tracked'... +✓ Migration terminée avec succès! + - Colonne 'tracked' ajoutée + - Index 'idx_ip_tracked' créé +``` + +### Étape 2: Mise à jour du frontend + +```bash +# Se placer dans le répertoire frontend +cd frontend + +# Installer les nouvelles dépendances +npm install + +# Rebuilder le frontend +npm run build +``` + +### Étape 3: Redémarrer l'application + +#### Mode Docker: +```bash +# Reconstruire l'image +docker-compose build + +# Redémarrer le conteneur +docker-compose up -d +``` + +#### Mode développement: +```bash +# Terminal 1 - Backend +cd backend +python -m backend.app.main + +# Terminal 2 - Frontend +cd frontend +npm run dev +``` + +## 🎯 Utilisation + +### 1. Marquer une IP comme suivie + +1. Cliquez sur une cellule IP dans la grille +2. Dans le panneau de gauche (détails), cochez **"IP suivie"** +3. Cliquez sur **"Enregistrer"** + +### 2. Accéder à la page de suivi + +1. Cliquez sur le bouton **"Suivi"** dans le header (jaune/orange avec icône étoile) +2. Vous arrivez sur `/tracking` avec la liste des équipements suivis + +### 3. Actions disponibles + +Pour chaque équipement suivi: + +- **WOL** (bouton vert): Envoie un paquet Wake-on-LAN + - Nécessite une adresse MAC + - Désactivé si l'équipement est déjà en ligne + +- **Éteindre** (bouton rose): Commande d'arrêt + - ⚠️ Nécessite une configuration supplémentaire (voir ci-dessous) + - Désactivé si l'équipement est hors ligne + +- **Détails** (bouton violet): Retourne à la page principale avec l'IP sélectionnée + +## ⚙️ Configuration Wake-on-LAN + +### Prérequis matériels + +Pour que WOL fonctionne, l'équipement cible doit avoir: + +1. **BIOS/UEFI**: Option "Wake-on-LAN" activée +2. **Carte réseau**: Support WOL activé dans les propriétés (Windows) ou via `ethtool` (Linux) +3. **Alimentation**: Alimentation connectée (ATX) + +### Configuration réseau + +Le paquet Magic Packet WOL est envoyé en **broadcast** sur le réseau local. Assurez-vous que: +- Le serveur IPWatch et l'équipement cible sont sur le même réseau local +- Aucun firewall ne bloque les paquets UDP broadcast + +## 🔧 Configuration Shutdown (avancé) + +La fonctionnalité d'arrêt nécessite une configuration selon votre infrastructure: + +### Option 1: SSH (Linux) + +```python +# Dans backend/app/routers/tracking.py +import paramiko + +def shutdown_via_ssh(ip_address, username, password): + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(ip_address, username=username, password=password) + ssh.exec_command("sudo shutdown -h now") + ssh.close() +``` + +**Dépendance**: `pip install paramiko` + +### Option 2: WMI (Windows) + +```python +# Nécessite wmi-client-wrapper +import wmi + +def shutdown_via_wmi(ip_address, username, password): + c = wmi.WMI(computer=ip_address, user=username, password=password) + os = c.Win32_OperatingSystem(Primary=1)[0] + os.Shutdown() +``` + +**Dépendance**: `pip install wmi-client-wrapper` + +### Option 3: API REST (Équipements réseau) + +Pour les équipements réseau (routeurs, switches) avec API REST. + +### ⚠️ Sécurité + +**IMPORTANT**: Stockez les credentials de manière sécurisée: +- Utilisez des variables d'environnement +- Ou un gestionnaire de secrets (Vault, AWS Secrets Manager) +- **JAMAIS en clair dans le code** + +## 🎨 Personnalisation visuelle + +### Couleurs utilisées (Monokai) + +La page de suivi utilise la palette Monokai définie dans `guidelines-css.md`: + +- **Vert** (`#A6E22E`): Équipement en ligne, bouton WOL +- **Rose** (`#F92672`): Équipement hors ligne, bouton Éteindre +- **Jaune** (`#E6DB74`): Bouton "Suivi" dans le header +- **Cyan** (`#66D9EF`): Bouton Rafraîchir +- **Violet** (`#AE81FF`): Bouton Détails + +### Layout + +- **Grille responsive**: 1-4 colonnes selon la taille d'écran +- **Cartes d'équipements**: Bordure dynamique selon l'état (en ligne/hors ligne) +- **Halo lumineux**: Effet de shadow pour les équipements en ligne + +## 🐛 Dépannage + +### Erreur: "La bibliothèque 'wakeonlan' n'est pas installée" + +```bash +pip install wakeonlan==3.1.0 +``` + +### Erreur: "IP non trouvée dans la base de données" + +Assurez-vous que: +1. L'IP existe dans la base (scan réseau effectué) +2. La migration a bien été exécutée + +### WOL ne fonctionne pas + +Vérifiez: +1. L'adresse MAC est bien renseignée pour l'IP +2. L'équipement cible a WOL activé dans le BIOS +3. Le serveur et la cible sont sur le même réseau local + +### Le bouton "Suivi" ne s'affiche pas + +Vérifiez: +1. `npm install` a bien été exécuté +2. Le frontend a été rebuilé (`npm run build`) +3. Le navigateur n'a pas de cache (Ctrl+Shift+R pour forcer le refresh) + +## 📊 Structure des fichiers + +``` +ipwatch/ +├── backend/ +│ ├── app/ +│ │ ├── models/ip.py [MODIFIÉ] +│ │ ├── routers/ +│ │ │ ├── ips.py [MODIFIÉ] +│ │ │ └── tracking.py [NOUVEAU] +│ │ ├── migrations/ +│ │ │ └── add_tracked_field.py [NOUVEAU] +│ │ └── main.py [MODIFIÉ] +│ └── requirements.txt [MODIFIÉ] +│ +├── frontend/ +│ ├── src/ +│ │ ├── router/ +│ │ │ └── index.js [NOUVEAU] +│ │ ├── views/ +│ │ │ ├── MainView.vue [NOUVEAU] +│ │ │ └── TrackingView.vue [NOUVEAU] +│ │ ├── components/ +│ │ │ ├── AppHeader.vue [MODIFIÉ] +│ │ │ └── IPDetails.vue [MODIFIÉ] +│ │ ├── App.vue [MODIFIÉ] +│ │ └── main.js [MODIFIÉ] +│ └── package.json [MODIFIÉ] +│ +└── SUIVI_EQUIPEMENTS.md [CE FICHIER] +``` + +## 🚀 Améliorations futures + +- [ ] Groupes d'équipements (ex: "Serveurs", "Imprimantes") +- [ ] Historique des démarrages/arrêts +- [ ] Notifications push lors de changements d'état +- [ ] Planification horaire (démarrage/arrêt automatique) +- [ ] Graphiques d'uptime +- [ ] Support IPMI/iLO/iDRAC pour serveurs +- [ ] Export CSV/Excel des équipements suivis +- [ ] Tags personnalisés + +## 📞 Support + +Pour toute question ou problème: +1. Consultez les logs du backend: `docker logs ipwatch_backend` +2. Consultez la console du navigateur (F12) +3. Vérifiez que la migration a bien été exécutée diff --git a/amelioration.md b/amelioration.md new file mode 100644 index 0000000..03d344e --- /dev/null +++ b/amelioration.md @@ -0,0 +1,9 @@ +- [x] brainstorming sur les options offerte par l'ajout d'un acces api a mon serveur opnsense => créer un fichier opensense_todo.md avec des listes de taches d'amelioration a ajouter dans une section brainstorming. on pourra ensuite les deplacer si selectionner dans section todo, done +- [x] analyse et brainstorming sur l'onglet architecture . amelioration via une integration de reactflow ? autre framewaork similaire. creation d'un fichier amelioration_onglet_architecture.md pour integrer les resultat du brainstorming et lister les amelioration a realiser dans section todo +- [ ] ajouter acces via api a un serveur opnsense 10.0.0.1: +key=ZOwL1iuko13l9tnARvJlU0s93C/44gFwZNRmStRhzGV8u6m2nXAcoOAbb6jxtkEe8dqzIjj4zECcKdzI +secret=rMOGHY+3SRfiT7cxpMoGZuwnPPRX0vPHV2oDTn6UPCvH87UXJe1qBkTs8y/ryG942TsTGe5UYO6F7fXK +integrer les option dans config.yml et accessible egalement dans parametre +- [ ] ajouter un bouton dans volet gauche pour ajouter le parametrage d'un equipement dans opensense mappage static. possibilite d'ajouter des mappages avec des ip differentes de la plage de dhcp dans opnsense ? +- [ ] ajout backup de la bdd dans parametre +- [ ] brainstorming ajout d'un onglet opnsense qui presente des parametrages claire des services actif et des paramaetrage disponible (style tableau de bord) avec des tooltips explicatif clair, une section logs et erreur \ No newline at end of file diff --git a/amelioration_onglet_architecture.md b/amelioration_onglet_architecture.md new file mode 100644 index 0000000..24eddaf --- /dev/null +++ b/amelioration_onglet_architecture.md @@ -0,0 +1,168 @@ +# Amelioration Onglet Architecture - Brainstorming & Todo + +## Analyse de l'existant + +### Etat actuel (ArchitectureView.vue - 1543 lignes) +L'onglet Architecture est un editeur visuel de diagrammes reseau avec : + +**Fonctionnel :** +- Canvas SVG avec drag-drop d'objets depuis une palette (7 types : world, home, computer, network, room, vm, service) +- Deplacement et redimensionnement des objets +- Systeme de connecteurs (4 cotes, 0-8 par cote) +- Splines (courbes de liaison) avec points de controle editables +- Relations parent-enfant (logiques, non visuelles) +- Integration IP : sync avec l'API pour recuperer les donnees reseau +- Panneau de proprietes complet (60+ proprietes editables) +- Persistence JSON (`data/architecture.json`) +- Edition inline de labels, verrouillage d'objets + +**Limitations actuelles :** +- Pas d'imbrication visuelle (enfants non positionnes relativement au parent) +- Pas de zoom/pan natif (pas de navigation fluide dans grands diagrammes) +- Pas de minimap pour vue d'ensemble +- Pas de undo/redo +- Pas de multi-selection / group operations +- Pas de snap-to-grid ou alignement automatique +- Pas de bibliotheque d'objets reutilisables +- Pas de multi-diagrammes +- Code monolithique (1543 lignes dans un seul composant) +- Performance limitee au-dela de 100+ objets (pas de virtualisation) +- Splines basiques (quadratiques, pas de routage intelligent) + +--- + +## Brainstorming : Vue Flow vs implementation actuelle + +### Option A : Migrer vers Vue Flow (@vue-flow/core) + +**Qu'est-ce que Vue Flow ?** +- Port Vue 3 de ReactFlow (reference React pour les node-based UIs) +- Librairie mature : v1.48.2, activement maintenue, 79+ projets dependants +- Site : https://vueflow.dev | GitHub : https://github.com/bcakmakoglu/vue-flow + +**Fonctionnalites cles :** +- Zoom et pan fluides (natifs, optimises) +- Minimap integree (vue d'ensemble en bas a droite) +- Controls integres (zoom in/out/fit, panel en bas a gauche) +- Background configurable (dots, lines, cross) +- Nodes personnalisables (Custom Nodes = composants Vue complets) +- Edges personnalisables (Custom Edges = SVG custom) +- Handles (points de connexion sur les nodes, equivalent des connecteurs actuels) +- Drag-and-drop natif +- Selection simple et multiple +- Evenements riches (onConnect, onNodeDrag, onEdgeUpdate, etc.) +- State management integre (useVueFlow composable) +- Types de nodes inclus : default, input, output +- Types d'edges inclus : bezier, straight, step, smoothstep +- Sub-flows (nodes imbriques dans d'autres nodes = groupes) +- Snap-to-grid +- Performant (virtualisation des nodes hors viewport) + +**Avantages pour IPWatch :** +| Fonctionnalite | Actuel (custom SVG) | Avec Vue Flow | +|----------------|---------------------|---------------| +| Zoom/Pan | Non | Natif, fluide | +| Minimap | Non | Composant inclus | +| Snap-to-grid | Non | Option native | +| Sub-flows (groupes) | Non | Nodes imbriques | +| Multi-selection | Non | Natif | +| Performance 100+ nodes | Problematique | Virtualisation | +| Routing edges | Quadratique simple | Bezier, step, smoothstep, custom | +| Undo/Redo | Non | Via plugin/composable | +| Maintenance | 1543 lignes custom | Communaute active | + +**Inconvenients / Risques :** +- Migration significative : recrire la logique d'affichage +- Les connecteurs actuels (multi-connecteurs par cote) devront etre adaptes aux "Handles" de Vue Flow +- Les splines avec points de controle editables sont plus avancees que les edges standard de Vue Flow (mais possible via Custom Edges) +- Le panneau de proprietes devra etre refait (mais c'est l'occasion de l'ameliorer) +- Dependance a une librairie tierce (risque de maintenance) +- Courbe d'apprentissage pour l'API Vue Flow + +### Option B : Ameliorer l'implementation actuelle + +**Avantages :** +- Pas de migration, evolution progressive +- Controle total sur le code +- Deja fonctionnel pour le cas d'usage actuel + +**Inconvenients :** +- Reimplementer zoom/pan/minimap/snap = beaucoup de travail +- Maintenance lourde du code custom SVG +- Performance difficile a optimiser sans virtualisation + +### Option C : Approche hybride + +Utiliser Vue Flow pour le canvas principal (zoom, pan, nodes, edges, minimap) tout en conservant : +- Le panneau de proprietes actuel (adapte) +- La palette d'outils actuelle +- Le systeme de persistence JSON existant +- L'integration IP existante + +**C'est l'option recommandee.** Vue Flow gere le "moteur graphique" et IPWatch garde le controle sur la logique metier. + +--- + +### Autres frameworks evalues + +| Framework | Type | Vue 3 | Avantages | Inconvenients | +|-----------|------|-------|-----------|---------------| +| **Vue Flow** | Node graph | Natif | Complet, actif, ReactFlow-like | Courbe d'apprentissage | +| **Cytoscape.js** | Graphe generique | Wrapper | Tres puissant, algorithmes de layout | Lourd, API complexe, pas Vue-natif | +| **D3.js** | Bas niveau | Non | Ultra-flexible | Enorme effort d'implementation | +| **Drawflow** | Node editor | Oui | Simple | Trop basique, peu maintenu | +| **JointJS** | Diagrammes | Non | Professionnel | Payant (rapperd), pas Vue | +| **GoJS** | Diagrammes | Non | Tres complet | Payant, pas Vue-natif | +| **jsPlumb** | Connexions | Wrapper | Bon pour les connexions | Pas de gestion de nodes | +| **Mermaid** | Diagrammes texte | Non | Declaratif | Pas interactif | + +**Verdict : Vue Flow est le meilleur choix pour IPWatch** - natif Vue 3, fonctionnalites proches de l'existant, communaute active. + +--- + +## Ameliorations identifiees + +### Phase 1 : Migration vers Vue Flow (fondations) +- [ ] Installer `@vue-flow/core`, `@vue-flow/minimap`, `@vue-flow/controls`, `@vue-flow/background` +- [ ] Creer un composant `ArchitectureCanvas.vue` base sur Vue Flow +- [ ] Adapter le format de donnees `architecture.json` (items → nodes, splines → edges) +- [ ] Ecrire un adaptateur de migration pour convertir les donnees existantes +- [ ] Implementer les Custom Nodes pour chaque type (world, home, computer, network, room, vm, service) +- [ ] Implementer les Custom Edges pour reproduire le style des splines actuelles +- [ ] Activer minimap, controls et background +- [ ] Conserver le panneau de proprietes (adapte a la nouvelle structure) + +### Phase 2 : Nouvelles fonctionnalites Vue Flow +- [ ] Zoom/Pan fluide avec raccourcis clavier (Ctrl+scroll, espace+drag) +- [ ] Snap-to-grid configurable +- [ ] Multi-selection (Shift+click, rectangle de selection) +- [ ] Sub-flows : grouper des nodes dans un node parent (imbrication visuelle) +- [ ] Undo/Redo (Ctrl+Z / Ctrl+Y) +- [ ] Copier/Coller de nodes +- [ ] Auto-layout : algorithme de positionnement automatique (dagre, elkjs) + +### Phase 3 : Ameliorations UX +- [ ] Bibliotheque d'objets : sauvegarder et reutiliser des configurations de nodes +- [ ] Templates de diagrammes predéfinis (reseau maison, datacenter, etc.) +- [ ] Export en image (PNG/SVG) du diagramme +- [ ] Mode lecture seule (partage de vue) +- [ ] Recherche de nodes (par nom, IP, type) +- [ ] Filtrage par type de node (afficher/masquer categories) +- [ ] Legende dynamique + +### Phase 4 : Integrations avancees +- [ ] Sync temps reel avec les scans reseau (nodes changent de couleur selon l'etat online/offline) +- [ ] Affichage du trafic sur les edges (epaisseur proportionnelle au trafic via OPNsense API) +- [ ] Integration OPNsense : noeud firewall avec interfaces et regles +- [ ] Multi-diagrammes : gestion de plusieurs vues (physique, logique, par etage, par VLAN) +- [ ] Animation des flux reseau sur les edges + +--- + +## Todo + +*(deplacer ici les elements valides a realiser)* + +## Done + +*(deplacer ici les elements termines)* diff --git a/architecture-technique.md b/architecture-technique.md new file mode 100755 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/architecture.md b/architecture.md new file mode 100644 index 0000000..c460fd8 --- /dev/null +++ b/architecture.md @@ -0,0 +1,588 @@ +# Architecture réseau (draft) +Date: 2025-12-25 + +## Vision générale +- Modèle principal: boîtes imbriquées (composition) + liaisons (graph). +- Objectifs: représenter la topologie physique/logique et les relations réseau. + +## Composition (boîtes imbriquées) +- Exemple de hiérarchie: + - World (Internet) + - Maison + - Livebox + - Switch + - Borne Wi‑Fi + - Laptop + - Smartphone + - Server Proxmox + - VM Debian + - Docker + - Service + - Raspberry Pi + - Service(s) + - IoT Hub + - Service(s) + +## Liaisons (graph) +- Les boîtes peuvent être reliées entre elles indépendamment de la hiérarchie. +- Exemples: + - Service -> Docker -> VM -> Server -> Switch -> Livebox -> Maison -> World + - AP (Wi‑Fi) -> Laptop / Smartphone +- Style des liaisons: + - LAN: trait plein + - Wi‑Fi: trait pointillé + +## Ports +- Nombre de ports ajustable. +- Plusieurs connexions possibles sur un même port. + +## Objets composés +- Un objet peut contenir des enfants. +- Enfants positionnés par rapport au parent (coordonnées locales). +- Types possibles d’enfants: icône, forme, texte, badge, objet complet. + +## Redimensionnement +- Les conteneurs sont redimensionnables pour inclure d’autres objets. +- À décider: + - Taille des enfants figée ou proportionnelle au parent lors du resize. + +## Modèle de données (à préciser) +- Node (id, type, label, size, children[], ports[]) +- Port (id, label, capacity) +- Edge (from, to, kind: LAN/Wi‑Fi) + +## Modèle d’objet (draft) +- Parent optionnel + enfants multiples. +- Types d’objets: rectangle, icône, image, table, texte, lien, commande, connecteur. +- Propriétés communes: + - id, name, parentId, children[] + - category + - position: x, y (locales au parent), zIndex + - size: width, height + - rotation: degrés + - keep_ratio: true | false + - visible + - anchor: free | edge, edgeSide: 1|2|3|4 + - style: fillColor, strokeColor, opacity + - font: family, size, weight (normal|bold), style (normal|italic), color + - state: libre | accroché | en bordure + - command: texte/commande associée (optionnel) + - connecteurs: liste d’IDs d’objets connecteur + +### Image +- formats: png, jpg, svg, webp +- size: width, height +- anchor: position d’accrochage +- state: libre | accroché | en bordure +- storage: fichier sur disque + chemin (`src`) dans JSON/DB (pas d’image brute) +- displayName: nom lisible optionnel +- naming: conserver le nom lisible + suffixe unique (ex: `livebox_7f3a.png`) +- upload: + - création d’un nouveau fichier avec métadonnées + - réduction de poids possible à l’upload et après (optimize) +- library: + - images ajoutées dans une bibliothèque + - metadata category obligatoire + +### Table +- size: width, height +- position: x, y (locales au parent) +- parentId, children[] +- grid: rows, cols +- headers: title1, title2, title3... +- style spécifique: + - borderColor, fillColor, headerColor, textColor + - fontWeight (normal|bold), fontStyle (normal|italic), fontSize, fontFamily +- anchor: x, y, zIndex +- connecteurs: liste d’IDs d’objets connecteur + +### Connecteur +- Objet à part entière: + - id, name, parentId, children[] + - endpoints: connecteur1 -> targetId, connecteur2 -> targetId + +## Exemple Mermaid (boîtes imbriquées + liaisons) +```mermaid +flowchart TB + %% Boîtes imbriquées + subgraph World["World (Internet)"] + subgraph Maison["Maison"] + Livebox["Livebox"] + Switch["Switch"] + subgraph Server["Server Proxmox"] + VM["VM Debian"] + subgraph Docker["Docker"] + Service["Service"] + end + end + Laptop["Laptop"] + Smartphone["Smartphone"] + end + end + + %% Liaisons (réseau/logique) + Service --> Docker + Docker --> VM + VM --> Server + Server --> Switch + Switch --> Livebox + Livebox --> Maison + Maison --> World +``` + +## Exemple JSON (draft) +```json +{ + "nodes": [ + { + "id": "world", + "type": "rectangle", + "name": "World", + "parentId": null, + "children": ["maison"], + "position": { "x": 0, "y": 0 }, + "zIndex": 0, + "visible": true, + "anchor": "free", + "edgeSide": null, + "style": { + "fillColor": "#2B2D2A", + "strokeColor": "#8F8F7A", + "opacity": 1 + }, + "font": { + "family": "Space Grotesk", + "size": 14, + "weight": "bold", + "style": "normal", + "color": "#E6DB74" + }, + "state": "libre", + "command": null, + "connecteurs": [] + }, + { + "id": "maison", + "type": "rectangle", + "name": "Maison", + "parentId": "world", + "children": ["livebox", "server"], + "position": { "x": 40, "y": 40 }, + "zIndex": 1, + "visible": true, + "anchor": "free", + "edgeSide": null, + "style": { + "fillColor": "#3A3D39", + "strokeColor": "#A6E22E", + "opacity": 0.95 + }, + "font": { + "family": "Space Grotesk", + "size": 12, + "weight": "bold", + "style": "normal", + "color": "#E6DB74" + }, + "state": "libre", + "command": null, + "connecteurs": [] + }, + { + "id": "livebox", + "type": "image", + "name": "Livebox", + "category": "network", + "parentId": "maison", + "children": [], + "position": { "x": 20, "y": 30 }, + "size": { "width": 48, "height": 48 }, + "rotation": 0, + "keepRatio": true, + "zIndex": 2, + "visible": true, + "anchor": "free", + "edgeSide": null, + "style": { + "fillColor": "#272822", + "strokeColor": "#F92672", + "opacity": 1 + }, + "font": { + "family": "Space Grotesk", + "size": 11, + "weight": "normal", + "style": "normal", + "color": "#F8F8F2" + }, + "state": "libre", + "command": null, + "connecteurs": ["c-livebox"], + "image": { + "format": "svg", + "src": "livebox.svg", + "displayName": "Livebox", + "category": "network", + "sizeBytes": 12456, + "width": 48, + "height": 48 + } + }, + { + "id": "server", + "type": "rectangle", + "name": "Server Proxmox", + "parentId": "maison", + "children": ["vm-debian"], + "position": { "x": 140, "y": 30 }, + "zIndex": 2, + "visible": true, + "anchor": "free", + "edgeSide": null, + "style": { + "fillColor": "#2F3130", + "strokeColor": "#66D9EF", + "opacity": 1 + }, + "font": { + "family": "Space Grotesk", + "size": 11, + "weight": "bold", + "style": "normal", + "color": "#E6DB74" + }, + "state": "libre", + "command": "ssh root@server", + "connecteurs": ["c-server"] + }, + { + "id": "vm-debian", + "type": "rectangle", + "name": "VM Debian", + "parentId": "server", + "children": ["docker"], + "position": { "x": 16, "y": 36 }, + "zIndex": 3, + "visible": true, + "anchor": "free", + "edgeSide": null, + "style": { + "fillColor": "#1F2A2A", + "strokeColor": "#A6E22E", + "opacity": 1 + }, + "font": { + "family": "Space Grotesk", + "size": 10, + "weight": "normal", + "style": "normal", + "color": "#E6DB74" + }, + "state": "libre", + "command": null, + "connecteurs": [] + }, + { + "id": "docker", + "type": "rectangle", + "name": "Docker", + "parentId": "vm-debian", + "children": ["service-web"], + "position": { "x": 12, "y": 28 }, + "zIndex": 4, + "visible": true, + "anchor": "free", + "edgeSide": null, + "style": { + "fillColor": "#263238", + "strokeColor": "#F8F8F2", + "opacity": 1 + }, + "font": { + "family": "Space Grotesk", + "size": 10, + "weight": "normal", + "style": "normal", + "color": "#F8F8F2" + }, + "state": "libre", + "command": "docker ps", + "connecteurs": [] + }, + { + "id": "service-web", + "type": "text", + "name": "Service Web", + "parentId": "docker", + "children": [], + "position": { "x": 10, "y": 18 }, + "zIndex": 5, + "visible": true, + "anchor": "free", + "edgeSide": null, + "style": { + "fillColor": "transparent", + "strokeColor": "transparent", + "opacity": 1 + }, + "font": { + "family": "Space Grotesk", + "size": 10, + "weight": "normal", + "style": "italic", + "color": "#A6E22E" + }, + "state": "libre", + "command": null, + "connecteurs": ["c-service"] + } + ], + "connecteurs": [ + { + "id": "c-livebox", + "name": "LAN Livebox", + "parentId": "livebox", + "children": [], + "endpoints": [ + { "name": "c1", "targetId": "c-server" }, + { "name": "c2", "targetId": "world" } + ] + }, + { + "id": "c-server", + "name": "LAN Server", + "parentId": "server", + "children": [], + "endpoints": [ + { "name": "c1", "targetId": "c-livebox" }, + { "name": "c2", "targetId": "c-service" } + ] + }, + { + "id": "c-service", + "name": "Service Link", + "parentId": "service-web", + "children": [], + "endpoints": [ + { "name": "c1", "targetId": "c-server" }, + { "name": "c2", "targetId": "docker" } + ] + } + ], + "edges": [ + { "from": "c-livebox", "to": "c-server", "kind": "lan" }, + { "from": "c-livebox", "to": "world", "kind": "lan" } + ] +} +``` + +## UML (tables SQLite - draft) +```mermaid +classDiagram +class diagram { + +id TEXT + +name TEXT + +created_at TEXT + +updated_at TEXT + +version TEXT +} +class node { + +id TEXT + +diagram_id TEXT + +type TEXT + +name TEXT + +category TEXT + +parent_id TEXT + +x REAL + +y REAL + +width REAL + +height REAL + +rotation REAL + +keep_ratio INTEGER + +z_index INTEGER + +visible INTEGER + +anchor TEXT + +edge_side INTEGER + +state TEXT +} +class node_style { + +node_id TEXT + +fill_color TEXT + +stroke_color TEXT + +opacity REAL + +font_family TEXT + +font_size REAL + +font_weight TEXT + +font_style TEXT + +font_color TEXT +} +class node_command { + +node_id TEXT + +command_text TEXT +} +class node_media { + +node_id TEXT + +media_type TEXT + +src TEXT + +format TEXT + +display_name TEXT + +category TEXT + +size_bytes INTEGER + +width REAL + +height REAL +} +class node_text { + +node_id TEXT + +content TEXT +} +class node_link { + +node_id TEXT + +url TEXT + +label TEXT +} +class node_table { + +node_id TEXT + +rows INTEGER + +cols INTEGER + +width REAL + +height REAL + +header_color TEXT + +header_text_color TEXT + +border_color TEXT + +cell_color TEXT + +font_family TEXT + +font_size REAL + +font_weight TEXT + +font_style TEXT +} +class node_table_header { + +node_id TEXT + +col_index INTEGER + +title TEXT +} +class connector { + +id TEXT + +diagram_id TEXT + +name TEXT + +parent_id TEXT + +x REAL + +y REAL + +z_index INTEGER + +visible INTEGER +} +class connector_endpoint { + +id TEXT + +connector_id TEXT + +name TEXT + +target_id TEXT +} +class edge { + +id TEXT + +diagram_id TEXT + +from_id TEXT + +to_id TEXT + +kind TEXT + +style TEXT + +label TEXT + +color TEXT +} +class port { + +id TEXT + +node_id TEXT + +name TEXT + +capacity INTEGER + +x REAL + +y REAL + +anchor TEXT + +edge_side INTEGER +} + +diagram "1" --> "many" node +diagram "1" --> "many" connector +diagram "1" --> "many" edge +node "1" --> "0..1" node : parent +node "1" --> "0..1" node_style +node "1" --> "0..1" node_command +node "1" --> "0..1" node_media +node "1" --> "0..1" node_text +node "1" --> "0..1" node_link +node "1" --> "0..1" node_table +node "1" --> "many" node_table_header +node "1" --> "many" port +connector "1" --> "many" connector_endpoint +``` + +## Frontend (UI - draft) +- Panneau gauche: **Édition** (mode édition activable). + - Palette d’objets: carré, rectangle, rond, ovale, pilule, image, texte, table, commande. + - Liaisons: ligne, connexion, flèche, spline. + - Boutons: + - Supprimer un objet du monde (avec choix de cascade): + - supprimer les enfants + - relier les enfants au grand‑parent + - Importer un objet (bibliothèque) + enfants (JSON). +- Bibliothèque: + - stockage local (JSON) + - export/import possible +- Panneau droit: **Propriétés** (ex‑Actions). + - Propriétés organisées par sections (plus lisible). + - Parent/enfants configurables depuis Propriétés. + - Image: afficher poids + catégorie. + - Afficher rotation + dimensions (avec poignées de resize/rotation activables). +- Barre d’outils dépliable (outils courants): + - couper, copier, coller, supprimer, cloner + - palette couleurs (trait / intérieur) + - police (liste déroulante) + taille (boutons + / -) + - zoom (loupe + / -) + - épaisseur de trait + +## Stockage des ressources (arborescence proposée) +``` +architecture/ + backend/ + app/ + diagrams/ + world.json + database/ + resources/ + toolbars/ + editbar.json + prop_bar.json + outils.json + images/ + originals/ + optimized/ + thumbs/ + police/ + palettes/ + commandes/ + library/ + objects.json + simple_objects.json + images.json + tables.json + commands.json + palettes.json + fonts.json + exports/ + imports/ +``` +Descriptions: +- backend/: API + logique serveur pour l’éditeur. +- app/: code UI + logique front. +- diagrams/: fichiers de diagrammes (world.json, etc.). +- database/: base SQLite locale. +- resources/toolbars/: définitions des barres d’outils. +- resources/images/: images sources, optimisées, miniatures. +- resources/police/: polices disponibles. +- formats acceptés: ttf (support initial), woff/woff2 possibles plus tard. +- resources/palettes/: palettes de couleurs. +- resources/commandes/: commandes prêtes à l’emploi. +- library/: bibliothèque d’objets réutilisables. +- exports/: exports (JSON, images, etc.). +- imports/: imports. +- Les deux volets sont repliés par défaut; un bouton en haut permet de les déplier. +- Drag & drop depuis Édition vers l’arbre réseau: + - crée un nouvel objet dans le monde avec paramètres par défaut. +- Mode lecture: + - édition désactivée = pas de déplacement ni de modification. diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100755 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 100755 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 100755 index 0000000..65f5579 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,167 @@ +""" +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 + ping_count: int = 1 # Nombre de ping par IP + port_scan_interval: int = 300 # secondes + parallel_pings: int = 50 + timeout: float = 1.0 + force_vendor_update: bool = False + + +class PortsConfig(BaseModel): + """Configuration des ports à scanner""" + ranges: List[str] = ["22", "80", "443", "3389", "8080"] + protocols: Optional[Dict[int, str]] = None # Mapping port -> protocole + + +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 + cell_size: int = 30 + font_size: int = 10 + cell_gap: float = 2 + details_font_size: int = 13 + details_spacing: int = 2 + architecture_title_font_size: int = 18 + + +class LinksConfig(BaseModel): + """Configuration des liens""" + hardware_bench_url: Optional[str] = None + + +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" + mac_changed: str = "#FD971F" + network_device: str = "#1E3A8A" + + +class OPNsenseConfig(BaseModel): + """Configuration OPNsense API""" + enabled: bool = False + host: str = "" + api_key: str = "" + api_secret: str = "" + verify_ssl: bool = False + protocol: str = "http" # "http" ou "https" + + +class DatabaseConfig(BaseModel): + """Configuration base de données""" + path: str = "./data/db.sqlite" + + +class SubnetConfig(BaseModel): + """Configuration d'un sous-réseau""" + name: str + cidr: str + start: str + end: str + description: str + + +class HostConfig(BaseModel): + """Configuration d'un hôte avec sa localisation""" + name: str + location: str + ip: Optional[str] = None + ip_parent: Optional[str] = None + ip_enfant: Optional[List[str]] = None + + +class IPWatchConfig(BaseModel): + """Configuration complète IPWatch""" + model_config = {"arbitrary_types_allowed": True} + + app: AppConfig = Field(default_factory=AppConfig) + network: NetworkConfig + subnets: List[SubnetConfig] = Field(default_factory=list) + 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[HostConfig] = Field(default_factory=list) + history: HistoryConfig = Field(default_factory=HistoryConfig) + ui: UIConfig = Field(default_factory=UIConfig) + links: LinksConfig = Field(default_factory=LinksConfig) + colors: ColorsConfig = Field(default_factory=ColorsConfig) + database: DatabaseConfig = Field(default_factory=DatabaseConfig) + opnsense: OPNsenseConfig = Field(default_factory=OPNsenseConfig) + + +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) + self._config_path = config_path + return self._config + + def reload_config(self) -> IPWatchConfig: + """Recharge la configuration depuis le fichier""" + if not hasattr(self, '_config_path'): + self._config_path = "./config.yaml" + return self.load_config(self._config_path) + + @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 100755 index 0000000..4eea41a --- /dev/null +++ b/backend/app/core/database.py @@ -0,0 +1,101 @@ +""" +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 (DB principale) +Base = declarative_base() +# Base dédiée à l'architecture +ArchBase = declarative_base() + +# Engine et session +engine = None +SessionLocal = None +arch_engine = None +ArchSessionLocal = 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) + + # Migration : ajouter les colonnes manquantes + _run_migrations(engine) + + return engine + + +def _run_migrations(eng): + """Ajoute les colonnes manquantes aux tables existantes""" + import sqlalchemy + inspector = sqlalchemy.inspect(eng) + + # Migration de la table 'ip' + if 'ip' in inspector.get_table_names(): + existing_columns = {col['name'] for col in inspector.get_columns('ip')} + with eng.connect() as conn: + if 'dhcp_synced' not in existing_columns: + conn.execute(sqlalchemy.text("ALTER TABLE ip ADD COLUMN dhcp_synced BOOLEAN DEFAULT 0")) + conn.commit() + print("✓ Migration: colonne dhcp_synced ajoutée à la table ip") + + +def init_architecture_database(db_path: str = "./architecture/database/architecture.sqlite"): + """Initialise la connexion à la base de données d'architecture""" + global arch_engine, ArchSessionLocal + + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + + database_url = f"sqlite:///{db_path}" + arch_engine = create_engine( + database_url, + connect_args={"check_same_thread": False}, + echo=False + ) + + ArchSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=arch_engine) + + # Créer les tables d'architecture si besoin + ArchBase.metadata.create_all(bind=arch_engine) + + return arch_engine + + +def get_db(): + """Dependency pour obtenir une session DB""" + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def get_arch_db(): + """Dependency pour obtenir une session DB architecture""" + if ArchSessionLocal is None: + init_architecture_database() + db = ArchSessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100755 index 0000000..c4332e1 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,221 @@ +""" +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.routers import architecture as architecture_router +from backend.app.routers import config as config_router +from backend.app.routers import system as system_router +from backend.app.routers import tracking as tracking_router +from backend.app.routers import opnsense as opnsense_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.include_router(config_router.router) +app.include_router(system_router.router) +app.include_router(tracking_router.router) +app.include_router(architecture_router.router) +app.include_router(opnsense_router.router) + +# Servir les ressources d'architecture +architecture_dir = Path("./architecture") +architecture_dir.mkdir(parents=True, exist_ok=True) +app.mount("/architecture", StaticFiles(directory=str(architecture_dir)), name="architecture") + + +@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") + + # Monter les icônes partagées + icons_dir = Path("./data/icons") + icons_dir.mkdir(parents=True, exist_ok=True) + app.mount("/icons", StaticFiles(directory=str(icons_dir)), name="icons") + + # Servir les fichiers statiques à la racine (favicon, manifest, etc.) + @app.get("/favicon.ico") + async def serve_favicon(): + favicon_path = frontend_dist / "favicon.ico" + if favicon_path.exists(): + return FileResponse(favicon_path) + return {"error": "Favicon non trouvée"} + + # 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 les fichiers statiques à la racine si présents + if ".." not in full_path: + candidate = (frontend_dist / full_path).resolve() + if frontend_dist in candidate.parents and candidate.is_file(): + return FileResponse(candidate) + + # 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/migrations/__init__.py b/backend/app/migrations/__init__.py new file mode 100644 index 0000000..39e48a5 --- /dev/null +++ b/backend/app/migrations/__init__.py @@ -0,0 +1,3 @@ +""" +Module de migrations pour la base de données IPWatch +""" diff --git a/backend/app/migrations/add_architecture_node_table.py b/backend/app/migrations/add_architecture_node_table.py new file mode 100644 index 0000000..eda77da --- /dev/null +++ b/backend/app/migrations/add_architecture_node_table.py @@ -0,0 +1,57 @@ +""" +Script de migration pour ajouter la table architecture_node +Exécuter avec: python -m backend.app.migrations.add_architecture_node_table +""" +from sqlalchemy import text, create_engine +import sys +import os + + +def migrate(): + try: + db_path = os.getenv('DB_PATH', './data/db.sqlite') + db_url = f"sqlite:///{db_path}" + engine = create_engine(db_url, echo=False) + + with engine.connect() as conn: + result = conn.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name='architecture_node'")) + if result.fetchone(): + print("✓ Table 'architecture_node' existe déjà") + return + + print("→ Création de la table 'architecture_node'...") + conn.execute(text(""" + CREATE TABLE architecture_node ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + x INTEGER NOT NULL, + y INTEGER NOT NULL, + width INTEGER NOT NULL, + height INTEGER NOT NULL, + rotation INTEGER NOT NULL, + payload TEXT NOT NULL, + created_at DATETIME NOT NULL + ) + """)) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_arch_node_created_at ON architecture_node(created_at)")) + conn.commit() + print("✓ Table 'architecture_node' créée") + + except Exception as e: + print(f"✗ Erreur migration architecture_node: {str(e)}", file=sys.stderr) + sys.exit(1) + + +def rollback(): + try: + print("⚠ Rollback non implémenté pour SQLite") + except Exception as e: + print(f"✗ Erreur rollback: {str(e)}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "rollback": + rollback() + else: + migrate() diff --git a/backend/app/migrations/add_hardware_bench_field.py b/backend/app/migrations/add_hardware_bench_field.py new file mode 100644 index 0000000..a107cf3 --- /dev/null +++ b/backend/app/migrations/add_hardware_bench_field.py @@ -0,0 +1,62 @@ +""" +Script de migration pour ajouter le champ 'hardware_bench' à la table IP +Exécuter avec: python -m backend.app.migrations.add_hardware_bench_field +""" +from sqlalchemy import text, create_engine +import sys +import os + + +def migrate(): + """Ajoute la colonne 'hardware_bench' et son index à la table IP""" + try: + db_path = os.getenv('DB_PATH', './data/db.sqlite') + db_url = f"sqlite:///{db_path}" + + engine = create_engine(db_url, echo=False) + + with engine.connect() as conn: + result = conn.execute(text("PRAGMA table_info(ip)")) + columns = [row[1] for row in result] + + if 'hardware_bench' in columns: + print("✓ La colonne 'hardware_bench' existe déjà dans la table IP") + return + + print("→ Ajout de la colonne 'hardware_bench' à la table IP...") + conn.execute(text("ALTER TABLE ip ADD COLUMN hardware_bench BOOLEAN DEFAULT 0")) + + print("→ Création de l'index sur 'hardware_bench'...") + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_ip_hardware_bench ON ip(hardware_bench)")) + + conn.commit() + print("✓ Migration terminée avec succès!") + print(" - Colonne 'hardware_bench' ajoutée") + print(" - Index 'idx_ip_hardware_bench' créé") + + except Exception as e: + print(f"✗ Erreur lors de la migration: {str(e)}", file=sys.stderr) + sys.exit(1) + + +def rollback(): + """Supprime la colonne 'hardware_bench' (rollback de la migration)""" + try: + db_path = os.getenv('DB_PATH', './data/db.sqlite') + db_url = f"sqlite:///{db_path}" + engine = create_engine(db_url, echo=False) + + with engine.connect() as conn: + print("⚠ Rollback non implémenté pour SQLite") + print(" Pour annuler, restaurez une sauvegarde de la base de données") + + except Exception as e: + print(f"✗ Erreur lors du rollback: {str(e)}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "rollback": + rollback() + else: + migrate() diff --git a/backend/app/migrations/add_icon_fields.py b/backend/app/migrations/add_icon_fields.py new file mode 100644 index 0000000..4967d02 --- /dev/null +++ b/backend/app/migrations/add_icon_fields.py @@ -0,0 +1,54 @@ +""" +Script de migration pour ajouter les champs 'icon_filename' et 'icon_url' à la table IP +Exécuter avec: python -m backend.app.migrations.add_icon_fields +""" +from sqlalchemy import text, create_engine +import sys +import os + + +def migrate(): + try: + db_path = os.getenv('DB_PATH', './data/db.sqlite') + db_url = f"sqlite:///{db_path}" + engine = create_engine(db_url, echo=False) + + with engine.connect() as conn: + result = conn.execute(text("PRAGMA table_info(ip)")) + columns = [row[1] for row in result] + + if 'icon_filename' not in columns: + print("→ Ajout de la colonne 'icon_filename'...") + conn.execute(text("ALTER TABLE ip ADD COLUMN icon_filename TEXT")) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_ip_icon_filename ON ip(icon_filename)")) + else: + print("✓ Colonne 'icon_filename' déjà présente") + + if 'icon_url' not in columns: + print("→ Ajout de la colonne 'icon_url'...") + conn.execute(text("ALTER TABLE ip ADD COLUMN icon_url TEXT")) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_ip_icon_url ON ip(icon_url)")) + else: + print("✓ Colonne 'icon_url' déjà présente") + + conn.commit() + print("✓ Migration terminée avec succès") + + except Exception as e: + print(f"✗ Erreur lors de la migration: {str(e)}", file=sys.stderr) + sys.exit(1) + + +def rollback(): + try: + print("⚠ Rollback non implémenté pour SQLite") + except Exception as e: + print(f"✗ Erreur lors du rollback: {str(e)}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "rollback": + rollback() + else: + migrate() diff --git a/backend/app/migrations/add_network_device_field.py b/backend/app/migrations/add_network_device_field.py new file mode 100644 index 0000000..0d6c6a2 --- /dev/null +++ b/backend/app/migrations/add_network_device_field.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +""" +Migration: Ajouter le champ network_device à la table ip +""" +import os +import sys +from sqlalchemy import text, create_engine + +def main(): + """Ajoute le champ network_device à la table ip""" + # Récupérer le chemin de la base de données + db_path = os.getenv('DB_PATH', './data/db.sqlite') + db_url = f"sqlite:///{db_path}" + + # Créer l'engine directement + engine = create_engine(db_url, echo=False) + + print(f"📦 Migration: Ajout du champ network_device") + print(f"🗄️ Base de données: {db_path}") + + try: + with engine.connect() as conn: + # Vérifier si la colonne existe déjà + result = conn.execute(text("PRAGMA table_info(ip)")) + columns = [row[1] for row in result] + + if 'network_device' in columns: + print("⚠️ La colonne 'network_device' existe déjà. Migration ignorée.") + return + + # Ajouter la colonne network_device + print("➕ Ajout de la colonne 'network_device'...") + conn.execute(text(""" + ALTER TABLE ip + ADD COLUMN network_device BOOLEAN DEFAULT 0 + """)) + + # Créer un index sur la colonne + print("🔍 Création de l'index sur 'network_device'...") + conn.execute(text(""" + CREATE INDEX IF NOT EXISTS idx_ip_network_device ON ip(network_device) + """)) + + conn.commit() + print("✅ Migration réussie!") + + except Exception as e: + print(f"❌ Erreur lors de la migration: {e}") + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/backend/app/migrations/add_scan_log_table.py b/backend/app/migrations/add_scan_log_table.py new file mode 100644 index 0000000..2e0524d --- /dev/null +++ b/backend/app/migrations/add_scan_log_table.py @@ -0,0 +1,54 @@ +""" +Script de migration pour ajouter la table scan_log +Exécuter avec: python -m backend.app.migrations.add_scan_log_table +""" +from sqlalchemy import text, create_engine +import sys +import os + + +def migrate(): + try: + db_path = os.getenv('DB_PATH', './data/db.sqlite') + db_url = f"sqlite:///{db_path}" + engine = create_engine(db_url, echo=False) + + with engine.connect() as conn: + result = conn.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name='scan_log'")) + if result.fetchone(): + print("✓ Table 'scan_log' existe déjà") + return + + print("→ Création de la table 'scan_log'...") + conn.execute(text(""" + CREATE TABLE scan_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip TEXT, + status TEXT, + message TEXT NOT NULL, + created_at DATETIME NOT NULL + ) + """)) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_scan_log_created_at ON scan_log(created_at)")) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_scan_log_ip ON scan_log(ip)")) + conn.commit() + print("✓ Table 'scan_log' créée") + + except Exception as e: + print(f"✗ Erreur migration scan_log: {str(e)}", file=sys.stderr) + sys.exit(1) + + +def rollback(): + try: + print("⚠ Rollback non implémenté pour SQLite") + except Exception as e: + print(f"✗ Erreur rollback: {str(e)}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "rollback": + rollback() + else: + migrate() diff --git a/backend/app/migrations/add_tracked_field.py b/backend/app/migrations/add_tracked_field.py new file mode 100644 index 0000000..b2c6b3a --- /dev/null +++ b/backend/app/migrations/add_tracked_field.py @@ -0,0 +1,70 @@ +""" +Script de migration pour ajouter le champ 'tracked' à la table IP +Exécuter avec: python -m backend.app.migrations.add_tracked_field +""" +from sqlalchemy import text, create_engine +import sys +import os + + +def migrate(): + """Ajoute la colonne 'tracked' et son index à la table IP""" + try: + # Charger le chemin de la base de données depuis config.yaml ou utiliser le défaut + db_path = os.getenv('DB_PATH', './data/db.sqlite') + db_url = f"sqlite:///{db_path}" + + # Créer l'engine + engine = create_engine(db_url, echo=False) + + with engine.connect() as conn: + # Vérifier si la colonne existe déjà + result = conn.execute(text("PRAGMA table_info(ip)")) + columns = [row[1] for row in result] + + if 'tracked' in columns: + print("✓ La colonne 'tracked' existe déjà dans la table IP") + return + + # Ajouter la colonne tracked + print("→ Ajout de la colonne 'tracked' à la table IP...") + conn.execute(text("ALTER TABLE ip ADD COLUMN tracked BOOLEAN DEFAULT 0")) + + # Créer l'index + print("→ Création de l'index sur 'tracked'...") + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_ip_tracked ON ip(tracked)")) + + conn.commit() + print("✓ Migration terminée avec succès!") + print(" - Colonne 'tracked' ajoutée") + print(" - Index 'idx_ip_tracked' créé") + + except Exception as e: + print(f"✗ Erreur lors de la migration: {str(e)}", file=sys.stderr) + sys.exit(1) + + +def rollback(): + """Supprime la colonne 'tracked' (rollback de la migration)""" + try: + # Charger le chemin de la base de données depuis config.yaml ou utiliser le défaut + db_path = os.getenv('DB_PATH', './data/db.sqlite') + db_url = f"sqlite:///{db_path}" + engine = create_engine(db_url, echo=False) + + with engine.connect() as conn: + # SQLite ne supporte pas DROP COLUMN directement + # Il faut recréer la table sans la colonne + print("⚠ Rollback non implémenté pour SQLite") + print(" Pour annuler, restaurez une sauvegarde de la base de données") + + except Exception as e: + print(f"✗ Erreur lors du rollback: {str(e)}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "rollback": + rollback() + else: + migrate() diff --git a/backend/app/migrations/add_vm_field.py b/backend/app/migrations/add_vm_field.py new file mode 100644 index 0000000..e996037 --- /dev/null +++ b/backend/app/migrations/add_vm_field.py @@ -0,0 +1,70 @@ +""" +Script de migration pour ajouter le champ 'vm' à la table IP +Exécuter avec: python -m backend.app.migrations.add_vm_field +""" +from sqlalchemy import text, create_engine +import sys +import os + + +def migrate(): + """Ajoute la colonne 'vm' et son index à la table IP""" + try: + # Charger le chemin de la base de données depuis config.yaml ou utiliser le défaut + db_path = os.getenv('DB_PATH', './data/db.sqlite') + db_url = f"sqlite:///{db_path}" + + # Créer l'engine + engine = create_engine(db_url, echo=False) + + with engine.connect() as conn: + # Vérifier si la colonne existe déjà + result = conn.execute(text("PRAGMA table_info(ip)")) + columns = [row[1] for row in result] + + if 'vm' in columns: + print("✓ La colonne 'vm' existe déjà dans la table IP") + return + + # Ajouter la colonne vm + print("→ Ajout de la colonne 'vm' à la table IP...") + conn.execute(text("ALTER TABLE ip ADD COLUMN vm BOOLEAN DEFAULT 0")) + + # Créer l'index + print("→ Création de l'index sur 'vm'...") + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_ip_vm ON ip(vm)")) + + conn.commit() + print("✓ Migration terminée avec succès!") + print(" - Colonne 'vm' ajoutée") + print(" - Index 'idx_ip_vm' créé") + + except Exception as e: + print(f"✗ Erreur lors de la migration: {str(e)}", file=sys.stderr) + sys.exit(1) + + +def rollback(): + """Supprime la colonne 'vm' (rollback de la migration)""" + try: + # Charger le chemin de la base de données depuis config.yaml ou utiliser le défaut + db_path = os.getenv('DB_PATH', './data/db.sqlite') + db_url = f"sqlite:///{db_path}" + engine = create_engine(db_url, echo=False) + + with engine.connect() as conn: + # SQLite ne supporte pas DROP COLUMN directement + # Il faut recréer la table sans la colonne + print("⚠ Rollback non implémenté pour SQLite") + print(" Pour annuler, restaurez une sauvegarde de la base de données") + + except Exception as e: + print(f"✗ Erreur lors du rollback: {str(e)}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "rollback": + rollback() + else: + migrate() diff --git a/backend/app/migrations/create_architecture_db.py b/backend/app/migrations/create_architecture_db.py new file mode 100644 index 0000000..370ef6d --- /dev/null +++ b/backend/app/migrations/create_architecture_db.py @@ -0,0 +1,60 @@ +""" +Script pour créer la base SQLite dédiée à l'architecture +Exécuter avec: python -m backend.app.migrations.create_architecture_db +""" +from sqlalchemy import text, create_engine +import sys +import os +from pathlib import Path + + +def migrate(): + try: + db_path = os.getenv("ARCH_DB_PATH", "./architecture/database/architecture.sqlite") + db_file = Path(db_path) + db_file.parent.mkdir(parents=True, exist_ok=True) + db_url = f"sqlite:///{db_file}" + engine = create_engine(db_url, echo=False) + + with engine.connect() as conn: + result = conn.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name='architecture_node'")) + if result.fetchone(): + print("✓ Table 'architecture_node' existe déjà") + return + + print("→ Création de la table 'architecture_node'...") + conn.execute(text(""" + CREATE TABLE architecture_node ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + x INTEGER NOT NULL, + y INTEGER NOT NULL, + width INTEGER NOT NULL, + height INTEGER NOT NULL, + rotation INTEGER NOT NULL, + payload TEXT NOT NULL, + created_at DATETIME NOT NULL + ) + """)) + conn.execute(text("CREATE INDEX IF NOT EXISTS idx_arch_node_created_at ON architecture_node(created_at)")) + conn.commit() + print(f"✓ Base architecture créée: {db_file}") + + except Exception as e: + print(f"✗ Erreur création base architecture: {str(e)}", file=sys.stderr) + sys.exit(1) + + +def rollback(): + try: + print("⚠ Rollback non implémenté pour SQLite") + except Exception as e: + print(f"✗ Erreur rollback: {str(e)}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "rollback": + rollback() + else: + migrate() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100755 index 0000000..7e513a1 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,8 @@ +""" +Modèles SQLAlchemy pour IPWatch +""" +from .ip import IP, IPHistory +from .scan_log import ScanLog +from .architecture import ArchitectureNode + +__all__ = ["IP", "IPHistory", "ScanLog", "ArchitectureNode"] diff --git a/backend/app/models/architecture.py b/backend/app/models/architecture.py new file mode 100644 index 0000000..79b4f87 --- /dev/null +++ b/backend/app/models/architecture.py @@ -0,0 +1,22 @@ +""" +Modèles SQLAlchemy pour l'éditeur d'architecture +""" +from datetime import datetime +from sqlalchemy import Column, String, Integer, DateTime, Text + +from backend.app.core.database import ArchBase + + +class ArchitectureNode(ArchBase): + """Noeud d'architecture sauvegardé""" + __tablename__ = "architecture_node" + + id = Column(String, primary_key=True, index=True) + type = Column(String, nullable=False) + x = Column(Integer, nullable=False, default=0) + y = Column(Integer, nullable=False, default=0) + width = Column(Integer, nullable=False, default=50) + height = Column(Integer, nullable=False, default=50) + rotation = Column(Integer, nullable=False, default=0) + payload = Column(Text, nullable=False, default="{}") + created_at = Column(DateTime, default=datetime.utcnow) diff --git a/backend/app/models/ip.py b/backend/app/models/ip.py new file mode 100755 index 0000000..8b81187 --- /dev/null +++ b/backend/app/models/ip.py @@ -0,0 +1,95 @@ +""" +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 + tracked = Column(Boolean, default=False, index=True) # IP suivie pour monitoring + vm = Column(Boolean, default=False, index=True) # Machine virtuelle + network_device = Column(Boolean, default=False, index=True) # Équipement réseau (switch, routeur, borne WiFi) + hardware_bench = Column(Boolean, default=False, index=True) # Lien hardware bench disponible + location = Column(String, nullable=True) # Localisation (ex: "Bureau", "Serveur") + host = Column(String, nullable=True) # Type d'hôte (ex: "PC", "Imprimante") + ip_parent = Column(String, nullable=True) # IP parent liée (relation logique) + ip_enfant = Column(JSON, default=list) # Liste d'IPs enfants (JSON) + + # Timestamps + first_seen = Column(DateTime, default=datetime.now) # Première détection + last_seen = Column(DateTime, default=datetime.now, onupdate=datetime.now) # 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 + link = Column(String, nullable=True) # Lien personnalisé (URL) + mac_changed = Column(Boolean, default=False) # MAC address différente de celle attendue + icon_filename = Column(String, nullable=True) # Icône associée (fichier dans /data/icons) + icon_url = Column(String, nullable=True) # Lien associé à l'icône + + # Ports ouverts (stocké en JSON) + open_ports = Column(JSON, default=list) # Liste des ports ouverts + + # Synchronisation DHCP OPNsense + dhcp_synced = Column(Boolean, default=False) # Réservation DHCP créée dans Kea + + # 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.now, 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/models/scan_log.py b/backend/app/models/scan_log.py new file mode 100644 index 0000000..8910cc0 --- /dev/null +++ b/backend/app/models/scan_log.py @@ -0,0 +1,25 @@ +""" +Historique détaillé des scans (logs par IP) +""" +from sqlalchemy import Column, Integer, String, DateTime, Index +from datetime import datetime +from backend.app.core.database import Base + + +class ScanLog(Base): + """ + Table de logs des scans réseau + """ + __tablename__ = "scan_log" + + id = Column(Integer, primary_key=True, autoincrement=True) + ip = Column(String, index=True, nullable=True) + status = Column(String, nullable=True) + message = Column(String, nullable=False) + created_at = Column(DateTime, default=datetime.now, index=True, nullable=False) + + def __repr__(self): + return f"" + + +Index('idx_scan_log_created_at', ScanLog.created_at) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100755 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/architecture.py b/backend/app/routers/architecture.py new file mode 100644 index 0000000..7ca09ba --- /dev/null +++ b/backend/app/routers/architecture.py @@ -0,0 +1,132 @@ +""" +Endpoints API pour l'éditeur d'architecture +""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from pydantic import BaseModel +from typing import List, Optional, Dict, Any +from uuid import uuid4 +from datetime import datetime +import json +from pathlib import Path + +from backend.app.core.database import get_arch_db +from backend.app.models.architecture import ArchitectureNode + +router = APIRouter(prefix="/api/architecture", tags=["Architecture"]) +DATA_DIR = Path(__file__).resolve().parents[3] / "data" +WORLD_FILE = DATA_DIR / "architecture.json" + + +class ArchitectureNodeCreate(BaseModel): + id: Optional[str] = None + type: str + x: int + y: int + width: int + height: int + rotation: int = 0 + payload: Dict[str, Any] + + +class ArchitectureNodeResponse(BaseModel): + id: str + type: str + x: int + y: int + width: int + height: int + rotation: int + payload: Dict[str, Any] + created_at: datetime + + class Config: + from_attributes = True + + +class ArchitectureWorldPayload(BaseModel): + items: List[Dict[str, Any]] + splines: Optional[List[Dict[str, Any]]] = None + + +@router.get("/nodes", response_model=List[ArchitectureNodeResponse]) +async def list_nodes(db: Session = Depends(get_arch_db)): + """Liste tous les noeuds d'architecture""" + nodes = db.query(ArchitectureNode).order_by(ArchitectureNode.created_at.asc()).all() + results = [] + for node in nodes: + try: + payload = json.loads(node.payload or "{}") + except json.JSONDecodeError: + payload = {} + results.append(ArchitectureNodeResponse( + id=node.id, + type=node.type, + x=node.x, + y=node.y, + width=node.width, + height=node.height, + rotation=node.rotation, + payload=payload, + created_at=node.created_at + )) + return results + + +@router.post("/nodes", response_model=ArchitectureNodeResponse) +async def create_node(payload: ArchitectureNodeCreate, db: Session = Depends(get_arch_db)): + """Créer un noeud d'architecture""" + node_id = payload.id or str(uuid4()) + node = ArchitectureNode( + id=node_id, + type=payload.type, + x=payload.x, + y=payload.y, + width=payload.width, + height=payload.height, + rotation=payload.rotation, + payload=json.dumps(payload.payload) + ) + db.add(node) + db.commit() + db.refresh(node) + return ArchitectureNodeResponse( + id=node.id, + type=node.type, + x=node.x, + y=node.y, + width=node.width, + height=node.height, + rotation=node.rotation, + payload=payload.payload, + created_at=node.created_at + ) + + +def ensure_world_file() -> None: + DATA_DIR.mkdir(parents=True, exist_ok=True) + if not WORLD_FILE.exists(): + WORLD_FILE.write_text(json.dumps({"items": [], "splines": []}, indent=2), encoding="utf-8") + + +@router.get("/world") +async def get_world(): + """Charge le fichier architecture.json, le crée si absent.""" + ensure_world_file() + try: + data = json.loads(WORLD_FILE.read_text(encoding="utf-8")) + except json.JSONDecodeError: + data = {"items": [], "splines": []} + return data + + +@router.post("/world") +async def save_world(payload: ArchitectureWorldPayload): + """Sauvegarde les éléments du world dans architecture.json.""" + ensure_world_file() + splines = payload.splines or [] + WORLD_FILE.write_text( + json.dumps({"items": payload.items, "splines": splines}, indent=2), + encoding="utf-8" + ) + return {"status": "ok", "count": len(payload.items), "splines": len(splines)} diff --git a/backend/app/routers/config.py b/backend/app/routers/config.py new file mode 100755 index 0000000..4d8c518 --- /dev/null +++ b/backend/app/routers/config.py @@ -0,0 +1,73 @@ +""" +Routes pour la configuration +""" +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +import yaml +from backend.app.core.config import config_manager + +router = APIRouter(prefix="/api/config", tags=["config"]) + +@router.get("/ui") +async def get_ui_config(): + """Récupérer la configuration UI""" + config = config_manager.config + return { + "cell_size": config.ui.cell_size, + "font_size": config.ui.font_size, + "cell_gap": config.ui.cell_gap, + "offline_transparency": config.ui.offline_transparency, + "show_mac": config.ui.show_mac, + "show_vendor": config.ui.show_vendor, + "architecture_title_font_size": config.ui.architecture_title_font_size + } + +@router.post("/reload") +async def reload_config(): + """Recharger la configuration depuis le fichier config.yaml""" + try: + config = config_manager.reload_config() + return { + "success": True, + "message": "Configuration rechargée avec succès", + "ui": { + "cell_size": config.ui.cell_size, + "font_size": config.ui.font_size, + "cell_gap": config.ui.cell_gap, + "offline_transparency": config.ui.offline_transparency, + "show_mac": config.ui.show_mac, + "show_vendor": config.ui.show_vendor, + "architecture_title_font_size": config.ui.architecture_title_font_size + } + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Erreur rechargement config: {str(e)}") + + +class UIConfigUpdate(BaseModel): + architecture_title_font_size: int + + +@router.post("/ui") +async def update_ui_config(payload: UIConfigUpdate): + """Mettre à jour la configuration UI""" + config_path = "./config.yaml" + try: + with open(config_path, "r", encoding="utf-8") as f: + yaml_data = yaml.safe_load(f) or {} + + if "ui" not in yaml_data or yaml_data["ui"] is None: + yaml_data["ui"] = {} + + yaml_data["ui"]["architecture_title_font_size"] = int(payload.architecture_title_font_size) + + with open(config_path, "w", encoding="utf-8") as f: + yaml.safe_dump(yaml_data, f, allow_unicode=True, sort_keys=False) + + config = config_manager.reload_config() + return { + "message": "Configuration UI mise à jour", + "architecture_title_font_size": config.ui.architecture_title_font_size + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Erreur mise à jour config UI: {str(e)}") diff --git a/backend/app/routers/ips.py b/backend/app/routers/ips.py new file mode 100755 index 0000000..818df70 --- /dev/null +++ b/backend/app/routers/ips.py @@ -0,0 +1,665 @@ +""" +Endpoints API pour la gestion des IPs +""" +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from sqlalchemy.orm import Session +from sqlalchemy import desc +from typing import List, Optional +from datetime import datetime, timedelta +import xml.etree.ElementTree as ET +import yaml +from pathlib import Path +import re +import time +import urllib.request + +from backend.app.core.database import get_db +from backend.app.models.ip import IP, IPHistory +from backend.app.core.config import config_manager +from pydantic import BaseModel + +router = APIRouter(prefix="/api/ips", tags=["IPs"]) + +ICONS_DIR = Path("./data/icons") +ALLOWED_ICON_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".svg"} +OUI_URL = "https://standards-oui.ieee.org/oui/oui.txt" +OUI_PATH = Path("./data/oui/oui.txt") + + +def _sanitize_filename(filename: str) -> str: + name = Path(filename).name + name = re.sub(r"[^A-Za-z0-9._-]+", "_", name) + if not name or name in {".", ".."}: + return f"icon_{int(time.time())}.png" + if "." not in name: + return f"{name}.png" + return name + + +@router.get("/oui/status") +async def oui_status(): + """ + Statut du fichier OUI local + """ + if not OUI_PATH.exists(): + return {"exists": False, "updated_at": None} + updated_at = datetime.fromtimestamp(OUI_PATH.stat().st_mtime) + return {"exists": True, "updated_at": updated_at.isoformat()} + + +@router.post("/oui/update") +async def update_oui(db: Session = Depends(get_db)): + """ + Télécharge le fichier OUI et met à jour les fabricants inconnus + """ + OUI_PATH.parent.mkdir(parents=True, exist_ok=True) + try: + request = urllib.request.Request( + OUI_URL, + headers={ + "User-Agent": "IPWatch/1.0 (+https://ipwatch.local)" + } + ) + with urllib.request.urlopen(request) as response: + OUI_PATH.write_bytes(response.read()) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Erreur téléchargement OUI: {str(e)}") + + # Mettre à jour les vendors inconnus dans la DB + from backend.app.services.network import OuiLookup + + updated = 0 + ips = db.query(IP).filter(IP.mac.isnot(None)).all() + for ip in ips: + if ip.vendor and ip.vendor not in {"Unknown", ""}: + continue + vendor = OuiLookup.lookup(ip.mac) + if vendor: + ip.vendor = vendor + updated += 1 + db.commit() + + return {"message": "Liste OUI mise à jour", "updated_vendors": updated} + + +@router.get("/icons") +async def list_icons(): + """ + Liste les icônes disponibles dans le dossier partagé + """ + ICONS_DIR.mkdir(parents=True, exist_ok=True) + files = [] + for path in ICONS_DIR.iterdir(): + if path.is_file() and path.suffix.lower() in ALLOWED_ICON_EXTENSIONS: + files.append(path.name) + return {"icons": sorted(files)} + + +@router.post("/icons/upload") +async def upload_icon(file: UploadFile = File(...)): + """ + Upload d'une icône dans le dossier partagé + """ + ICONS_DIR.mkdir(parents=True, exist_ok=True) + filename = _sanitize_filename(file.filename or "") + ext = Path(filename).suffix.lower() + if ext not in ALLOWED_ICON_EXTENSIONS: + raise HTTPException(status_code=400, detail="Format d'image non supporté") + + target = ICONS_DIR / filename + + try: + content = await file.read() + target.write_bytes(content) + return { + "filename": target.name, + "url": f"/icons/{target.name}" + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Erreur upload: {str(e)}") + + + + + +# Schémas Pydantic pour validation +class IPUpdate(BaseModel): + """Schéma pour mise à jour d'IP""" + name: Optional[str] = None + known: Optional[bool] = None + tracked: Optional[bool] = None + vm: Optional[bool] = None + hardware_bench: Optional[bool] = None + network_device: Optional[bool] = None + location: Optional[str] = None + host: Optional[str] = None + link: Optional[str] = None + last_status: Optional[str] = None + mac: Optional[str] = None + vendor: Optional[str] = None + hostname: Optional[str] = None + mac_changed: Optional[bool] = None + open_ports: Optional[List[int]] = None + first_seen: Optional[datetime] = None + last_seen: Optional[datetime] = None + icon_filename: Optional[str] = None + icon_url: Optional[str] = None + ip_parent: Optional[str] = None + ip_enfant: Optional[List[str]] = None + dhcp_synced: Optional[bool] = None + + +class IPResponse(BaseModel): + """Schéma de réponse IP""" + ip: str + name: Optional[str] + known: bool + tracked: Optional[bool] = False + vm: Optional[bool] = False + hardware_bench: Optional[bool] = False + network_device: Optional[bool] = False + 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] + link: Optional[str] + mac_changed: Optional[bool] = False + open_ports: List[int] + icon_filename: Optional[str] + icon_url: Optional[str] + ip_parent: Optional[str] + ip_enfant: List[str] = [] + dhcp_synced: Optional[bool] = False + + 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) + old_parent = ip.ip_parent + new_parent = update_data.get("ip_parent", old_parent) + for field, value in update_data.items(): + setattr(ip, field, value) + + # Mettre à jour automatiquement network_device si host change + if 'host' in update_data: + ip.network_device = (update_data['host'] == 'Network') + + if "ip_enfant" in update_data and update_data["ip_enfant"] is not None: + ip.ip_enfant = update_data["ip_enfant"] + + if new_parent != old_parent: + if old_parent: + parent = db.query(IP).filter(IP.ip == old_parent).first() + if parent and parent.ip_enfant: + parent.ip_enfant = [child for child in parent.ip_enfant if child != ip.ip] + if new_parent: + parent = db.query(IP).filter(IP.ip == new_parent).first() + if parent: + current_children = parent.ip_enfant or [] + if ip.ip not in current_children: + parent.ip_enfant = current_children + [ip.ip] + + 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.now() - 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.delete("/{ip_address}/history") +async def delete_ip_history(ip_address: str, db: Session = Depends(get_db)): + """ + Supprime l'historique d'une IP (sans supprimer l'IP elle-même) + + Args: + ip_address: Adresse IP + db: Session de base de données + + Returns: + Message de confirmation avec nombre d'entrées supprimées + """ + # 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") + + # Supprimer tout l'historique de cette IP + deleted_count = db.query(IPHistory).filter(IPHistory.ip == ip_address).delete() + db.commit() + + return {"message": f"Historique de {ip_address} supprimé", "deleted_count": deleted_count} + + +@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 + } + + +@router.get("/config/options") +async def get_config_options(): + """ + Récupère les options de configuration (locations, hosts, port_protocols, version, subnets) + + Returns: + Dictionnaire avec locations, hosts, port_protocols, subnets et version + """ + config = config_manager.config + + # Récupérer les protocoles de ports depuis la config + port_protocols = {} + if hasattr(config.ports, 'protocols') and config.ports.protocols: + port_protocols = config.ports.protocols + + # Récupérer les subnets + subnets = [] + if hasattr(config, 'subnets') and config.subnets: + subnets = [ + { + "name": s.name, + "cidr": s.cidr, + "start": s.start, + "end": s.end, + "description": s.description + } + for s in config.subnets + ] + + return { + "locations": config.locations, + "hosts": [{"name": h.name, "location": h.location} for h in config.hosts], + "port_protocols": port_protocols, + "subnets": subnets, + "version": config.app.version, + "hardware_bench_url": getattr(config.links, "hardware_bench_url", None), + "force_vendor_update": getattr(config.scan, "force_vendor_update", False) + } + + +class HardwareBenchConfig(BaseModel): + """Schéma pour mise à jour du lien hardware bench""" + url: Optional[str] = None + + +class ForceVendorConfig(BaseModel): + """Schéma pour mise à jour du mode force fabricant""" + enabled: bool = False + + +@router.post("/config/hardware-bench") +async def update_hardware_bench(config_update: HardwareBenchConfig): + """ + Met à jour l'URL hardware bench dans config.yaml + + Returns: + Message de confirmation + """ + config_path = "./config.yaml" + + try: + with open(config_path, "r", encoding="utf-8") as f: + yaml_data = yaml.safe_load(f) or {} + + if "links" not in yaml_data or yaml_data["links"] is None: + yaml_data["links"] = {} + + url = (config_update.url or "").strip() + yaml_data["links"]["hardware_bench_url"] = url if url else None + + with open(config_path, "w", encoding="utf-8") as f: + yaml.safe_dump(yaml_data, f, allow_unicode=True, sort_keys=False) + + config_manager.reload_config() + return {"message": "Lien hardware bench mis à jour"} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Erreur mise à jour config: {str(e)}") + + +@router.post("/config/force-vendor") +async def update_force_vendor(config_update: ForceVendorConfig): + """ + Active/désactive le mode force pour le fabricant + """ + config_path = "./config.yaml" + + try: + with open(config_path, "r", encoding="utf-8") as f: + yaml_data = yaml.safe_load(f) or {} + + if "scan" not in yaml_data or yaml_data["scan"] is None: + yaml_data["scan"] = {} + + yaml_data["scan"]["force_vendor_update"] = bool(config_update.enabled) + + with open(config_path, "w", encoding="utf-8") as f: + yaml.safe_dump(yaml_data, f, allow_unicode=True, sort_keys=False) + + config_manager.reload_config() + return {"message": "Mode force fabricant mis à jour"} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Erreur mise à jour config: {str(e)}") + + +@router.get("/config/content") +async def get_config_content(): + """ + Récupère le contenu brut du fichier config.yaml + + Returns: + Contenu du fichier YAML + """ + try: + with open("./config.yaml", "r", encoding="utf-8") as f: + content = f.read() + return {"content": content} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Erreur lecture config: {str(e)}") + + +@router.post("/config/reload") +async def reload_config(): + """ + Recharge la configuration depuis config.yaml + + Returns: + Message de confirmation + """ + try: + config_manager.reload_config() + return {"message": "Configuration rechargée avec succès"} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Erreur rechargement config: {str(e)}") + + +@router.post("/import/ipscan") +async def import_ipscan(file: UploadFile = File(...), db: Session = Depends(get_db)): + """ + Importe les données depuis un fichier XML Angry IP Scanner + + Args: + file: Fichier XML uploadé + db: Session de base de données + + Returns: + Statistiques d'import + """ + if not file.filename.endswith('.xml'): + raise HTTPException(status_code=400, detail="Le fichier doit être un XML") + + try: + # Lire le contenu du fichier + content = await file.read() + + # Essayer de parser le XML avec récupération d'erreurs + try: + root = ET.fromstring(content) + except ET.ParseError as e: + # Si le parsing échoue, essayer de nettoyer le contenu + import re + content_str = content.decode('utf-8', errors='ignore') + + # Supprimer les caractères de contrôle invalides (sauf tab, CR, LF) + content_str = ''.join(char for char in content_str + if ord(char) >= 32 or char in '\t\r\n') + + try: + root = ET.fromstring(content_str.encode('utf-8')) + except ET.ParseError: + raise HTTPException(status_code=400, detail=f"Fichier XML invalide même après nettoyage: {str(e)}") + + imported = 0 + updated = 0 + errors = [] + + # Parser chaque host + for host in root.findall('.//host'): + try: + # Extraire l'adresse IP + ip_address = host.get('address') + if not ip_address: + continue + + # Extraire les informations + hostname = None + mac = None + vendor = None + ports = [] + + for result in host.findall('result'): + name = result.get('name') + value = result.text.strip() if result.text else "" + + # Nettoyer les valeurs [n/a] + if value == "[n/a]": + value = None + + if name == "Nom d'hôte" and value: + hostname = value + elif name == "Adresse MAC" and value: + mac = value + elif name == "Constructeur MAC" and value: + vendor = value + elif name == "Ports" and value: + # Parser les ports (format: "22,80,443") + try: + ports = [int(p.strip()) for p in value.split(',') if p.strip().isdigit()] + except Exception as e: + ports = [] + + # Vérifier si l'IP existe déjà + existing_ip = db.query(IP).filter(IP.ip == ip_address).first() + + if existing_ip: + # Mettre à jour avec de nouvelles informations + if hostname: + if not existing_ip.hostname: + existing_ip.hostname = hostname + if not existing_ip.name: + existing_ip.name = hostname + if mac and not existing_ip.mac: + existing_ip.mac = mac + # Toujours mettre à jour vendor et ports depuis IPScan (plus complet et à jour) + if vendor: + existing_ip.vendor = vendor + if ports: + existing_ip.open_ports = ports + existing_ip.last_status = "online" + existing_ip.last_seen = datetime.now() + updated += 1 + else: + # Créer une nouvelle entrée + new_ip = IP( + ip=ip_address, + name=hostname, + hostname=hostname, + mac=mac, + vendor=vendor, + open_ports=ports or [], + last_status="online", + known=False, + first_seen=datetime.now(), + last_seen=datetime.now() + ) + db.add(new_ip) + imported += 1 + + except Exception as e: + errors.append(f"Erreur pour {ip_address}: {str(e)}") + continue + + # Commit des changements + db.commit() + + return { + "message": "Import terminé", + "imported": imported, + "updated": updated, + "errors": errors[:10] # Limiter à 10 erreurs + } + + except ET.ParseError as e: + raise HTTPException(status_code=400, detail=f"Fichier XML invalide: {str(e)}") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Erreur import: {str(e)}") diff --git a/backend/app/routers/opnsense.py b/backend/app/routers/opnsense.py new file mode 100644 index 0000000..a0c1457 --- /dev/null +++ b/backend/app/routers/opnsense.py @@ -0,0 +1,164 @@ +""" +Endpoints API pour l'intégration OPNsense (Kea DHCP) +""" +import traceback +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from pydantic import BaseModel +from typing import Optional + +from backend.app.core.database import get_db +from backend.app.core.config import config_manager +from backend.app.models.ip import IP +from backend.app.services.opnsense_client import OPNsenseClient, OPNsenseAPIError + +router = APIRouter(prefix="/api/opnsense", tags=["OPNsense"]) + + +class DHCPReservationRequest(BaseModel): + """Schéma pour créer/mettre à jour une réservation DHCP""" + ip_address: str + hw_address: str + hostname: str = "" + description: str = "Ajouté par IPWatch" + + +def get_opnsense_client() -> OPNsenseClient: + """Retourne un client OPNsense configuré""" + config = config_manager.config.opnsense + print(f"[OPNsense Router] Config: enabled={config.enabled}, host={config.host}, api_key={'***' + config.api_key[-8:] if config.api_key else 'VIDE'}") + if not config.enabled: + raise HTTPException(status_code=503, detail="Intégration OPNsense désactivée") + if not config.host or not config.api_key: + raise HTTPException(status_code=503, detail="Configuration OPNsense incomplète") + return OPNsenseClient() + + +@router.get("/status") +async def opnsense_status(): + """Teste la connexion à l'API OPNsense""" + client = get_opnsense_client() + try: + result = await client.test_connection() + return {"status": "connected", "data": result} + except Exception as e: + print(f"[OPNsense Router] Erreur status: {type(e).__name__}: {e}") + traceback.print_exc() + raise HTTPException(status_code=502, detail=f"Connexion OPNsense échouée: {type(e).__name__}: {str(e)}") + + +@router.get("/dhcp/reservations") +async def list_reservations(): + """Liste toutes les réservations DHCP Kea""" + client = get_opnsense_client() + try: + result = await client.search_reservations() + return result + except Exception as e: + print(f"[OPNsense Router] Erreur list_reservations: {type(e).__name__}: {e}") + traceback.print_exc() + raise HTTPException(status_code=502, detail=f"Erreur récupération réservations: {type(e).__name__}: {str(e)}") + + +@router.get("/dhcp/reservation/{ip_address}") +async def get_reservation_by_ip(ip_address: str): + """Cherche une réservation DHCP par adresse IP""" + client = get_opnsense_client() + try: + reservation = await client.find_reservation_by_ip(ip_address) + if reservation: + return {"found": True, "reservation": reservation} + return {"found": False, "reservation": None} + except Exception as e: + print(f"[OPNsense Router] Erreur get_reservation_by_ip: {type(e).__name__}: {e}") + traceback.print_exc() + raise HTTPException(status_code=502, detail=f"Erreur recherche réservation: {type(e).__name__}: {str(e)}") + + +@router.post("/dhcp/reservation") +async def upsert_reservation( + request: DHCPReservationRequest, + db: Session = Depends(get_db) +): + """ + Crée ou met à jour une réservation DHCP Kea pour une IP. + Après succès, met à jour dhcp_synced=True dans la BDD. + """ + print(f"[OPNsense Router] === UPSERT RESERVATION ===") + print(f"[OPNsense Router] IP: {request.ip_address}, MAC: {request.hw_address}, Hostname: {request.hostname}") + + client = get_opnsense_client() + + try: + # Étape 0 : Résoudre le subnet UUID + print(f"[OPNsense Router] Étape 0: Résolution du subnet pour {request.ip_address}...") + subnet_uuid = await client.find_subnet_for_ip(request.ip_address) + if not subnet_uuid: + raise HTTPException(status_code=400, detail=f"Aucun subnet Kea trouvé pour l'IP {request.ip_address}") + + reservation_data = { + "subnet": subnet_uuid, + "ip_address": request.ip_address, + "hw_address": request.hw_address, + "hostname": request.hostname, + "description": request.description + } + print(f"[OPNsense Router] Données réservation: {reservation_data}") + + # Étape 1 : Chercher si une réservation existe déjà + print(f"[OPNsense Router] Étape 1: Recherche réservation existante...") + existing = await client.find_reservation_by_ip(request.ip_address) + + if existing: + # Mise à jour de la réservation existante + uuid = existing.get("uuid") + print(f"[OPNsense Router] Étape 2: Mise à jour réservation existante uuid={uuid}") + if not uuid: + raise HTTPException(status_code=500, detail="UUID de réservation introuvable") + result = await client.set_reservation(uuid, reservation_data) + action = "updated" + else: + # Création d'une nouvelle réservation + print(f"[OPNsense Router] Étape 2: Création nouvelle réservation") + result = await client.add_reservation(reservation_data) + action = "created" + + print(f"[OPNsense Router] Étape 2 terminée: action={action}, result={result}") + + # Étape 3 : Appliquer les changements dans Kea + print(f"[OPNsense Router] Étape 3: Reconfiguration Kea...") + await client.reconfigure_kea() + print(f"[OPNsense Router] Étape 3 terminée: Kea reconfiguré") + + # Étape 4 : Mettre à jour dhcp_synced dans la BDD + print(f"[OPNsense Router] Étape 4: Mise à jour BDD dhcp_synced=True") + ip_record = db.query(IP).filter(IP.ip == request.ip_address).first() + if ip_record: + ip_record.dhcp_synced = True + db.commit() + db.refresh(ip_record) + print(f"[OPNsense Router] Étape 4 terminée: BDD mise à jour") + else: + print(f"[OPNsense Router] ATTENTION: IP {request.ip_address} non trouvée en BDD") + + print(f"[OPNsense Router] === SUCCÈS: {action} ===") + return { + "status": "success", + "action": action, + "ip_address": request.ip_address, + "result": result + } + + except HTTPException: + raise + except OPNsenseAPIError as e: + print(f"[OPNsense Router] === ERREUR VALIDATION ===") + print(f"[OPNsense Router] Message: {str(e)}") + print(f"[OPNsense Router] Validations: {e.validations}") + raise HTTPException(status_code=422, detail=str(e)) + except Exception as e: + print(f"[OPNsense Router] === ERREUR ===") + print(f"[OPNsense Router] Type: {type(e).__name__}") + print(f"[OPNsense Router] Message: {str(e)}") + traceback.print_exc() + raise HTTPException(status_code=502, detail=f"Erreur OPNsense: {type(e).__name__}: {str(e)}") diff --git a/backend/app/routers/scan.py b/backend/app/routers/scan.py new file mode 100755 index 0000000..a9a8f1f --- /dev/null +++ b/backend/app/routers/scan.py @@ -0,0 +1,362 @@ +""" +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, Optional, List +from pydantic import BaseModel + +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.models.scan_log import ScanLog +from backend.app.services.network import NetworkScanner, OuiLookup +from backend.app.services.websocket import ws_manager + +router = APIRouter(prefix="/api/scan", tags=["Scan"]) + + +class ScanLogResponse(BaseModel): + """Schéma de réponse logs scan""" + id: int + ip: Optional[str] + status: Optional[str] + message: str + created_at: datetime + + class Config: + from_attributes = True + + +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: + async def scan_log(message: str): + print(message) + try: + await ws_manager.broadcast_scan_log(message) + except Exception: + pass + + await scan_log(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 + await scan_log(f"[{datetime.now()}] Config chargée: {config.network.cidr}") + + # Initialiser le scanner + scanner = NetworkScanner( + cidr=config.network.cidr, + timeout=config.scan.timeout, + ping_count=config.scan.ping_count + ) + + # 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)) + + await scan_log(f"[{datetime.now()}] Ports à scanner: {len(port_list)}") + + # Récupérer les IPs connues + known_ips = config.ip_classes + await scan_log(f"[{datetime.now()}] IPs connues: {len(known_ips)}") + + # Callback de progression pour WebSocket + async def progress_callback(current: int, total: int, current_ip: str, status: str, ping_ok: bool): + try: + ping_label = "ok" if ping_ok else "fail" + await ws_manager.broadcast_scan_progress({ + "current": current, + "total": total, + "ip": current_ip + }) + await ws_manager.broadcast_scan_log( + f"[{current}/{total}] {current_ip} -> ping:{ping_label} ({status})" + ) + except Exception: + # Ignorer les erreurs WebSocket pour ne pas bloquer le scan + pass + + # Lancer le scan + await scan_log(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, + progress_callback=progress_callback + ) + await scan_log(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 + + # Log par IP (historique scan) + ping_label = "ok" if ip_data["last_status"] == "online" else "fail" + log_message = f"Scan {ip_address} -> ping:{ping_label} ({ip_data['last_status']})" + db.add(ScanLog( + ip=ip_address, + status=ip_data["last_status"], + message=log_message + )) + + # 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 + + # Si l'IP passe de offline à online ET qu'elle était inconnue, c'est une "nouvelle détection" + # On réinitialise first_seen pour qu'elle apparaisse dans "Nouvelles Détections" + if (old_status == "offline" and ip_data["last_status"] == "online" and not existing_ip.known): + existing_ip.first_seen = datetime.now() + + # Détecter changement de MAC address + new_mac = ip_data.get("mac") + if new_mac and existing_ip.mac and new_mac != existing_ip.mac: + # MAC a changé ! Marquer comme changée + existing_ip.mac_changed = True + print(f"[ALERTE] MAC changée pour {ip_address}: {existing_ip.mac} -> {new_mac}") + else: + # Pas de changement ou pas de MAC précédente + existing_ip.mac_changed = False + + 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 + + vendor = ip_data.get("vendor") + if (not vendor or vendor == "Unknown") and existing_ip.mac: + vendor = OuiLookup.lookup(existing_ip.mac) or vendor + if config.scan.force_vendor_update: + if vendor and vendor != "Unknown": + existing_ip.vendor = vendor + else: + if (not existing_ip.vendor or existing_ip.vendor == "Unknown") and vendor and vendor != "Unknown": + existing_ip.vendor = vendor + existing_ip.hostname = ip_data.get("hostname") or existing_ip.hostname + existing_ip.open_ports = ip_data.get("open_ports", []) + + # Mettre à jour host seulement si présent dans ip_data (config) + if "host" in ip_data: + existing_ip.host = ip_data["host"] + + # Mettre à jour le flag network_device (basé sur host="Network") + # Utiliser le host existant si ip_data n'en a pas + current_host = ip_data.get("host") or existing_ip.host + existing_ip.network_device = (current_host == "Network") + + # 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 + vendor = ip_data.get("vendor") + if (not vendor or vendor == "Unknown") and ip_data.get("mac"): + vendor = OuiLookup.lookup(ip_data.get("mac")) or vendor + new_ip = IP( + ip=ip_address, + name=ip_data.get("name"), + known=ip_data.get("known", False), + network_device=ip_data.get("host") == "Network", + location=ip_data.get("location"), + host=ip_data.get("host"), + first_seen=datetime.now(), + last_seen=ip_data.get("last_seen") or datetime.now(), + last_status=ip_data["last_status"], + mac=ip_data.get("mac"), + vendor=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.now(), + 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.now() + } + + +@router.get("/logs", response_model=List[ScanLogResponse]) +async def get_scan_logs(limit: int = 200, db: Session = Depends(get_db)): + """ + Retourne les derniers logs de scan + """ + logs = db.query(ScanLog).order_by(ScanLog.created_at.desc()).limit(limit).all() + return list(reversed(logs)) + + +@router.post("/ports/{ip_address}") +async def scan_ip_ports(ip_address: str, db: Session = Depends(get_db)): + """ + Scanne les ports d'une IP spécifique + + Args: + ip_address: Adresse IP à scanner + db: Session de base de données + + Returns: + Liste des ports ouverts + """ + try: + # Récupérer la config + config = config_manager.config + + # 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)) + + # Initialiser le scanner + scanner = NetworkScanner( + cidr=config.network.cidr, + timeout=config.scan.timeout, + ping_count=config.scan.ping_count + ) + + # Scanner les ports de cette IP + print(f"[{datetime.now()}] Scan ports pour {ip_address}...") + open_ports = await scanner.scan_ports(ip_address, port_list) + print(f"[{datetime.now()}] Ports ouverts pour {ip_address}: {open_ports}") + + # Mettre à jour la base de données + ip_record = db.query(IP).filter(IP.ip == ip_address).first() + if ip_record: + ip_record.open_ports = open_ports + ip_record.last_seen = datetime.now() + db.commit() + + # Notifier via WebSocket + await ws_manager.broadcast_ip_update({ + "ip": ip_address, + "open_ports": open_ports + }) + + return { + "message": "Scan de ports terminé", + "ip": ip_address, + "open_ports": open_ports, + "timestamp": datetime.now() + } + + except Exception as e: + print(f"Erreur scan ports {ip_address}: {e}") + return { + "message": f"Erreur: {str(e)}", + "ip": ip_address, + "open_ports": [], + "timestamp": datetime.now() + } + + +@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.now() - 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/system.py b/backend/app/routers/system.py new file mode 100755 index 0000000..948bafb --- /dev/null +++ b/backend/app/routers/system.py @@ -0,0 +1,73 @@ +""" +Router pour les statistiques système +Fournit les métriques RAM et CPU du serveur IPWatch +""" +from fastapi import APIRouter +import psutil +from datetime import datetime + +router = APIRouter(prefix="/api/system", tags=["system"]) + + +@router.get("/stats") +async def get_system_stats(): + """ + Récupère les statistiques système du serveur IPWatch + + Returns: + dict: Statistiques RAM et CPU + - ram_percent: Pourcentage de RAM utilisée + - ram_used: RAM utilisée en MB + - ram_total: RAM totale en MB + - ram_available: RAM disponible en MB + - cpu_percent: Pourcentage d'utilisation CPU + - cpu_count: Nombre de cœurs CPU + - timestamp: Horodatage de la mesure + """ + # Statistiques mémoire + memory = psutil.virtual_memory() + + # Statistiques CPU (moyenne sur 1 seconde) + cpu_percent = psutil.cpu_percent(interval=1) + + # Informations processus IPWatch + process = psutil.Process() + process_memory = process.memory_info() + + return { + # RAM système + "ram_percent": round(memory.percent, 1), + "ram_used": round(memory.used / (1024 * 1024), 1), # MB + "ram_total": round(memory.total / (1024 * 1024), 1), # MB + "ram_available": round(memory.available / (1024 * 1024), 1), # MB + + # CPU système + "cpu_percent": round(cpu_percent, 1), + "cpu_count": psutil.cpu_count(), + + # Processus IPWatch + "process_ram_mb": round(process_memory.rss / (1024 * 1024), 1), # MB + "process_cpu_percent": round(process.cpu_percent(interval=0.1), 1), + + # Timestamp + "timestamp": datetime.now().isoformat() + } + + +@router.get("/uptime") +async def get_uptime(): + """ + Récupère l'uptime du système + + Returns: + dict: Informations sur l'uptime + """ + import time + boot_time = psutil.boot_time() + uptime_seconds = time.time() - boot_time + + return { + "uptime_seconds": int(uptime_seconds), + "uptime_hours": round(uptime_seconds / 3600, 1), + "boot_time": datetime.fromtimestamp(boot_time).isoformat() + } diff --git a/backend/app/routers/tracking.py b/backend/app/routers/tracking.py new file mode 100644 index 0000000..ad4249a --- /dev/null +++ b/backend/app/routers/tracking.py @@ -0,0 +1,227 @@ +""" +Endpoints API pour le suivi d'équipements (Wake-on-LAN, shutdown, etc.) +""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import List, Optional +from datetime import datetime +from pydantic import BaseModel + +from backend.app.core.database import get_db +from backend.app.models.ip import IP + +router = APIRouter(prefix="/api/tracking", tags=["Tracking"]) + + +# Schémas Pydantic +class IPTrackingResponse(BaseModel): + """Schéma de réponse pour les IPs suivies""" + ip: str + name: Optional[str] + known: bool + tracked: bool + location: Optional[str] + host: Optional[str] + last_status: Optional[str] + mac: Optional[str] + vendor: Optional[str] + hostname: Optional[str] + link: Optional[str] + last_seen: Optional[datetime] + open_ports: List[int] + + class Config: + from_attributes = True + + +class WOLResponse(BaseModel): + """Réponse après envoi Wake-on-LAN""" + message: str + ip: str + mac: str + success: bool + + +class ShutdownResponse(BaseModel): + """Réponse après commande d'arrêt""" + message: str + ip: str + success: bool + + +@router.get("/", response_model=List[IPTrackingResponse]) +async def get_tracked_ips(db: Session = Depends(get_db)): + """ + Récupère toutes les IPs marquées comme suivies + Retourne la liste des équipements avec leur état actuel + """ + tracked_ips = db.query(IP).filter(IP.tracked == True).order_by(IP.name, IP.ip).all() + return tracked_ips + + +@router.post("/wol/{ip_address}", response_model=WOLResponse) +async def wake_on_lan(ip_address: str, db: Session = Depends(get_db)): + """ + Envoie un paquet Magic Packet Wake-on-LAN à l'équipement + Nécessite que l'IP ait une adresse MAC enregistrée + """ + # Récupérer l'IP depuis la base + ip_obj = db.query(IP).filter(IP.ip == ip_address).first() + + if not ip_obj: + raise HTTPException( + status_code=404, + detail=f"IP {ip_address} non trouvée dans la base de données" + ) + + if not ip_obj.mac: + raise HTTPException( + status_code=400, + detail=f"Adresse MAC manquante pour {ip_address}. Impossible d'envoyer le paquet WOL." + ) + + try: + # Importer la bibliothèque wakeonlan + from wakeonlan import send_magic_packet + + # Envoyer le paquet Magic Packet + send_magic_packet(ip_obj.mac) + + return WOLResponse( + message=f"Paquet Wake-on-LAN envoyé avec succès", + ip=ip_address, + mac=ip_obj.mac, + success=True + ) + + except ImportError: + raise HTTPException( + status_code=500, + detail="La bibliothèque 'wakeonlan' n'est pas installée. Exécutez: pip install wakeonlan" + ) + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Erreur lors de l'envoi du paquet WOL: {str(e)}" + ) + + +@router.post("/shutdown/{ip_address}", response_model=ShutdownResponse) +async def shutdown_device(ip_address: str, db: Session = Depends(get_db)): + """ + Envoie une commande shutdown via MQTT à l'équipement + """ + # Récupérer l'IP depuis la base + ip_obj = db.query(IP).filter(IP.ip == ip_address).first() + + if not ip_obj: + raise HTTPException( + status_code=404, + detail=f"IP {ip_address} non trouvée dans la base de données" + ) + + if ip_obj.last_status != "online": + raise HTTPException( + status_code=400, + detail=f"L'équipement {ip_address} est déjà hors ligne" + ) + + try: + from backend.app.services.mqtt_client import send_mqtt_command + + # Envoyer commande shutdown via MQTT + success = send_mqtt_command(ip_address, "shutdown") + + if success: + return ShutdownResponse( + message=f"Commande shutdown envoyée à {ip_address} via MQTT", + ip=ip_address, + success=True + ) + else: + raise HTTPException( + status_code=500, + detail="Échec de l'envoi de la commande MQTT" + ) + + except ImportError: + raise HTTPException( + status_code=500, + detail="Le service MQTT n'est pas configuré. Consultez mqtt/docs/README.md" + ) + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Erreur lors de l'envoi de la commande: {str(e)}" + ) + + +@router.post("/reboot/{ip_address}", response_model=ShutdownResponse) +async def reboot_device(ip_address: str, db: Session = Depends(get_db)): + """ + Envoie une commande reboot via MQTT à l'équipement + """ + # Récupérer l'IP depuis la base + ip_obj = db.query(IP).filter(IP.ip == ip_address).first() + + if not ip_obj: + raise HTTPException( + status_code=404, + detail=f"IP {ip_address} non trouvée" + ) + + if ip_obj.last_status != "online": + raise HTTPException( + status_code=400, + detail=f"L'équipement {ip_address} est hors ligne" + ) + + try: + from backend.app.services.mqtt_client import send_mqtt_command + + # Envoyer commande reboot via MQTT + success = send_mqtt_command(ip_address, "reboot") + + if success: + return ShutdownResponse( + message=f"Commande reboot envoyée à {ip_address} via MQTT", + ip=ip_address, + success=True + ) + else: + raise HTTPException( + status_code=500, + detail="Échec de l'envoi de la commande MQTT" + ) + + except ImportError: + raise HTTPException( + status_code=500, + detail="Le service MQTT n'est pas configuré" + ) + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Erreur: {str(e)}" + ) + + +@router.patch("/{ip_address}/toggle", response_model=IPTrackingResponse) +async def toggle_tracking(ip_address: str, db: Session = Depends(get_db)): + """ + Bascule l'état de suivi d'une IP (tracked true/false) + """ + ip_obj = db.query(IP).filter(IP.ip == ip_address).first() + + if not ip_obj: + raise HTTPException( + status_code=404, + detail=f"IP {ip_address} non trouvée" + ) + + # Inverser l'état tracked + ip_obj.tracked = not ip_obj.tracked + db.commit() + db.refresh(ip_obj) + + return ip_obj diff --git a/backend/app/routers/websocket.py b/backend/app/routers/websocket.py new file mode 100755 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/scripts/check_network_device.py b/backend/app/scripts/check_network_device.py new file mode 100644 index 0000000..4de4049 --- /dev/null +++ b/backend/app/scripts/check_network_device.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +""" +Script pour vérifier et forcer la mise à jour du flag network_device +""" +import os +import sys + +# Ajouter le chemin parent pour les imports +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..'))) + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from backend.app.models.ip import IP + +# Créer la connexion à la base de données +db_path = os.getenv('DB_PATH', './data/db.sqlite') +db_url = f"sqlite:///{db_path}" +engine = create_engine(db_url, echo=False) +SessionLocal = sessionmaker(bind=engine) + +db = SessionLocal() + +try: + # Récupérer toutes les IPs + ips = db.query(IP).all() + + print(f"\n📊 Total IPs: {len(ips)}\n") + + updated = 0 + for ip in ips: + # Afficher les IPs avec host défini + if ip.host: + status_icon = "🟢" if ip.last_status == "online" else "🔴" + network_icon = "🔷" if ip.network_device else " " + + print(f"{status_icon} {network_icon} {ip.ip:15s} | Host: {ip.host:15s} | Network: {ip.network_device} | Status: {ip.last_status}") + + # Mettre à jour network_device si host == "Network" + should_be_network = (ip.host == "Network") + if ip.network_device != should_be_network: + ip.network_device = should_be_network + updated += 1 + print(f" ✓ Flag network_device mis à jour pour {ip.ip}: {should_be_network}") + + if updated > 0: + db.commit() + print(f"\n✅ {updated} IP(s) mise(s) à jour!") + else: + print(f"\n✓ Tous les flags network_device sont déjà à jour") + +except Exception as e: + print(f"❌ Erreur: {e}") + import traceback + traceback.print_exc() +finally: + db.close() diff --git a/backend/app/scripts/rebuild_ip_relations.py b/backend/app/scripts/rebuild_ip_relations.py new file mode 100644 index 0000000..e1ff74a --- /dev/null +++ b/backend/app/scripts/rebuild_ip_relations.py @@ -0,0 +1,118 @@ +""" +Reconstruit ip_parent depuis config.yaml, puis recalcule ip_enfant depuis ip_parent. +""" +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path +from typing import Any, Dict, List, Optional + +import yaml + + +CONFIG_PATH = Path(__file__).resolve().parents[3] / "config.yaml" + + +def load_config() -> Dict[str, Any]: + with CONFIG_PATH.open("r", encoding="utf-8") as handle: + return yaml.safe_load(handle) or {} + + +def normalize_children(value: Any) -> Optional[List[str]]: + if value is None: + return None + if isinstance(value, str): + return [value] if value else [] + if isinstance(value, list): + return [str(item) for item in value if item] + return [] + + +def ensure_columns(conn: sqlite3.Connection) -> None: + cursor = conn.execute("PRAGMA table_info(ip)") + columns = {row[1] for row in cursor.fetchall()} + if "ip_parent" not in columns: + conn.execute("ALTER TABLE ip ADD COLUMN ip_parent TEXT") + if "ip_enfant" not in columns: + conn.execute("ALTER TABLE ip ADD COLUMN ip_enfant TEXT") + conn.commit() + + +def collect_parent_mapping(config: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: + mapping: Dict[str, Dict[str, Any]] = {} + + ip_classes = config.get("ip_classes", {}) or {} + for ip_address, data in ip_classes.items(): + if not isinstance(data, dict): + continue + if "ip_parent" in data or "ip_enfant" in data: + mapping[ip_address] = { + "ip_parent": data.get("ip_parent"), + "ip_enfant": normalize_children(data.get("ip_enfant")) + } + + for host in config.get("hosts", []) or []: + if not isinstance(host, dict): + continue + ip_address = host.get("ip") + if not ip_address: + continue + if "ip_parent" in host or "ip_enfant" in host: + entry = mapping.setdefault(ip_address, {}) + entry.setdefault("ip_parent", host.get("ip_parent")) + entry.setdefault("ip_enfant", normalize_children(host.get("ip_enfant"))) + + return mapping + + +def main() -> None: + config = load_config() + db_path = Path(config.get("database", {}).get("path", "./data/db.sqlite")) + mapping = collect_parent_mapping(config) + + if not db_path.exists(): + raise FileNotFoundError(f"Base de données introuvable: {db_path}") + + conn = sqlite3.connect(db_path) + try: + ensure_columns(conn) + + if mapping: + for ip_address, values in mapping.items(): + ip_parent = values.get("ip_parent") + ip_enfant = values.get("ip_enfant") + if ip_enfant is not None: + conn.execute( + "UPDATE ip SET ip_parent = ?, ip_enfant = ? WHERE ip = ?", + (ip_parent, json.dumps(ip_enfant), ip_address) + ) + else: + conn.execute( + "UPDATE ip SET ip_parent = ? WHERE ip = ?", + (ip_parent, ip_address) + ) + + cursor = conn.execute("SELECT ip, ip_parent FROM ip") + rows = cursor.fetchall() + parent_children: Dict[str, List[str]] = {} + + for ip_address, ip_parent in rows: + if ip_parent: + parent_children.setdefault(ip_parent, []).append(ip_address) + + for ip_address, _ in rows: + children = parent_children.get(ip_address, []) + conn.execute( + "UPDATE ip SET ip_enfant = ? WHERE ip = ?", + (json.dumps(children), ip_address) + ) + + conn.commit() + print("Reconstruction terminée.") + finally: + conn.close() + + +if __name__ == "__main__": + main() diff --git a/backend/app/scripts/rebuild_ip_relations_from_hosts.py b/backend/app/scripts/rebuild_ip_relations_from_hosts.py new file mode 100644 index 0000000..bd3bddd --- /dev/null +++ b/backend/app/scripts/rebuild_ip_relations_from_hosts.py @@ -0,0 +1,98 @@ +""" +Reconstruit ip_parent/ip_enfant en utilisant le champ host et config.yaml. +1) Pour chaque IP avec host, retrouve l'IP du host dans config.yaml et met ip_parent. +2) Recalcule ip_enfant depuis ip_parent. +""" +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path +from typing import Any, Dict + +import yaml + + +CONFIG_PATH = Path(__file__).resolve().parents[3] / "config.yaml" + + +def load_config() -> Dict[str, Any]: + with CONFIG_PATH.open("r", encoding="utf-8") as handle: + return yaml.safe_load(handle) or {} + + +def ensure_columns(conn: sqlite3.Connection) -> None: + cursor = conn.execute("PRAGMA table_info(ip)") + columns = {row[1] for row in cursor.fetchall()} + if "ip_parent" not in columns: + conn.execute("ALTER TABLE ip ADD COLUMN ip_parent TEXT") + if "ip_enfant" not in columns: + conn.execute("ALTER TABLE ip ADD COLUMN ip_enfant TEXT") + conn.commit() + + +def host_ip_map(config: Dict[str, Any]) -> Dict[str, str]: + mapping: Dict[str, str] = {} + for host in config.get("hosts", []) or []: + if not isinstance(host, dict): + continue + name = (host.get("name") or "").strip() + ip = (host.get("ip") or "").strip() + if name and ip: + mapping[name.lower()] = ip + return mapping + + +def main() -> None: + config = load_config() + db_path = Path(config.get("database", {}).get("path", "./data/db.sqlite")) + + if not db_path.exists(): + raise FileNotFoundError(f"Base de données introuvable: {db_path}") + + host_map = host_ip_map(config) + conn = sqlite3.connect(db_path) + try: + ensure_columns(conn) + + cursor = conn.execute("SELECT ip, host FROM ip") + rows = cursor.fetchall() + + updated = 0 + skipped = 0 + for ip_address, host in rows: + if not host: + skipped += 1 + continue + parent_ip = host_map.get(str(host).lower()) + if not parent_ip: + print(f"[WARN] host sans IP config: {host} (ip {ip_address})") + skipped += 1 + continue + conn.execute("UPDATE ip SET ip_parent = ? WHERE ip = ?", (parent_ip, ip_address)) + updated += 1 + + print(f"[INFO] ip_parent mis à jour: {updated} | ignorés: {skipped}") + + config_by_ip = {ip for ip in host_map.values()} + parent_children: Dict[str, list[str]] = {} + for ip_address, host in rows: + host_value = (host or "").strip() + if host_value in config_by_ip: + parent_children.setdefault(host_value, []).append(ip_address) + + for parent_ip in config_by_ip: + children = parent_children.get(parent_ip, []) + conn.execute( + "UPDATE ip SET ip_enfant = ? WHERE ip = ?", + (json.dumps(children), parent_ip) + ) + + conn.commit() + print("[INFO] ip_enfant recalculé.") + finally: + conn.close() + + +if __name__ == "__main__": + main() diff --git a/backend/app/scripts/update_ip_relations.py b/backend/app/scripts/update_ip_relations.py new file mode 100644 index 0000000..5051388 --- /dev/null +++ b/backend/app/scripts/update_ip_relations.py @@ -0,0 +1,135 @@ +""" +Met à jour la base IP avec les champs ip_parent/ip_enfant depuis config.yaml. +Ajoute les colonnes si nécessaire et synchronise les relations. +""" +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path +from typing import Any, Dict, List, Optional + +import yaml + + +CONFIG_PATH = Path(__file__).resolve().parents[3] / "config.yaml" + + +def load_config() -> Dict[str, Any]: + with CONFIG_PATH.open("r", encoding="utf-8") as handle: + return yaml.safe_load(handle) or {} + + +def normalize_children(value: Any) -> Optional[List[str]]: + if value is None: + return None + if isinstance(value, str): + return [value] if value else [] + if isinstance(value, list): + return [str(item) for item in value if item] + return [] + + +def ensure_columns(conn: sqlite3.Connection) -> None: + cursor = conn.execute("PRAGMA table_info(ip)") + columns = {row[1] for row in cursor.fetchall()} + if "ip_parent" not in columns: + conn.execute("ALTER TABLE ip ADD COLUMN ip_parent TEXT") + if "ip_enfant" not in columns: + conn.execute("ALTER TABLE ip ADD COLUMN ip_enfant TEXT") + conn.commit() + + +def collect_config_mapping(config: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: + mapping: Dict[str, Dict[str, Any]] = {} + + ip_classes = config.get("ip_classes", {}) or {} + for ip_address, data in ip_classes.items(): + if not isinstance(data, dict): + continue + if "ip_parent" in data or "ip_enfant" in data: + mapping[ip_address] = { + "ip_parent": data.get("ip_parent"), + "ip_enfant": normalize_children(data.get("ip_enfant")) + } + + for host in config.get("hosts", []) or []: + if not isinstance(host, dict): + continue + ip_address = host.get("ip") + if not ip_address: + continue + if "ip_parent" in host or "ip_enfant" in host: + entry = mapping.setdefault(ip_address, {}) + entry.setdefault("ip_parent", host.get("ip_parent")) + entry.setdefault("ip_enfant", normalize_children(host.get("ip_enfant"))) + + return mapping + + +def parse_json_list(value: Optional[str]) -> List[str]: + if not value: + return [] + try: + parsed = json.loads(value) + except json.JSONDecodeError: + return [] + if isinstance(parsed, list): + return [str(item) for item in parsed if item] + return [] + + +def main() -> None: + config = load_config() + db_path = Path(config.get("database", {}).get("path", "./data/db.sqlite")) + mapping = collect_config_mapping(config) + + if not db_path.exists(): + raise FileNotFoundError(f"Base de données introuvable: {db_path}") + + conn = sqlite3.connect(db_path) + try: + ensure_columns(conn) + + if mapping: + for ip_address, values in mapping.items(): + ip_parent = values.get("ip_parent") + ip_enfant = values.get("ip_enfant") + if ip_parent is None and ip_enfant is None: + continue + if ip_enfant is not None: + ip_enfant_json = json.dumps(ip_enfant) + conn.execute( + "UPDATE ip SET ip_parent = COALESCE(?, ip_parent), ip_enfant = ? WHERE ip = ?", + (ip_parent, ip_enfant_json, ip_address) + ) + else: + conn.execute( + "UPDATE ip SET ip_parent = COALESCE(?, ip_parent) WHERE ip = ?", + (ip_parent, ip_address) + ) + + cursor = conn.execute("SELECT ip, ip_parent, ip_enfant FROM ip") + rows = cursor.fetchall() + parent_children: Dict[str, List[str]] = {} + + for ip_address, ip_parent, _ in rows: + if ip_parent: + parent_children.setdefault(ip_parent, []).append(ip_address) + + for ip_address, _, ip_enfant_raw in rows: + existing = parse_json_list(ip_enfant_raw) + merged = list(dict.fromkeys(existing + parent_children.get(ip_address, []))) + conn.execute( + "UPDATE ip SET ip_enfant = ? WHERE ip = ?", + (json.dumps(merged), ip_address) + ) + + conn.commit() + print("Mise à jour terminée.") + finally: + conn.close() + + +if __name__ == "__main__": + main() diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100755 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/mqtt_client.py b/backend/app/services/mqtt_client.py new file mode 100644 index 0000000..62bc6e9 --- /dev/null +++ b/backend/app/services/mqtt_client.py @@ -0,0 +1,80 @@ +""" +Service MQTT pour IPWatch Backend +Envoie des commandes MQTT aux agents installés sur les machines +""" +import paho.mqtt.client as mqtt +import json +import logging +from typing import Optional +import os + +logger = logging.getLogger(__name__) + +# Configuration MQTT (à charger depuis config.yaml ou variables d'environnement) +MQTT_BROKER = os.getenv('MQTT_BROKER', 'localhost') +MQTT_PORT = int(os.getenv('MQTT_PORT', '1883')) +MQTT_USERNAME = os.getenv('MQTT_USERNAME', None) +MQTT_PASSWORD = os.getenv('MQTT_PASSWORD', None) + + +def send_mqtt_command(ip_address: str, command: str) -> bool: + """ + Envoie une commande MQTT à un équipement + + Args: + ip_address: Adresse IP de l'équipement + command: Commande à envoyer (shutdown, reboot, status) + + Returns: + bool: True si la commande a été envoyée avec succès + """ + try: + # Créer le client MQTT + client = mqtt.Client(client_id=f"ipwatch-backend-{os.getpid()}") + + # Authentification si configurée + if MQTT_USERNAME and MQTT_PASSWORD: + client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD) + + # Connexion au broker + client.connect(MQTT_BROKER, MQTT_PORT, keepalive=10) + + # Topic de commande pour l'équipement + topic = f"ipwatch/device/{ip_address}/command" + + # Payload JSON + payload = json.dumps({ + "command": command, + "timestamp": __import__('datetime').datetime.now().isoformat() + }) + + # Publier la commande + result = client.publish(topic, payload, qos=1) + + # Attendre que le message soit envoyé + result.wait_for_publish(timeout=5) + + # Déconnexion + client.disconnect() + + logger.info(f"✓ Commande '{command}' envoyée à {ip_address} via MQTT") + return result.is_published() + + except Exception as e: + logger.error(f"✗ Erreur envoi commande MQTT à {ip_address}: {e}") + return False + + +def get_device_status(ip_address: str) -> Optional[dict]: + """ + Récupère le statut d'un équipement via MQTT (si disponible) + + Args: + ip_address: Adresse IP de l'équipement + + Returns: + dict: Statut de l'équipement ou None + """ + # TODO: Implémenter la récupération du statut + # Nécessite un mécanisme de souscription et d'attente de réponse + pass diff --git a/backend/app/services/network.py b/backend/app/services/network.py new file mode 100755 index 0000000..223ea6b --- /dev/null +++ b/backend/app/services/network.py @@ -0,0 +1,365 @@ +""" +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 +from pathlib import Path + +# 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, ping_count: int = 1): + """ + Initialise le scanner réseau + + Args: + cidr: Réseau CIDR (ex: "192.168.1.0/24") + timeout: Timeout pour ping et connexions (secondes) + ping_count: Nombre de ping par IP + """ + self.cidr = cidr + self.timeout = timeout + self.ping_count = max(1, int(ping_count)) + 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, str(self.ping_count), + 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" + """ + mac_norm = re.sub(r"[^0-9A-Fa-f]", "", mac).upper() + if not mac_norm: + return "Unknown" + + # Lookup OUI si fichier disponible + vendor = OuiLookup.lookup(mac_norm) + if vendor: + return vendor + + # Mini DB des fabricants courants (fallback) + 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(): + prefix_norm = prefix.replace(":", "").upper() + if mac_norm.startswith(prefix_norm): + 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, progress_callback=None) -> 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 + progress_callback: Fonction optionnelle pour rapporter la progression + + 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() + total_ips = len(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 index, ip in enumerate(ip_list, start=1): + 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.now() 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 + + # Rapporter la progression + if progress_callback: + await progress_callback(index, total_ips, ip, ip_data["last_status"], is_online) + + return results + + +class OuiLookup: + """Lookup OUI basé sur un fichier local (oui.txt)""" + _cache = {} + _mtime = None + _path = Path("./data/oui/oui.txt") + + @classmethod + def _load(cls): + if not cls._path.exists(): + cls._cache = {} + cls._mtime = None + return + + mtime = cls._path.stat().st_mtime + if cls._mtime == mtime and cls._cache: + return + + cache = {} + try: + with cls._path.open("r", encoding="utf-8", errors="ignore") as handle: + for line in handle: + raw = line.strip() + if "(hex)" in raw: + left, right = raw.split("(hex)", 1) + prefix = re.sub(r"[^0-9A-Fa-f]", "", left).upper()[:6] + vendor = right.strip() + if len(prefix) == 6 and vendor: + cache[prefix] = vendor + except Exception: + cache = {} + + cls._cache = cache + cls._mtime = mtime + print(f"[OUI] Base chargée: {len(cls._cache)} entrées depuis {cls._path}") + + @classmethod + def lookup(cls, mac: str) -> Optional[str]: + if not mac: + return None + cls._load() + if not cls._cache: + return None + prefix = re.sub(r"[^0-9A-Fa-f]", "", mac).upper()[:6] + if len(prefix) != 6: + return None + return cls._cache.get(prefix) diff --git a/backend/app/services/opnsense_client.py b/backend/app/services/opnsense_client.py new file mode 100644 index 0000000..c423e73 --- /dev/null +++ b/backend/app/services/opnsense_client.py @@ -0,0 +1,194 @@ +""" +Client API OPNsense pour IPWatch +Gère les communications avec l'API REST OPNsense (Kea DHCP) +""" +import httpx +import ipaddress +from typing import Optional, Dict, Any, List +from backend.app.core.config import config_manager + + +class OPNsenseAPIError(Exception): + """Erreur retournée par l'API OPNsense (validation, etc.)""" + def __init__(self, message: str, validations: dict = None): + self.validations = validations or {} + super().__init__(message) + + +class OPNsenseClient: + """Client pour l'API OPNsense avec authentification Basic (api_key:api_secret)""" + + def __init__(self): + config = config_manager.config.opnsense + self.base_url = f"{config.protocol}://{config.host}" + self.auth = (config.api_key, config.api_secret) + self.verify_ssl = config.verify_ssl + self.enabled = config.enabled + print(f"[OPNsense] Client initialisé: {self.base_url} (ssl_verify={self.verify_ssl})") + + def _get_client(self) -> httpx.AsyncClient: + """Crée un client HTTP async configuré""" + return httpx.AsyncClient( + base_url=self.base_url, + auth=self.auth, + verify=self.verify_ssl, + timeout=30.0 + ) + + def _check_result(self, data: Dict[str, Any], action: str): + """Vérifie que le résultat OPNsense n'est pas 'failed'""" + if data.get("result") == "failed": + validations = data.get("validations", {}) + msg = f"{action} échoué" + if validations: + details = "; ".join(f"{k}: {v}" for k, v in validations.items()) + msg = f"{action} échoué: {details}" + print(f"[OPNsense] VALIDATION ERREUR: {msg}") + raise OPNsenseAPIError(msg, validations) + + async def test_connection(self) -> Dict[str, Any]: + """Teste la connexion à l'API OPNsense""" + print(f"[OPNsense] Test connexion: GET {self.base_url}/api/core/firmware/status") + async with self._get_client() as client: + response = await client.get("/api/core/firmware/status") + print(f"[OPNsense] Réponse test: {response.status_code}") + response.raise_for_status() + return response.json() + + async def search_subnets(self) -> Dict[str, Any]: + """Liste les subnets Kea DHCPv4""" + print(f"[OPNsense] Recherche subnets: GET {self.base_url}/api/kea/dhcpv4/search_subnet") + async with self._get_client() as client: + response = await client.get("/api/kea/dhcpv4/search_subnet") + print(f"[OPNsense] Réponse search_subnet: {response.status_code}") + if response.status_code != 200: + print(f"[OPNsense] Corps réponse erreur: {response.text[:500]}") + response.raise_for_status() + data = response.json() + rows = data.get("rows", []) + print(f"[OPNsense] {len(rows)} subnet(s) trouvé(s)") + for row in rows: + print(f"[OPNsense] - {row.get('subnet')}: uuid={row.get('uuid')}") + return data + + async def find_subnet_for_ip(self, ip_address: str) -> Optional[str]: + """Trouve le subnet UUID correspondant à une adresse IP""" + print(f"[OPNsense] Recherche subnet pour IP {ip_address}") + ip_obj = ipaddress.ip_address(ip_address) + data = await self.search_subnets() + rows = data.get("rows", []) + for row in rows: + subnet_cidr = row.get("subnet", "") + try: + network = ipaddress.ip_network(subnet_cidr, strict=False) + if ip_obj in network: + uuid = row.get("uuid") + print(f"[OPNsense] Subnet trouvé: {subnet_cidr} -> uuid={uuid}") + return uuid + except ValueError: + continue + print(f"[OPNsense] Aucun subnet trouvé pour {ip_address}") + return None + + async def search_reservations(self) -> Dict[str, Any]: + """Liste toutes les réservations DHCP Kea""" + print(f"[OPNsense] Recherche réservations: GET {self.base_url}/api/kea/dhcpv4/search_reservation") + async with self._get_client() as client: + response = await client.get("/api/kea/dhcpv4/search_reservation") + print(f"[OPNsense] Réponse search_reservation: {response.status_code}") + if response.status_code != 200: + print(f"[OPNsense] Corps réponse erreur: {response.text[:500]}") + response.raise_for_status() + data = response.json() + rows = data.get("rows", []) + print(f"[OPNsense] {len(rows)} réservation(s) trouvée(s)") + return data + + async def get_reservation(self, uuid: str) -> Dict[str, Any]: + """Récupère une réservation par UUID""" + print(f"[OPNsense] Get réservation: {uuid}") + async with self._get_client() as client: + response = await client.get(f"/api/kea/dhcpv4/get_reservation/{uuid}") + print(f"[OPNsense] Réponse get_reservation: {response.status_code}") + response.raise_for_status() + return response.json() + + async def add_reservation(self, data: Dict[str, Any]) -> Dict[str, Any]: + """Crée une nouvelle réservation DHCP Kea""" + payload = {"reservation": data} + print(f"[OPNsense] Ajout réservation: POST {self.base_url}/api/kea/dhcpv4/add_reservation") + print(f"[OPNsense] Payload: {payload}") + async with self._get_client() as client: + response = await client.post( + "/api/kea/dhcpv4/add_reservation", + json=payload + ) + print(f"[OPNsense] Réponse add_reservation: {response.status_code}") + print(f"[OPNsense] Corps réponse: {response.text[:500]}") + response.raise_for_status() + result = response.json() + self._check_result(result, "Ajout réservation") + return result + + async def set_reservation(self, uuid: str, data: Dict[str, Any]) -> Dict[str, Any]: + """Met à jour une réservation existante""" + payload = {"reservation": data} + print(f"[OPNsense] Mise à jour réservation {uuid}: POST {self.base_url}/api/kea/dhcpv4/set_reservation/{uuid}") + print(f"[OPNsense] Payload: {payload}") + async with self._get_client() as client: + response = await client.post( + f"/api/kea/dhcpv4/set_reservation/{uuid}", + json=payload + ) + print(f"[OPNsense] Réponse set_reservation: {response.status_code}") + print(f"[OPNsense] Corps réponse: {response.text[:500]}") + response.raise_for_status() + result = response.json() + self._check_result(result, "Mise à jour réservation") + return result + + async def del_reservation(self, uuid: str) -> Dict[str, Any]: + """Supprime une réservation""" + print(f"[OPNsense] Suppression réservation: {uuid}") + async with self._get_client() as client: + response = await client.post(f"/api/kea/dhcpv4/del_reservation/{uuid}") + print(f"[OPNsense] Réponse del_reservation: {response.status_code}") + response.raise_for_status() + return response.json() + + async def reconfigure_kea(self) -> Dict[str, Any]: + """Applique les changements Kea (reconfigure le service)""" + print(f"[OPNsense] Reconfiguration Kea: POST {self.base_url}/api/kea/service/reconfigure") + async with self._get_client() as client: + response = await client.post("/api/kea/service/reconfigure") + print(f"[OPNsense] Réponse reconfigure: {response.status_code}") + if response.status_code != 200: + print(f"[OPNsense] Corps réponse erreur: {response.text[:500]}") + response.raise_for_status() + return response.json() + + async def find_reservation_by_ip(self, ip_address: str) -> Optional[Dict[str, Any]]: + """Cherche une réservation existante par adresse IP""" + print(f"[OPNsense] Recherche réservation par IP: {ip_address}") + result = await self.search_reservations() + rows = result.get("rows", []) + for row in rows: + if row.get("ip_address") == ip_address: + print(f"[OPNsense] Réservation trouvée: uuid={row.get('uuid')}") + return row + print(f"[OPNsense] Aucune réservation existante pour {ip_address}") + return None + + async def find_reservation_by_mac(self, mac_address: str) -> Optional[Dict[str, Any]]: + """Cherche une réservation existante par adresse MAC""" + mac_normalized = mac_address.lower().replace("-", ":") + print(f"[OPNsense] Recherche réservation par MAC: {mac_normalized}") + result = await self.search_reservations() + rows = result.get("rows", []) + for row in rows: + row_mac = (row.get("hw_address") or "").lower().replace("-", ":") + if row_mac == mac_normalized: + print(f"[OPNsense] Réservation trouvée par MAC: uuid={row.get('uuid')}") + return row + print(f"[OPNsense] Aucune réservation pour MAC {mac_normalized}") + return None diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py new file mode 100755 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 100755 index 0000000..cf61ce3 --- /dev/null +++ b/backend/app/services/websocket.py @@ -0,0 +1,146 @@ +""" +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.now().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')}" + }) + + async def broadcast_scan_progress(self, progress_data: Dict[str, Any]): + """ + Notifie la progression d'un scan en cours + + Args: + progress_data: Données de progression (current, total, ip) + """ + await self.broadcast({ + "type": "scan_progress", + "current": progress_data.get("current"), + "total": progress_data.get("total"), + "ip": progress_data.get("ip") + }) + + async def broadcast_scan_log(self, message: str): + """Diffuse une ligne de log de scan""" + await self.broadcast({ + "type": "scan_log", + "message": message + }) + + +# Instance globale du gestionnaire WebSocket +ws_manager = WebSocketManager() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100755 index 0000000..c402d8e --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,19 @@ +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 +psutil==5.9.8 +wakeonlan==3.1.0 +paho-mqtt==1.6.1 diff --git a/capture/apple-touch-icon.png b/capture/apple-touch-icon.png new file mode 100644 index 0000000..b352879 Binary files /dev/null and b/capture/apple-touch-icon.png differ diff --git a/capture/favicon/android-chrome-192x192.png b/capture/favicon/android-chrome-192x192.png new file mode 100644 index 0000000..997ed1f Binary files /dev/null and b/capture/favicon/android-chrome-192x192.png differ diff --git a/capture/favicon/android-chrome-512x512.png b/capture/favicon/android-chrome-512x512.png new file mode 100644 index 0000000..e712ba7 Binary files /dev/null and b/capture/favicon/android-chrome-512x512.png differ diff --git a/capture/favicon/apple-touch-icon.png b/capture/favicon/apple-touch-icon.png new file mode 100644 index 0000000..f9f7c1d Binary files /dev/null and b/capture/favicon/apple-touch-icon.png differ diff --git a/capture/favicon/favicon-16x16.png b/capture/favicon/favicon-16x16.png new file mode 100644 index 0000000..0612a1d Binary files /dev/null and b/capture/favicon/favicon-16x16.png differ diff --git a/capture/favicon/favicon-32x32.png b/capture/favicon/favicon-32x32.png new file mode 100644 index 0000000..9e53530 Binary files /dev/null and b/capture/favicon/favicon-32x32.png differ diff --git a/capture/favicon/favicon.ico b/capture/favicon/favicon.ico new file mode 100644 index 0000000..a4f9604 Binary files /dev/null and b/capture/favicon/favicon.ico differ diff --git a/capture/favicon/site.webmanifest b/capture/favicon/site.webmanifest new file mode 100644 index 0000000..45dc8a2 --- /dev/null +++ b/capture/favicon/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/capture/hardware_benchtools.png b/capture/hardware_benchtools.png new file mode 100644 index 0000000..f4f97bf Binary files /dev/null and b/capture/hardware_benchtools.png differ diff --git a/capture/image copy.png b/capture/image copy.png new file mode 100644 index 0000000..d839efa Binary files /dev/null and b/capture/image copy.png differ diff --git a/capture/image.png b/capture/image.png new file mode 100644 index 0000000..b11b4b3 Binary files /dev/null and b/capture/image.png differ diff --git a/capture/image1.png b/capture/image1.png new file mode 100644 index 0000000..8addb6c Binary files /dev/null and b/capture/image1.png differ diff --git a/capture/ipwatch.png b/capture/ipwatch.png new file mode 100644 index 0000000..1475850 Binary files /dev/null and b/capture/ipwatch.png differ diff --git a/changelog.md b/changelog.md new file mode 100644 index 0000000..4c92939 --- /dev/null +++ b/changelog.md @@ -0,0 +1,17 @@ +# Changelog / Plan de déploiement + +## 2025-12-25 + +- [ ] Rebuild Docker (no-cache) + redémarrage pour charger les nouvelles routes backend. +- [ ] Lancer les migrations DB : + - `backend/app/migrations/add_vm_field.py` + - `backend/app/migrations/add_hardware_bench_field.py` + - `backend/app/migrations/add_icon_fields.py` +- [ ] Vérifier `/api/ips/icons` et le montage `/icons/*`. +- [ ] Tester popup icône (liste, upload, sélection, lien, validation). +- [ ] Vérifier l’affichage de l’icône associée dans le volet gauche. +- [ ] Tester la recherche (tags, inversion, accents, multi-termes). +- [ ] Tester l’édition réseau (MAC/Fabricant/Hostname) + copie clipboard. +- [ ] Vérifier la page `/architecture` (squelette). +- [ ] Vérifier la page `/test` (squelette). +- [ ] Mettre à jour la base OUI via Paramètres et vérifier mise à jour des fabricants. diff --git a/config.yaml b/config.yaml new file mode 100755 index 0000000..66bbfd7 --- /dev/null +++ b/config.yaml @@ -0,0 +1,179 @@ +# Configuration IPWatch +# Basé sur consigne-parametrage.md + +app: + name: "IPWatch" + version: "1.0.6" + 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.0" + end: "10.0.0.255" + description: "Machines virtuelles statiques" + - name: "dhcp" + cidr: "10.0.1.0/24" + start: "10.0.1.0" + end: "10.0.1.255" + description: "DHCP" + - name: "iot" + cidr: "10.0.2.0/24" + start: "10.0.2.0" + end: "10.0.2.255" + description: "IoT" + - name: "autres" + cidr: "10.0.3.0/24" + start: "10.0.3.0" + end: "10.0.3.254" + description: "autres" + +# IPs connues avec métadonnées +ip_classes: + "10.0.0.1": + name: "Gateway" + location: "Réseau" + host: "Network" # Type Network pour affichage spécial (bordure bleue) + +scan: + ping_interval: 600 # Intervalle scan ping (secondes) + ping_count: 3 # Nombre de ping par IP + port_scan_interval: 1200 # Intervalle scan ports (secondes) + parallel_pings: 100 # Nombre de pings simultanés + timeout: 1.0 # Timeout réseau (secondes) + force_vendor_update: true # Écrase le fabricant à chaque scan si true + +ports: + ranges: + - "22" # SSH + - "80" # HTTP + - "443" # HTTPS + - "445" # SAMBA + - "1880" #nodered + - "3000" + - "3389" # RDP + - "8123" #home assistant + - "8080" # HTTP alternatif + - "8006" # proxmox + - "8007" # proxmox backup center + - "8081" # HTTP alternatif + - "8096" #jellyfin + - "9090" + - "3306" # MySQL + - "3552" #arcane + - "5432" # PostgreSQL + - "6053" + - "8266" + - "9000" + + # Mapping port -> protocole pour générer des liens cliquables + protocols: + 22: "ssh" + 80: "http" + 443: "https" + 445: "smb" + 1880: "http" + 3000: "http" + 3306: "mysql" + 3389: "rdp" + 3552: "http" + 5432: "postgresql" + 6053: "http" + 8006: "https" + 8007: "https" + 8080: "http" + 8081: "http" + 8096: "http" + 8266: "http" + 9000: "http" + 9090: "https" + +locations: + - "Bureau" + - "Salon" + - "Comble" + - "Bureau RdC" + - "Garage" + - "Exterieur" + - "SdB" + - "" + +# Hosts avec localisation associée +hosts: + - name: "physique" + location: "" + ip: "" + - name: "smartphone" + location: "" + ip: "" + - name: "elitedesk" + location: "Comble" + ip: "10.0.0.101" + - name: "m710Q" + location: "Bureau RdC" + ip: "10.0.1.232" + - name: "HP Proliant" + location: "Bureau RdC" + ip: "10.0.0.205" + - name: "pve MSI" + location: "Bureau RdC" + ip: "10.0.1.174" + - name: "HP Proxmox" + location: "Bureau" + ip: "10.0.0.x" + - name: "Lenovo Bureau" + location: "Bureau" + - name: "Pve Dell" + location: "Comble" + ip: "10.0.1.228" + - name: "IoT" + location: "" + - name: "Network" + location: "" + + + +history: + retention_hours: 24 # Conserver 24h d'historique + +ui: + offline_transparency: 0.5 # Transparence des IPs offline + show_mac: true + show_vendor: true + cell_size: 30 # Taille des cellules IP en pixels (30, 35, 40...) + font_size: 12 # Taille de la police dans les cellules en pixels + cell_gap: 2.5 # Espacement entre les cellules en pixels + details_font_size: 13 # Taille de la police dans le volet détails en pixels + details_spacing: 2 # Espacement entre les champs du volet détails en pixels + architecture_title_font_size: 18 # Taille des titres Architecture (px) + +links: + hardware_bench_url: "http://10.0.0.50:8087/devices.html" + +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) + mac_changed: "#FD971F" # MAC address changée (orange - alerte) + network_device: "#1E3A8A" # Équipements réseau (bleu foncé) + +opnsense: + enabled: true + host: "10.0.0.1" + protocol: "http" + api_key: "ZOwL1iuko13l9tnARvJlU0s93C/44gFwZNRmStRhzGV8u6m2nXAcoOAbb6jxtkEe8dqzIjj4zECcKdzI" + api_secret: "rMOGHY+3SRfiT7cxpMoGZuwnPPRX0vPHV2oDTn6UPCvH87UXJe1qBkTs8y/ryG942TsTGe5UYO6F7fXK" + verify_ssl: false + +database: + path: "./data/db.sqlite" diff --git a/consigne-design_webui.md b/consigne-design_webui.md new file mode 100755 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 100755 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 100755 index 0000000..e7446e2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,43 @@ +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 l'architecture (ressources + projets) + - ./architecture:/app/architecture + + # 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 100755 index 0000000..0fcf074 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,19 @@ + + + + + + + + + + + + + IP Watch + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100755 index 0000000..0d1bbba --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2405 @@ +{ + "name": "ipwatch-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ipwatch-frontend", + "version": "1.0.0", + "dependencies": { + "@mdi/font": "^7.4.47", + "axios": "^1.6.5", + "pinia": "^2.1.7", + "vue": "^3.4.15" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.3", + "autoprefixer": "^10.4.17", + "postcss": "^8.4.33", + "tailwindcss": "^3.4.1", + "vite": "^5.0.11" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mdi/font": { + "version": "7.4.47", + "resolved": "https://registry.npmjs.org/@mdi/font/-/font-7.4.47.tgz", + "integrity": "sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw==" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz", + "integrity": "sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.25", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.25.tgz", + "integrity": "sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==", + "dependencies": { + "@vue/compiler-core": "3.5.25", + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.25.tgz", + "integrity": "sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag==", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.25", + "@vue/compiler-dom": "3.5.25", + "@vue/compiler-ssr": "3.5.25", + "@vue/shared": "3.5.25", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.25.tgz", + "integrity": "sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A==", + "dependencies": { + "@vue/compiler-dom": "3.5.25", + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.25.tgz", + "integrity": "sha512-5xfAypCQepv4Jog1U4zn8cZIcbKKFka3AgWHEFQeK65OW+Ys4XybP6z2kKgws4YB43KGpqp5D/K3go2UPPunLA==", + "dependencies": { + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.25.tgz", + "integrity": "sha512-Z751v203YWwYzy460bzsYQISDfPjHTl+6Zzwo/a3CsAf+0ccEjQ8c+0CdX1WsumRTHeywvyUFtW6KvNukT/smA==", + "dependencies": { + "@vue/reactivity": "3.5.25", + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.25.tgz", + "integrity": "sha512-a4WrkYFbb19i9pjkz38zJBg8wa/rboNERq3+hRRb0dHiJh13c+6kAbgqCPfMaJ2gg4weWD3APZswASOfmKwamA==", + "dependencies": { + "@vue/reactivity": "3.5.25", + "@vue/runtime-core": "3.5.25", + "@vue/shared": "3.5.25", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.25.tgz", + "integrity": "sha512-UJaXR54vMG61i8XNIzTSf2Q7MOqZHpp8+x3XLGtE3+fL+nQd+k7O5+X3D/uWrnQXOdMw5VPih+Uremcw+u1woQ==", + "dependencies": { + "@vue/compiler-ssr": "3.5.25", + "@vue/shared": "3.5.25" + }, + "peerDependencies": { + "vue": "3.5.25" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.25.tgz", + "integrity": "sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==" + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/autoprefixer": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", + "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.27.0", + "caniuse-lite": "^1.0.30001754", + "fraction.js": "^5.3.4", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.4.tgz", + "integrity": "sha512-ZCQ9GEWl73BVm8bu5Fts8nt7MHdbt5vY9bP6WGnUh+r3l8M7CgfyTlwsgCbMC66BNxPr6Xoce3j66Ms5YUQTNA==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001759", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz", + "integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.266", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.266.tgz", + "integrity": "sha512-kgWEglXvkEfMH7rxP5OSZZwnaDWT7J9EoZCujhnpLbfi0bbNtRkgdX2E3gt0Uer11c61qCYktB3hwkAS325sJg==", + "dev": true + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", + "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", + "dev": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, + "node_modules/update-browserslist-db": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", + "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz", + "integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==", + "dependencies": { + "@vue/compiler-dom": "3.5.25", + "@vue/compiler-sfc": "3.5.25", + "@vue/runtime-dom": "3.5.25", + "@vue/server-renderer": "3.5.25", + "@vue/shared": "3.5.25" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100755 index 0000000..dd55a47 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "ipwatch-frontend", + "version": "1.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@mdi/font": "^7.4.47", + "axios": "^1.6.5", + "pinia": "^2.1.7", + "vue": "^3.4.15", + "vue-router": "^4.2.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.3", + "autoprefixer": "^10.4.17", + "postcss": "^8.4.33", + "tailwindcss": "^3.4.1", + "vite": "^5.0.11" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100755 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/android-chrome-192x192.png b/frontend/public/android-chrome-192x192.png new file mode 100644 index 0000000..997ed1f Binary files /dev/null and b/frontend/public/android-chrome-192x192.png differ diff --git a/frontend/public/android-chrome-512x512.png b/frontend/public/android-chrome-512x512.png new file mode 100644 index 0000000..e712ba7 Binary files /dev/null and b/frontend/public/android-chrome-512x512.png differ diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 0000000..f9f7c1d Binary files /dev/null and b/frontend/public/apple-touch-icon.png differ diff --git a/frontend/public/favicon-16x16.png b/frontend/public/favicon-16x16.png new file mode 100644 index 0000000..0612a1d Binary files /dev/null and b/frontend/public/favicon-16x16.png differ diff --git a/frontend/public/favicon-32x32.png b/frontend/public/favicon-32x32.png new file mode 100644 index 0000000..9e53530 Binary files /dev/null and b/frontend/public/favicon-32x32.png differ diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..a4f9604 Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/site.webmanifest b/frontend/public/site.webmanifest new file mode 100644 index 0000000..45dc8a2 --- /dev/null +++ b/frontend/public/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100755 index 0000000..779df43 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,26 @@ + + + diff --git a/frontend/src/assets/hardware_benchtools.png b/frontend/src/assets/hardware_benchtools.png new file mode 100644 index 0000000..f4f97bf Binary files /dev/null and b/frontend/src/assets/hardware_benchtools.png differ diff --git a/frontend/src/assets/ipwatch-logo.png b/frontend/src/assets/ipwatch-logo.png new file mode 100644 index 0000000..997ed1f Binary files /dev/null and b/frontend/src/assets/ipwatch-logo.png differ diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css new file mode 100755 index 0000000..676eda5 --- /dev/null +++ b/frontend/src/assets/main.css @@ -0,0 +1,214 @@ +/* Styles principaux IPWatch - Thème Monokai */ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Variables CSS Monokai */ +:root { + --monokai-bg: #272822; + --monokai-text: #F8F8F2; + --monokai-comment: #30BF97; + --monokai-green: #A6E22E; + --monokai-pink: #F92672; + --monokai-cyan: #66D9EF; + --monokai-purple: #AE81FF; + --monokai-yellow: #E6DB74; + --monokai-orange: #FD971F; + --monokai-blue-dark: #1E3A8A; + --monokai-blue-light: #3B82F6; +} + +/* Base */ +body { + margin: 0; + padding: 0; + background-color: var(--monokai-bg); + color: var(--monokai-text); + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; +} + +.no-select { + user-select: none; +} + +/* 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; +} + +/* Grille des IPs avec espacement configurable */ +.ip-grid { + gap: var(--cell-gap, 2px); +} + +/* Cases IP compactes - Version minimale */ +.ip-cell-compact { + @apply rounded cursor-pointer transition-all duration-200 relative; + border: 1px solid; + width: var(--cell-size, 30px); + height: var(--cell-size, 30px); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: var(--font-size, 10px); +} + +/* 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; + position: relative; +} + +/* 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; + position: relative; +} + +/* Point jaune pour équipements réseau hors ligne - utilise ::after pour échapper à l'opacité du parent */ +.ip-cell-compact.network-device-offline::after { + content: ''; + position: absolute; + bottom: 0.25rem; + left: 50%; + transform: translateX(-50%); + width: 0.375rem; /* 1.5 = 6px */ + height: 0.375rem; + border-radius: 50%; + background-color: #FBBF24; + box-shadow: 0 0 6px #FBBF24, 0 0 10px rgba(251, 191, 36, 0.6); + opacity: 1 !important; + z-index: 100; + pointer-events: none; +} + +/* MAC address changée (orange - alerte) */ +.ip-cell.mac-changed, +.ip-cell-compact.mac-changed { + border-color: var(--monokai-orange) !important; + border-width: 2px !important; + border-style: solid !important; + box-shadow: 0 0 10px rgba(253, 151, 31, 0.5); +} + +/* Équipements réseau (switches, routeurs, bornes WiFi) */ +/* En ligne : bordure bleue foncée + fond bleu */ +.ip-cell.network-device-online, +.ip-cell-compact.network-device-online { + background-color: rgba(30, 58, 138, 0.25); + border-color: var(--monokai-blue-dark); + border-style: solid; + border-width: 3px; + color: var(--monokai-text); +} + +.ip-cell.network-device-online:hover, +.ip-cell-compact.network-device-online:hover { + background-color: rgba(30, 58, 138, 0.35); +} + +/* Hors ligne : utilise le style normal (offline-known/offline-unknown) + point bleu en bas */ +/* Le point bleu est affiché via le template HTML */ + +/* Sélection */ +.ip-cell.selected { + box-shadow: 0 0 20px rgba(230, 219, 116, 0.5); + border-color: var(--monokai-yellow); +} + +.ip-cell-compact.selected { + box-shadow: + inset 0 0 6px rgba(230, 219, 116, 0.9), + inset 0 0 12px rgba(230, 219, 116, 0.6), + 0 0 6px rgba(230, 219, 116, 0.2); + border-color: var(--monokai-yellow); + border-width: 2px; +} + +/* 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 100755 index 0000000..8db2ca7 --- /dev/null +++ b/frontend/src/components/AppHeader.vue @@ -0,0 +1,186 @@ + + + diff --git a/frontend/src/components/IPCell.vue b/frontend/src/components/IPCell.vue new file mode 100755 index 0000000..d5583c6 --- /dev/null +++ b/frontend/src/components/IPCell.vue @@ -0,0 +1,104 @@ + + + diff --git a/frontend/src/components/IPDetails.vue b/frontend/src/components/IPDetails.vue new file mode 100755 index 0000000..06e9e79 --- /dev/null +++ b/frontend/src/components/IPDetails.vue @@ -0,0 +1,1360 @@ + + + diff --git a/frontend/src/components/IPGrid.vue b/frontend/src/components/IPGrid.vue new file mode 100755 index 0000000..4e65cd3 --- /dev/null +++ b/frontend/src/components/IPGrid.vue @@ -0,0 +1,155 @@ + + + diff --git a/frontend/src/components/IPGridTree.vue b/frontend/src/components/IPGridTree.vue new file mode 100755 index 0000000..4d8d287 --- /dev/null +++ b/frontend/src/components/IPGridTree.vue @@ -0,0 +1,162 @@ + + + diff --git a/frontend/src/components/NewDetections.vue b/frontend/src/components/NewDetections.vue new file mode 100755 index 0000000..6af8831 --- /dev/null +++ b/frontend/src/components/NewDetections.vue @@ -0,0 +1,137 @@ + + + diff --git a/frontend/src/components/SettingsModal.vue b/frontend/src/components/SettingsModal.vue new file mode 100755 index 0000000..b584fd6 --- /dev/null +++ b/frontend/src/components/SettingsModal.vue @@ -0,0 +1,440 @@ + + + diff --git a/frontend/src/components/SystemStats.vue b/frontend/src/components/SystemStats.vue new file mode 100755 index 0000000..f44f0b4 --- /dev/null +++ b/frontend/src/components/SystemStats.vue @@ -0,0 +1,190 @@ + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100755 index 0000000..59f2adb --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,13 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import { router } from './router' +import App from './App.vue' +import './assets/main.css' +import '@mdi/font/css/materialdesignicons.css' + +const app = createApp(App) +const pinia = createPinia() + +app.use(pinia) +app.use(router) +app.mount('#app') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..a0212f3 --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,55 @@ +/** + * Configuration du router Vue Router pour IPWatch + * Gère la navigation entre la page principale et la page de suivi + */ +import { createRouter, createWebHistory } from 'vue-router' +import MainView from '@/views/MainView.vue' +import TrackingView from '@/views/TrackingView.vue' +import ArchitectureView from '@/views/ArchitectureView.vue' +import TestView from '@/views/TestView.vue' + +const routes = [ + { + path: '/', + name: 'main', + component: MainView, + meta: { + title: 'IPWatch - Scanner réseau' + } + }, + { + path: '/tracking', + name: 'tracking', + component: TrackingView, + meta: { + title: 'IPWatch - Équipements suivis' + } + }, + { + path: '/architecture', + name: 'architecture', + component: ArchitectureView, + meta: { + title: 'IPWatch - Architecture réseau' + } + }, + { + path: '/test', + name: 'test', + component: TestView, + meta: { + title: 'IPWatch - Tests réseau' + } + } +] + +export const router = createRouter({ + history: createWebHistory(), + routes +}) + +// Mise à jour du titre de page +router.beforeEach((to, from, next) => { + document.title = to.meta.title || 'IPWatch' + next() +}) diff --git a/frontend/src/stores/ipStore.js b/frontend/src/stores/ipStore.js new file mode 100755 index 0000000..77ae6f0 --- /dev/null +++ b/frontend/src/stores/ipStore.js @@ -0,0 +1,450 @@ +/** + * 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 + }) + const searchQuery = ref('') + const invertSearch = ref(false) + const lastScanDate = ref(null) + const scanProgress = ref({ + current: 0, + total: 0, + currentIP: null + }) + const scanLogs = ref([]) + const isScanning = ref(false) + const uiConfig = ref({ + cell_size: 30, + architecture_title_font_size: 18 + }) + const configReloadTick = ref(0) + + // WebSocket + const ws = ref(null) + const wsConnected = ref(false) + + // Computed + const filteredIPs = computed(() => { + const { tokens, flags } = parseSearchQuery(searchQuery.value) + + return ips.value.filter(ip => { + if (flags.requireOnline && ip.last_status !== 'online') return false + if (flags.requireOffline && ip.last_status !== 'offline') return false + if (flags.requireFree && ip.last_status) return false + if (flags.requireKnown && !ip.known) return false + if (flags.requireUnknown && ip.known) return false + if (flags.requireTracked && !ip.tracked) return false + if (flags.requireVm && !ip.vm) return false + if (flags.requireHardwareBench && !ip.hardware_bench) return false + + let matches = true + + if (tokens.length === 0) { + matches = true + } else { + const haystack = normalizeText([ + ip.ip, + ip.name, + ip.hostname, + ip.host, + ip.location, + ip.mac, + ip.vendor, + ip.link, + ip.last_status, + ip.tracked ? 'suivie' : '', + ip.vm ? 'vm' : '', + ip.hardware_bench ? 'hardware' : '', + (ip.open_ports || []).join(' ') + ].filter(Boolean).join(' ')) + + // Match si au moins un mot est présent + matches = tokens.some(token => haystack.includes(token)) + } + + return invertSearch.value ? !matches : matches + }) + }) + + // Actions + async function fetchUIConfig() { + try { + const response = await axios.get('/api/config/ui') + uiConfig.value = response.data + // Appliquer la taille des cellules, de la police et de l'espacement via variables CSS + document.documentElement.style.setProperty('--cell-size', `${response.data.cell_size}px`) + document.documentElement.style.setProperty('--font-size', `${response.data.font_size}px`) + document.documentElement.style.setProperty('--cell-gap', `${response.data.cell_gap}px`) + document.documentElement.style.setProperty( + '--arch-title-size', + `${response.data.architecture_title_font_size}px` + ) + } catch (err) { + console.error('Erreur chargement config UI:', err) + } + } + + async function reloadConfig() { + try { + const response = await axios.post('/api/config/reload') + if (response.data.success) { + // Appliquer la nouvelle config UI + uiConfig.value = response.data.ui + document.documentElement.style.setProperty('--cell-size', `${response.data.ui.cell_size}px`) + document.documentElement.style.setProperty('--font-size', `${response.data.ui.font_size}px`) + document.documentElement.style.setProperty('--cell-gap', `${response.data.ui.cell_gap}px`) + document.documentElement.style.setProperty( + '--arch-title-size', + `${response.data.ui.architecture_title_font_size}px` + ) + return response.data.message + } + } catch (err) { + console.error('Erreur rechargement config:', err) + throw err + } + } + + function bumpConfigReload() { + configReloadTick.value += 1 + } + + 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 deleteIP(ipAddress) { + try { + await axios.delete(`/api/ips/${ipAddress}`) + + // Retirer du store + const index = ips.value.findIndex(ip => ip.ip === ipAddress) + if (index !== -1) { + ips.value.splice(index, 1) + } + + if (selectedIP.value?.ip === ipAddress) { + selectedIP.value = null + } + + await fetchStats() + } 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) => { + // Ignorer les messages ping/pong (texte brut) + if (typeof event.data === 'string' && (event.data === 'ping' || event.data === 'pong')) { + return + } + + try { + const message = JSON.parse(event.data) + handleWebSocketMessage(message) + } catch (err) { + // Ignorer silencieusement les erreurs de parsing pour les messages non-JSON + } + } + + ws.value.onerror = (error) => { + // Erreur WebSocket - ne pas logger si c'est juste une déconnexion normale + wsConnected.value = false + } + + ws.value.onclose = (event) => { + wsConnected.value = false + + // Ne logger que si c'est une fermeture anormale + if (!event.wasClean) { + console.log('WebSocket déconnecté - reconnexion dans 5s...') + } + + // Reconnexion après 5s + setTimeout(() => { + // Ne reconnecter que si on n'est pas déjà connecté + if (!ws.value || ws.value.readyState === WebSocket.CLOSED) { + connectWebSocket() + } + }, 5000) + } + } + + function handleWebSocketMessage(message) { + // Logger uniquement les messages importants (pas scan_progress pour éviter le spam) + if (message.type !== 'scan_progress') { + console.log('WebSocket:', message.type) + } + + switch (message.type) { + case 'scan_start': + // Notification début de scan + isScanning.value = true + scanLogs.value = [] + scanProgress.value = { + current: 0, + total: message.total || 0, + currentIP: null + } + break + + case 'scan_progress': + // Progression du scan + if (message.current) scanProgress.value.current = message.current + if (message.total) scanProgress.value.total = message.total + if (message.ip) scanProgress.value.currentIP = message.ip + break + + case 'scan_complete': + // Rafraîchir les données après scan + isScanning.value = false + lastScanDate.value = new Date() + scanProgress.value = { current: 0, total: 0, currentIP: null } + fetchIPs() + if (message.stats) 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 + case 'scan_log': + if (message.message) { + scanLogs.value.push(message.message) + if (scanLogs.value.length > 200) { + scanLogs.value.splice(0, scanLogs.value.length - 200) + } + } + break + } + } + + function disconnectWebSocket() { + if (ws.value) { + ws.value.close() + ws.value = null + wsConnected.value = false + } + } + + return { + // État + ips, + selectedIP, + loading, + error, + stats, + searchQuery, + invertSearch, + wsConnected, + lastScanDate, + scanProgress, + scanLogs, + isScanning, + uiConfig, + configReloadTick, + + // Computed + filteredIPs, + + // Actions + fetchUIConfig, + reloadConfig, + bumpConfigReload, + fetchIPs, + fetchStats, + updateIP, + deleteIP, + getIPHistory, + startScan, + selectIP, + clearSelection, + connectWebSocket, + disconnectWebSocket + } +}) + +function normalizeText(value) { + return String(value ?? '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .trim() +} + +function parseSearchQuery(query) { + let normalized = normalizeText(query) + + const flags = { + requireOnline: false, + requireOffline: false, + requireKnown: false, + requireUnknown: false, + requireFree: false, + requireTracked: false, + requireVm: false, + requireHardwareBench: false + } + + const onlinePattern = /\ben\s+ligne\b/g + const offlinePattern = /\bhors\s+ligne\b/g + + if (onlinePattern.test(normalized)) flags.requireOnline = true + if (offlinePattern.test(normalized)) flags.requireOffline = true + + normalized = normalized + .replace(onlinePattern, ' ') + .replace(offlinePattern, ' ') + + let tokens = normalized.split(/\s+/).filter(Boolean) + + tokens = tokens.filter(token => { + if (token === 'connue') { + flags.requireKnown = true + return false + } + if (token === 'inconnue') { + flags.requireUnknown = true + return false + } + if (token === 'libre') { + flags.requireFree = true + return false + } + if (token === 'suivie' || token === 'suivi') { + flags.requireTracked = true + return false + } + if (token === 'vm') { + flags.requireVm = true + return false + } + if (token === 'hardware' || token === 'bench' || token === 'hardware_bench') { + flags.requireHardwareBench = true + return false + } + if (token === 'enligne' || token === 'en-ligne') { + flags.requireOnline = true + return false + } + if (token === 'horsligne' || token === 'hors-ligne') { + flags.requireOffline = true + return false + } + return true + }) + + return { tokens, flags } +} diff --git a/frontend/src/views/ArchitectureView.vue b/frontend/src/views/ArchitectureView.vue new file mode 100644 index 0000000..be884e6 --- /dev/null +++ b/frontend/src/views/ArchitectureView.vue @@ -0,0 +1,1542 @@ + + + diff --git a/frontend/src/views/MainView.vue b/frontend/src/views/MainView.vue new file mode 100644 index 0000000..580bcd4 --- /dev/null +++ b/frontend/src/views/MainView.vue @@ -0,0 +1,51 @@ + + + diff --git a/frontend/src/views/TestView.vue b/frontend/src/views/TestView.vue new file mode 100644 index 0000000..14197ce --- /dev/null +++ b/frontend/src/views/TestView.vue @@ -0,0 +1,151 @@ + + + diff --git a/frontend/src/views/TrackingView.vue b/frontend/src/views/TrackingView.vue new file mode 100644 index 0000000..8b00b44 --- /dev/null +++ b/frontend/src/views/TrackingView.vue @@ -0,0 +1,251 @@ + + + diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100755 index 0000000..65bea86 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,27 @@ +/** @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', + 'purple-dark': '#5E4B8C', // Violet foncé pour détections anciennes + yellow: '#E6DB74', + orange: '#FD971F', + }, + }, + }, + }, + plugins: [], +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100755 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 100755 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/image.png b/image.png new file mode 100644 index 0000000..559368d Binary files /dev/null and b/image.png differ diff --git a/images/Capture d’écran du 2025-12-23 06-32-20.png b/images/Capture d’écran du 2025-12-23 06-32-20.png new file mode 100755 index 0000000..559368d Binary files /dev/null and b/images/Capture d’écran du 2025-12-23 06-32-20.png differ diff --git a/ipscan.xml b/ipscan.xml new file mode 100755 index 0000000..0a65781 --- /dev/null +++ b/ipscan.xml @@ -0,0 +1,940 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/modele-donnees.md b/modele-donnees.md new file mode 100755 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/mqtt/README.md b/mqtt/README.md new file mode 100644 index 0000000..c57250b --- /dev/null +++ b/mqtt/README.md @@ -0,0 +1,337 @@ +# 🔌 IPWatch MQTT - Contrôle à distance des équipements + +Ce module permet de contrôler les équipements du réseau via MQTT (shutdown, reboot) de manière centralisée depuis IPWatch. + +## 📋 Vue d'ensemble + +L'architecture MQTT d'IPWatch permet de : +- **Éteindre** des machines à distance via commande MQTT +- **Redémarrer** des machines à distance via commande MQTT +- **Démarrer** des machines via Wake-on-LAN (WOL) +- **Monitorer** l'état des équipements en temps réel +- **Intégrer** avec Home Assistant pour la domotique + +## 🏗️ Architecture + +``` +┌─────────────────┐ MQTT ┌──────────────────┐ +│ IPWatch Web UI │ ──────────────────────► │ MQTT Broker │ +│ (Frontend) │ │ (Mosquitto) │ +└─────────────────┘ └──────────────────┘ + │ │ + │ HTTP API │ MQTT Topics + ▼ ▼ +┌─────────────────┐ ┌──────────────────┐ +│ IPWatch Backend │ │ MQTT Agents │ +│ (FastAPI) │ │ (sur machines) │ +└─────────────────┘ └──────────────────┘ +``` + +### Topics MQTT + +- **Commandes** : `ipwatch/device/{IP_ADDRESS}/command` +- **Statut** : `ipwatch/device/{IP_ADDRESS}/status` +- **Disponibilité** : `ipwatch/device/{IP_ADDRESS}/availability` +- **Réponses** : `ipwatch/device/{IP_ADDRESS}/response` + +### Format des messages + +**Commande** : +```json +{ + "command": "shutdown", // ou "reboot", "status" + "timestamp": "2025-12-23T10:30:00Z" +} +``` + +**Statut** : +```json +{ + "hostname": "server-01", + "ip": "192.168.1.100", + "platform": "Linux", + "uptime": 86400, + "cpu_percent": 45.2, + "memory_percent": 62.5, + "timestamp": "2025-12-23T10:30:00Z" +} +``` + +## 🚀 Installation + +### 1. Broker MQTT (serveur central) + +```bash +# Installer Mosquitto +sudo apt update +sudo apt install mosquitto mosquitto-clients + +# Démarrer le service +sudo systemctl enable mosquitto +sudo systemctl start mosquitto + +# Vérifier le statut +sudo systemctl status mosquitto +``` + +### 2. Agent MQTT (sur chaque machine à contrôler) + +```bash +# Copier l'agent +sudo cp mqtt/client/ipwatch_mqtt_agent.py /usr/local/bin/ +sudo chmod +x /usr/local/bin/ipwatch_mqtt_agent.py + +# Installer les dépendances Python +pip3 install paho-mqtt psutil netifaces + +# Créer le dossier de configuration +sudo mkdir -p /etc/ipwatch + +# Copier et éditer la configuration +sudo cp mqtt/client/mqtt-agent.conf.example /etc/ipwatch/mqtt-agent.conf +sudo nano /etc/ipwatch/mqtt-agent.conf +``` + +**Configuration** (`/etc/ipwatch/mqtt-agent.conf`) : +```ini +[mqtt] +broker = 192.168.1.10 # IP du serveur IPWatch +port = 1883 +username = # Optionnel +password = # Optionnel + +[agent] +hostname = auto +check_interval = 30 +``` + +### 3. Service systemd + +```bash +# Copier le service systemd +sudo cp mqtt/systemd/ipwatch-mqtt-agent.service /etc/systemd/system/ + +# Recharger systemd +sudo systemctl daemon-reload + +# Activer et démarrer le service +sudo systemctl enable ipwatch-mqtt-agent +sudo systemctl start ipwatch-mqtt-agent + +# Vérifier le statut +sudo systemctl status ipwatch-mqtt-agent + +# Voir les logs +sudo journalctl -u ipwatch-mqtt-agent -f +``` + +### 4. Configuration IPWatch Backend + +Dans le fichier `config.yaml` ou via variables d'environnement Docker : + +```yaml +mqtt: + broker: localhost + port: 1883 + username: "" + password: "" +``` + +Ou dans `docker-compose.yml` : +```yaml +environment: + - MQTT_BROKER=localhost + - MQTT_PORT=1883 + - MQTT_USERNAME= + - MQTT_PASSWORD= +``` + +## 🔧 Configuration sudo (IMPORTANT) + +Pour que l'agent puisse exécuter shutdown/reboot sans mot de passe : + +```bash +# Éditer sudoers +sudo visudo + +# Ajouter cette ligne (remplacer 'username' par l'utilisateur qui lance l'agent) +username ALL=(ALL) NOPASSWD: /sbin/shutdown, /sbin/reboot +``` + +Ou créer un fichier dédié : +```bash +echo "username ALL=(ALL) NOPASSWD: /sbin/shutdown, /sbin/reboot" | sudo tee /etc/sudoers.d/ipwatch-agent +sudo chmod 440 /etc/sudoers.d/ipwatch-agent +``` + +## 🧪 Tests + +### Test de l'agent + +```bash +# Mode test (affiche la config sans démarrer) +python3 /usr/local/bin/ipwatch_mqtt_agent.py --test + +# Démarrage manuel (pour debug) +python3 /usr/local/bin/ipwatch_mqtt_agent.py +``` + +### Test manuel des commandes MQTT + +```bash +# Publier une commande shutdown +mosquitto_pub -h localhost -t "ipwatch/device/192.168.1.100/command" -m '{"command":"shutdown"}' + +# Publier une commande reboot +mosquitto_pub -h localhost -t "ipwatch/device/192.168.1.100/command" -m '{"command":"reboot"}' + +# Demander le statut +mosquitto_pub -h localhost -t "ipwatch/device/192.168.1.100/command" -m '{"command":"status"}' + +# Écouter les réponses +mosquitto_sub -h localhost -t "ipwatch/device/192.168.1.100/#" -v +``` + +## 📊 Utilisation depuis IPWatch + +### Via l'interface Web + +1. Accédez à la page **"Suivi"** (`/tracking`) +2. Cliquez sur le bouton **"Éteindre"** (rose) pour shutdown +3. Cliquez sur le bouton **"WOL"** (vert) pour démarrer + +### Ajouter un bouton Reboot (optionnel) + +Modifier `frontend/src/views/TrackingView.vue` pour ajouter un bouton reboot : + +```vue + +``` + +Et la fonction JavaScript : +```javascript +async function rebootDevice(ip) { + if (!confirm(`Voulez-vous vraiment redémarrer ${ip.name || ip.ip} ?`)) return + + actionLoading.value[ip.ip] = 'reboot' + try { + await axios.post(`/api/tracking/reboot/${ip.ip}`) + alert(`✓ Commande reboot envoyée à ${ip.name || ip.ip}`) + setTimeout(() => fetchTrackedIPs(), 3000) + } catch (error) { + alert(`✗ Erreur reboot: ${error.response?.data?.detail || error.message}`) + } finally { + delete actionLoading.value[ip.ip] + } +} +``` + +## 🔐 Sécurité + +### Authentification MQTT + +```bash +# Créer un utilisateur MQTT +sudo mosquitto_passwd -c /etc/mosquitto/passwd ipwatch + +# Configurer Mosquitto pour l'authentification +sudo nano /etc/mosquitto/mosquitto.conf +``` + +Ajouter : +``` +allow_anonymous false +password_file /etc/mosquitto/passwd +``` + +Redémarrer Mosquitto : +```bash +sudo systemctl restart mosquitto +``` + +Mettre à jour la configuration des agents : +```ini +[mqtt] +broker = 192.168.1.10 +port = 1883 +username = ipwatch +password = VotreMotDePasse +``` + +### SSL/TLS (optionnel) + +Pour sécuriser les communications MQTT : + +```bash +# Générer un certificat +sudo openssl req -new -x509 -days 365 -extensions v3_ca -keyout /etc/mosquitto/ca.key -out /etc/mosquitto/ca.crt +``` + +Configuration Mosquitto avec SSL : +``` +listener 8883 +cafile /etc/mosquitto/ca.crt +certfile /etc/mosquitto/server.crt +keyfile /etc/mosquitto/server.key +``` + +## 📖 Documentation complémentaire + +- [MQTT_ARCHITECTURE.md](docs/MQTT_ARCHITECTURE.md) - Architecture détaillée +- [MQTT_CODING_GUIDELINES.md](docs/MQTT_CODING_GUIDELINES.md) - Consignes de développement +- [HOMEASSISTANT_SPEC.md](docs/HOMEASSISTANT_SPEC.md) - Intégration Home Assistant + +## 🐛 Dépannage + +### L'agent ne se connecte pas + +```bash +# Vérifier que Mosquitto écoute +sudo netstat -tlnp | grep 1883 + +# Vérifier les logs +sudo journalctl -u mosquitto -f + +# Tester la connexion +mosquitto_sub -h localhost -t "test" -v +``` + +### Les commandes ne sont pas exécutées + +```bash +# Vérifier les permissions sudo +sudo -l + +# Vérifier les logs de l'agent +sudo journalctl -u ipwatch-mqtt-agent -f + +# Tester manuellement +python3 /usr/local/bin/ipwatch_mqtt_agent.py +``` + +### Erreur "ModuleNotFoundError: No module named 'paho'" + +```bash +# Réinstaller paho-mqtt +pip3 install --upgrade paho-mqtt +``` + +## 📞 Support + +Pour toute question ou problème : +1. Consultez les logs : `sudo journalctl -u ipwatch-mqtt-agent -f` +2. Vérifiez la configuration : `python3 /usr/local/bin/ipwatch_mqtt_agent.py --test` +3. Testez manuellement avec `mosquitto_pub/sub` + +## 🔄 Compatibilité Home Assistant + +Ce système MQTT est conçu pour être compatible avec Home Assistant. Consultez [HOMEASSISTANT_SPEC.md](docs/HOMEASSISTANT_SPEC.md) pour l'intégration. + +Les topics MQTT suivent la convention MQTT Discovery de Home Assistant pour une auto-découverte automatique. diff --git a/mqtt/client/ipwatch_mqtt_agent.py b/mqtt/client/ipwatch_mqtt_agent.py new file mode 100644 index 0000000..6b30f6a --- /dev/null +++ b/mqtt/client/ipwatch_mqtt_agent.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python3 +""" +IPWatch MQTT Agent +Agent à installer sur chaque machine pour recevoir les commandes shutdown/reboot via MQTT + +Installation: + pip install paho-mqtt psutil netifaces + +Configuration: + Créer /etc/ipwatch/mqtt-agent.conf avec: + [mqtt] + broker = localhost + port = 1883 + username = + password = + + [agent] + hostname = auto + check_interval = 30 +""" + +import paho.mqtt.client as mqtt +import platform +import os +import sys +import subprocess +import json +import time +import socket +import configparser +import logging +from datetime import datetime +from pathlib import Path + +# Configuration du logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('/var/log/ipwatch-mqtt-agent.log'), + logging.StreamHandler() + ] +) +logger = logging.getLogger('ipwatch-mqtt-agent') + + +class IPWatchMQTTAgent: + """Agent MQTT pour recevoir les commandes de contrôle système""" + + def __init__(self, config_file='/etc/ipwatch/mqtt-agent.conf'): + self.config = self.load_config(config_file) + self.hostname = self.get_hostname() + self.ip_address = self.get_ip_address() + + # Topics MQTT + self.base_topic = f"ipwatch/device/{self.ip_address}" + self.command_topic = f"{self.base_topic}/command" + self.status_topic = f"{self.base_topic}/status" + self.availability_topic = f"{self.base_topic}/availability" + + # Client MQTT + self.client = mqtt.Client(client_id=f"ipwatch-agent-{self.hostname}") + self.client.on_connect = self.on_connect + self.client.on_message = self.on_message + self.client.on_disconnect = self.on_disconnect + + # Will message (si l'agent se déconnecte brutalement) + self.client.will_set( + self.availability_topic, + payload="offline", + qos=1, + retain=True + ) + + def load_config(self, config_file): + """Charge la configuration depuis le fichier""" + config = configparser.ConfigParser() + + if not Path(config_file).exists(): + logger.warning(f"Fichier de configuration {config_file} introuvable, utilisation des valeurs par défaut") + return { + 'broker': 'localhost', + 'port': 1883, + 'username': None, + 'password': None, + 'check_interval': 30 + } + + config.read(config_file) + + return { + 'broker': config.get('mqtt', 'broker', fallback='localhost'), + 'port': config.getint('mqtt', 'port', fallback=1883), + 'username': config.get('mqtt', 'username', fallback=None), + 'password': config.get('mqtt', 'password', fallback=None), + 'check_interval': config.getint('agent', 'check_interval', fallback=30) + } + + def get_hostname(self): + """Récupère le hostname de la machine""" + return platform.node() + + def get_ip_address(self): + """Récupère l'adresse IP principale de la machine""" + try: + # Créer une socket pour déterminer l'IP locale + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except Exception as e: + logger.error(f"Erreur récupération IP: {e}") + return "127.0.0.1" + + def on_connect(self, client, userdata, flags, rc): + """Callback lors de la connexion au broker MQTT""" + if rc == 0: + logger.info(f"✓ Connecté au broker MQTT {self.config['broker']}:{self.config['port']}") + + # S'abonner au topic des commandes + client.subscribe(self.command_topic) + client.subscribe(f"ipwatch/device/+/command") # Pour broadcast + logger.info(f"✓ Abonné à {self.command_topic}") + + # Publier la disponibilité + client.publish(self.availability_topic, "online", qos=1, retain=True) + + # Publier le statut initial + self.publish_status() + else: + logger.error(f"✗ Échec connexion MQTT, code: {rc}") + + def on_disconnect(self, client, userdata, rc): + """Callback lors de la déconnexion""" + if rc != 0: + logger.warning(f"⚠ Déconnexion inattendue du broker MQTT (code {rc})") + + def on_message(self, client, userdata, msg): + """Callback lors de la réception d'un message""" + try: + payload = msg.payload.decode('utf-8') + logger.info(f"→ Message reçu sur {msg.topic}: {payload}") + + # Parser le message JSON + try: + command_data = json.loads(payload) + command = command_data.get('command', payload) # Support format simple ou JSON + except json.JSONDecodeError: + command = payload # Format texte simple + + # Exécuter la commande + self.execute_command(command) + + except Exception as e: + logger.error(f"✗ Erreur traitement message: {e}") + + def execute_command(self, command): + """Exécute une commande système""" + logger.info(f"⚙ Exécution commande: {command}") + + try: + if command == "shutdown": + self.shutdown() + elif command == "reboot": + self.reboot() + elif command == "status": + self.publish_status() + else: + logger.warning(f"⚠ Commande inconnue: {command}") + self.publish_response(f"Commande inconnue: {command}", success=False) + + except Exception as e: + logger.error(f"✗ Erreur exécution commande: {e}") + self.publish_response(str(e), success=False) + + def shutdown(self): + """Éteint la machine""" + logger.warning("🔴 Shutdown demandé, arrêt dans 5 secondes...") + + self.publish_response("Shutdown en cours...", success=True) + time.sleep(1) + + # Publier offline avant l'arrêt + self.client.publish(self.availability_topic, "offline", qos=1, retain=True) + time.sleep(1) + + # Commande d'arrêt selon l'OS + if platform.system() == "Windows": + subprocess.run(["shutdown", "/s", "/t", "5"]) + else: + subprocess.run(["sudo", "shutdown", "-h", "+0"]) + + def reboot(self): + """Redémarre la machine""" + logger.warning("🔄 Reboot demandé, redémarrage dans 5 secondes...") + + self.publish_response("Reboot en cours...", success=True) + time.sleep(1) + + # Publier offline avant le redémarrage + self.client.publish(self.availability_topic, "offline", qos=1, retain=True) + time.sleep(1) + + # Commande de redémarrage selon l'OS + if platform.system() == "Windows": + subprocess.run(["shutdown", "/r", "/t", "5"]) + else: + subprocess.run(["sudo", "reboot"]) + + def publish_status(self): + """Publie le statut de la machine""" + try: + import psutil + + status = { + "hostname": self.hostname, + "ip": self.ip_address, + "platform": platform.system(), + "platform_version": platform.version(), + "uptime": time.time() - psutil.boot_time(), + "cpu_percent": psutil.cpu_percent(interval=1), + "memory_percent": psutil.virtual_memory().percent, + "disk_percent": psutil.disk_usage('/').percent, + "timestamp": datetime.now().isoformat() + } + + self.client.publish( + self.status_topic, + json.dumps(status), + qos=1, + retain=False + ) + logger.info(f"✓ Statut publié") + + except ImportError: + logger.warning("psutil non installé, statut limité") + status = { + "hostname": self.hostname, + "ip": self.ip_address, + "timestamp": datetime.now().isoformat() + } + self.client.publish(self.status_topic, json.dumps(status), qos=1) + + def publish_response(self, message, success=True): + """Publie une réponse sur le topic de statut""" + response = { + "success": success, + "message": message, + "timestamp": datetime.now().isoformat() + } + self.client.publish( + f"{self.base_topic}/response", + json.dumps(response), + qos=1 + ) + + def run(self): + """Démarre l'agent""" + try: + # Authentification si configurée + if self.config['username'] and self.config['password']: + self.client.username_pw_set( + self.config['username'], + self.config['password'] + ) + + # Connexion au broker + logger.info(f"→ Connexion au broker MQTT {self.config['broker']}:{self.config['port']}...") + self.client.connect( + self.config['broker'], + self.config['port'], + keepalive=60 + ) + + # Démarrer la boucle MQTT + self.client.loop_start() + + # Publier le statut périodiquement + logger.info(f"✓ Agent IPWatch MQTT démarré sur {self.ip_address}") + logger.info(f" Topics: {self.command_topic} | {self.status_topic}") + + while True: + time.sleep(self.config['check_interval']) + self.publish_status() + + except KeyboardInterrupt: + logger.info("\n→ Arrêt demandé par l'utilisateur") + self.stop() + except Exception as e: + logger.error(f"✗ Erreur fatale: {e}") + self.stop() + sys.exit(1) + + def stop(self): + """Arrête l'agent proprement""" + logger.info("→ Arrêt de l'agent...") + self.client.publish(self.availability_topic, "offline", qos=1, retain=True) + self.client.loop_stop() + self.client.disconnect() + logger.info("✓ Agent arrêté") + + +def main(): + """Point d'entrée principal""" + import argparse + + parser = argparse.ArgumentParser(description='IPWatch MQTT Agent') + parser.add_argument( + '-c', '--config', + default='/etc/ipwatch/mqtt-agent.conf', + help='Fichier de configuration (défaut: /etc/ipwatch/mqtt-agent.conf)' + ) + parser.add_argument( + '--test', + action='store_true', + help='Mode test (affiche la config et quitte)' + ) + + args = parser.parse_args() + + agent = IPWatchMQTTAgent(config_file=args.config) + + if args.test: + print(f"Configuration:") + print(f" Broker: {agent.config['broker']}:{agent.config['port']}") + print(f" Hostname: {agent.hostname}") + print(f" IP: {agent.ip_address}") + print(f" Command topic: {agent.command_topic}") + print(f" Status topic: {agent.status_topic}") + return + + agent.run() + + +if __name__ == "__main__": + main() diff --git a/mqtt/client/mqtt-agent.conf.example b/mqtt/client/mqtt-agent.conf.example new file mode 100644 index 0000000..57f5aeb --- /dev/null +++ b/mqtt/client/mqtt-agent.conf.example @@ -0,0 +1,17 @@ +[mqtt] +# Adresse du broker MQTT +broker = localhost + +# Port MQTT (défaut: 1883, SSL: 8883) +port = 1883 + +# Authentification (laisser vide si non utilisée) +username = +password = + +[agent] +# Hostname (auto = détection automatique) +hostname = auto + +# Intervalle de publication du statut (en secondes) +check_interval = 30 diff --git a/mqtt/docs/HOMEASSISTANT_SPEC.md b/mqtt/docs/HOMEASSISTANT_SPEC.md new file mode 100644 index 0000000..66e47c5 --- /dev/null +++ b/mqtt/docs/HOMEASSISTANT_SPEC.md @@ -0,0 +1,935 @@ +# 🏠 Spécifications Home Assistant pour IPWatch MQTT + +Ce document définit les spécifications pour intégrer les équipements IPWatch dans Home Assistant via MQTT Discovery. + +## 1. Vue d'ensemble + +L'intégration Home Assistant permet de : +- **Détecter automatiquement** les équipements IPWatch via MQTT Discovery +- **Visualiser** l'état des équipements (online/offline) +- **Contrôler** les équipements (shutdown, reboot) +- **Monitorer** les métriques système (CPU, RAM, Disk, Uptime) +- **Créer des automatisations** basées sur l'état des équipements + +## 2. MQTT Discovery + +### 2.1 Principe + +MQTT Discovery permet à Home Assistant de détecter automatiquement les entités sans configuration manuelle. + +**Topic de découverte** : `homeassistant/{component}/ipwatch_{unique_id}/{object_id}/config` + +**Composants supportés** : +- `sensor` : Métriques système +- `binary_sensor` : État de disponibilité +- `button` : Actions (shutdown, reboot) + +### 2.2 Configuration Discovery pour Sensor + +L'agent MQTT publie automatiquement sa configuration au démarrage : + +**Topic** : `homeassistant/sensor/ipwatch_10_0_0_100/system/config` + +**Payload** : +```json +{ + "name": "Server 01 - Système", + "unique_id": "ipwatch_10_0_0_100_system", + "state_topic": "ipwatch/device/10.0.0.100/status", + "value_template": "{{ value_json.hostname }}", + "json_attributes_topic": "ipwatch/device/10.0.0.100/status", + "availability": { + "topic": "ipwatch/device/10.0.0.100/availability", + "payload_available": "online", + "payload_not_available": "offline" + }, + "device": { + "identifiers": ["ipwatch_10_0_0_100"], + "name": "Server 01", + "model": "IPWatch MQTT Agent v1.0", + "manufacturer": "IPWatch", + "sw_version": "1.0.0" + }, + "icon": "mdi:desktop-tower", + "qos": 1 +} +``` + +### 2.3 Configuration Discovery pour Binary Sensor (Availability) + +**Topic** : `homeassistant/binary_sensor/ipwatch_10_0_0_100/availability/config` + +**Payload** : +```json +{ + "name": "Server 01 - Disponibilité", + "unique_id": "ipwatch_10_0_0_100_availability", + "state_topic": "ipwatch/device/10.0.0.100/availability", + "payload_on": "online", + "payload_off": "offline", + "device_class": "connectivity", + "device": { + "identifiers": ["ipwatch_10_0_0_100"], + "name": "Server 01" + }, + "icon": "mdi:lan-connect", + "qos": 1 +} +``` + +### 2.4 Configuration Discovery pour Buttons + +**Shutdown Button** : + +**Topic** : `homeassistant/button/ipwatch_10_0_0_100/shutdown/config` + +**Payload** : +```json +{ + "name": "Server 01 - Shutdown", + "unique_id": "ipwatch_10_0_0_100_shutdown", + "command_topic": "ipwatch/device/10.0.0.100/command", + "payload_press": "{\"command\":\"shutdown\",\"timestamp\":\"{{ now().isoformat() }}\"}", + "availability": { + "topic": "ipwatch/device/10.0.0.100/availability", + "payload_available": "online", + "payload_not_available": "offline" + }, + "device": { + "identifiers": ["ipwatch_10_0_0_100"], + "name": "Server 01" + }, + "icon": "mdi:power-off", + "qos": 1 +} +``` + +**Reboot Button** : + +**Topic** : `homeassistant/button/ipwatch_10_0_0_100/reboot/config` + +**Payload** : +```json +{ + "name": "Server 01 - Reboot", + "unique_id": "ipwatch_10_0_0_100_reboot", + "command_topic": "ipwatch/device/10.0.0.100/command", + "payload_press": "{\"command\":\"reboot\",\"timestamp\":\"{{ now().isoformat() }}\"}", + "availability": { + "topic": "ipwatch/device/10.0.0.100/availability", + "payload_available": "online", + "payload_not_available": "offline" + }, + "device": { + "identifiers": ["ipwatch_10_0_0_100"], + "name": "Server 01" + }, + "icon": "mdi:restart", + "qos": 1 +} +``` + +## 3. Entités Créées dans Home Assistant + +### 3.1 Sensors (Métriques Système) + +Chaque équipement IPWatch crée automatiquement ces sensors : + +| Entity ID | Nom | Description | Attribut JSON | +|-----------|-----|-------------|---------------| +| `sensor.server_01_systeme` | Server 01 - Système | État général | Tous les attributs | +| `sensor.server_01_cpu` | Server 01 - CPU | Usage CPU | `cpu_percent` | +| `sensor.server_01_memory` | Server 01 - RAM | Usage mémoire | `memory_percent` | +| `sensor.server_01_disk` | Server 01 - Disk | Usage disque | `disk_percent` | +| `sensor.server_01_uptime` | Server 01 - Uptime | Temps de fonctionnement | `uptime` | + +**Exemple de valeur du sensor système** : +```json +{ + "hostname": "server-01", + "ip": "10.0.0.100", + "platform": "Linux", + "platform_version": "5.15.0-91-generic", + "uptime": 86400, + "cpu_percent": 45.2, + "memory_percent": 62.5, + "disk_percent": 78.3, + "timestamp": "2025-12-23T10:30:00Z" +} +``` + +### 3.2 Binary Sensors + +| Entity ID | Nom | État | Icon | +|-----------|-----|------|------| +| `binary_sensor.server_01_disponibilite` | Server 01 - Disponibilité | on/off | `mdi:lan-connect` | + +### 3.3 Buttons + +| Entity ID | Nom | Action | Icon | +|-----------|-----|--------|------| +| `button.server_01_shutdown` | Server 01 - Shutdown | Éteindre | `mdi:power-off` | +| `button.server_01_reboot` | Server 01 - Reboot | Redémarrer | `mdi:restart` | + +## 4. Configuration Home Assistant + +### 4.1 Configuration MQTT + +Dans `configuration.yaml` : + +```yaml +# Configuration MQTT +mqtt: + broker: localhost + port: 1883 + username: !secret mqtt_username + password: !secret mqtt_password + discovery: true + discovery_prefix: homeassistant + birth_message: + topic: 'homeassistant/status' + payload: 'online' + will_message: + topic: 'homeassistant/status' + payload: 'offline' +``` + +### 4.2 Secrets + +Dans `secrets.yaml` : + +```yaml +mqtt_username: ipwatch +mqtt_password: VotreMotDePasse +``` + +### 4.3 Templates pour Sensors Individuels (Optionnel) + +Si vous souhaitez créer des sensors séparés pour chaque métrique : + +```yaml +# Sensor CPU +mqtt: + - sensor: + name: "Server 01 CPU" + unique_id: ipwatch_10_0_0_100_cpu + state_topic: "ipwatch/device/10.0.0.100/status" + value_template: "{{ value_json.cpu_percent }}" + unit_of_measurement: "%" + icon: mdi:cpu-64-bit + availability: + topic: "ipwatch/device/10.0.0.100/availability" + payload_available: "online" + payload_not_available: "offline" + device: + identifiers: ["ipwatch_10_0_0_100"] + name: "Server 01" + + # Sensor RAM + - sensor: + name: "Server 01 Memory" + unique_id: ipwatch_10_0_0_100_memory + state_topic: "ipwatch/device/10.0.0.100/status" + value_template: "{{ value_json.memory_percent }}" + unit_of_measurement: "%" + icon: mdi:memory + availability: + topic: "ipwatch/device/10.0.0.100/availability" + device: + identifiers: ["ipwatch_10_0_0_100"] + + # Sensor Disk + - sensor: + name: "Server 01 Disk" + unique_id: ipwatch_10_0_0_100_disk + state_topic: "ipwatch/device/10.0.0.100/status" + value_template: "{{ value_json.disk_percent }}" + unit_of_measurement: "%" + icon: mdi:harddisk + availability: + topic: "ipwatch/device/10.0.0.100/availability" + device: + identifiers: ["ipwatch_10_0_0_100"] + + # Sensor Uptime + - sensor: + name: "Server 01 Uptime" + unique_id: ipwatch_10_0_0_100_uptime + state_topic: "ipwatch/device/10.0.0.100/status" + value_template: "{{ (value_json.uptime / 3600) | round(1) }}" + unit_of_measurement: "h" + icon: mdi:clock-outline + availability: + topic: "ipwatch/device/10.0.0.100/availability" + device: + identifiers: ["ipwatch_10_0_0_100"] +``` + +## 5. Lovelace UI Cards + +### 5.1 Entity Card (Simple) + +```yaml +type: entities +title: Server 01 +entities: + - entity: binary_sensor.server_01_disponibilite + name: Disponibilité + - entity: sensor.server_01_cpu + name: CPU + - entity: sensor.server_01_memory + name: RAM + - entity: sensor.server_01_disk + name: Disque + - entity: sensor.server_01_uptime + name: Uptime + - entity: button.server_01_shutdown + name: Éteindre + - entity: button.server_01_reboot + name: Redémarrer +``` + +### 5.2 Glance Card (Compact) + +```yaml +type: glance +title: Server 01 +entities: + - entity: binary_sensor.server_01_disponibilite + name: État + - entity: sensor.server_01_cpu + name: CPU + - entity: sensor.server_01_memory + name: RAM + - entity: sensor.server_01_disk + name: Disque +``` + +### 5.3 Gauge Card (Métriques visuelles) + +```yaml +type: vertical-stack +cards: + - type: gauge + entity: sensor.server_01_cpu + name: CPU + min: 0 + max: 100 + severity: + green: 0 + yellow: 60 + red: 80 + + - type: gauge + entity: sensor.server_01_memory + name: RAM + min: 0 + max: 100 + severity: + green: 0 + yellow: 70 + red: 90 + + - type: gauge + entity: sensor.server_01_disk + name: Disque + min: 0 + max: 100 + severity: + green: 0 + yellow: 80 + red: 95 +``` + +### 5.4 Custom Card (Markdown) + +```yaml +type: markdown +content: | + ## 🖥️ Server 01 + + **État** : {{ states('binary_sensor.server_01_disponibilite') }} + **CPU** : {{ states('sensor.server_01_cpu') }}% + **RAM** : {{ states('sensor.server_01_memory') }}% + **Disque** : {{ states('sensor.server_01_disk') }}% + **Uptime** : {{ states('sensor.server_01_uptime') }}h + + {% if is_state('binary_sensor.server_01_disponibilite', 'on') %} + ✅ Serveur en ligne + {% else %} + ❌ Serveur hors ligne + {% endif %} +``` + +### 5.5 Button Card (Actions rapides) + +```yaml +type: horizontal-stack +cards: + - type: button + entity: button.server_01_shutdown + name: Éteindre + icon: mdi:power-off + tap_action: + action: call-service + service: button.press + service_data: + entity_id: button.server_01_shutdown + hold_action: + action: none + + - type: button + entity: button.server_01_reboot + name: Redémarrer + icon: mdi:restart + tap_action: + action: call-service + service: button.press + service_data: + entity_id: button.server_01_reboot +``` + +## 6. Automatisations + +### 6.1 Alerte si serveur offline + +```yaml +alias: "Alert - Server 01 Offline" +description: Notification si serveur hors ligne +trigger: + - platform: state + entity_id: binary_sensor.server_01_disponibilite + to: "off" + for: + minutes: 5 +action: + - service: notify.mobile_app + data: + title: "⚠️ Serveur Offline" + message: "Server 01 (10.0.0.100) est hors ligne depuis 5 minutes" + data: + priority: high +mode: single +``` + +### 6.2 Alerte CPU élevé + +```yaml +alias: "Alert - Server 01 High CPU" +description: Notification si CPU > 90% +trigger: + - platform: numeric_state + entity_id: sensor.server_01_cpu + above: 90 + for: + minutes: 10 +condition: + - condition: state + entity_id: binary_sensor.server_01_disponibilite + state: "on" +action: + - service: notify.mobile_app + data: + title: "🔥 CPU élevé" + message: "Server 01 - CPU à {{ states('sensor.server_01_cpu') }}%" +mode: single +``` + +### 6.3 Shutdown programmé (extinction nocturne) + +```yaml +alias: "Scheduled Shutdown - Server 01" +description: Éteindre le serveur à 23h chaque soir +trigger: + - platform: time + at: "23:00:00" +condition: + - condition: state + entity_id: binary_sensor.server_01_disponibilite + state: "on" +action: + - service: button.press + target: + entity_id: button.server_01_shutdown +mode: single +``` + +### 6.4 Wake-on-LAN au démarrage de HA + +```yaml +alias: "WOL - Server 01 on HA Start" +description: Démarrer le serveur quand Home Assistant démarre +trigger: + - platform: homeassistant + event: start +action: + - service: wake_on_lan.send_magic_packet + data: + mac: "AA:BB:CC:DD:EE:FF" + broadcast_address: "192.168.1.255" +mode: single +``` + +## 7. Intégration Personnalisée (Custom Integration) + +### 7.1 Structure du Custom Component + +Pour créer une intégration personnalisée plus avancée : + +``` +custom_components/ +└── ipwatch/ + ├── __init__.py + ├── manifest.json + ├── config_flow.py + ├── const.py + ├── sensor.py + ├── binary_sensor.py + ├── button.py + └── strings.json +``` + +### 7.2 Manifest + +**`manifest.json`** : +```json +{ + "domain": "ipwatch", + "name": "IPWatch Network Monitor", + "version": "1.0.0", + "documentation": "https://github.com/your-repo/ipwatch-ha", + "requirements": ["paho-mqtt==1.6.1"], + "dependencies": ["mqtt"], + "codeowners": ["@yourusername"], + "iot_class": "local_push", + "config_flow": true +} +``` + +### 7.3 Constants + +**`const.py`** : +```python +"""Constants for IPWatch integration.""" +DOMAIN = "ipwatch" +CONF_MQTT_BROKER = "mqtt_broker" +CONF_MQTT_PORT = "mqtt_port" +CONF_DEVICE_IP = "device_ip" +CONF_DEVICE_NAME = "device_name" + +# MQTT Topics +TOPIC_PREFIX = "ipwatch/device" +TOPIC_COMMAND = "command" +TOPIC_STATUS = "status" +TOPIC_AVAILABILITY = "availability" +TOPIC_RESPONSE = "response" + +# Commands +COMMAND_SHUTDOWN = "shutdown" +COMMAND_REBOOT = "reboot" +COMMAND_STATUS = "status" + +# Platforms +PLATFORMS = ["sensor", "binary_sensor", "button"] +``` + +### 7.4 Config Flow (UI Configuration) + +**`config_flow.py`** : +```python +"""Config flow for IPWatch integration.""" +import voluptuous as vol +from homeassistant import config_entries +from homeassistant.core import callback +from .const import DOMAIN, CONF_DEVICE_IP, CONF_DEVICE_NAME + +class IPWatchConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for IPWatch.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + + if user_input is not None: + # Validate user input + await self.async_set_unique_id(user_input[CONF_DEVICE_IP]) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=user_input[CONF_DEVICE_NAME], + data=user_input + ) + + data_schema = vol.Schema({ + vol.Required(CONF_DEVICE_IP): str, + vol.Required(CONF_DEVICE_NAME): str, + }) + + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors + ) +``` + +### 7.5 Sensor Platform + +**`sensor.py`** : +```python +"""Sensor platform for IPWatch.""" +from homeassistant.components.sensor import SensorEntity +from homeassistant.core import callback +from .const import DOMAIN + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up IPWatch sensor based on a config entry.""" + device_ip = config_entry.data[CONF_DEVICE_IP] + device_name = config_entry.data[CONF_DEVICE_NAME] + + sensors = [ + IPWatchCPUSensor(device_ip, device_name), + IPWatchMemorySensor(device_ip, device_name), + IPWatchDiskSensor(device_ip, device_name), + IPWatchUptimeSensor(device_ip, device_name), + ] + + async_add_entities(sensors) + +class IPWatchCPUSensor(SensorEntity): + """Representation of IPWatch CPU sensor.""" + + def __init__(self, device_ip, device_name): + """Initialize the sensor.""" + self._device_ip = device_ip + self._device_name = device_name + self._state = None + self._available = False + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self._device_name} CPU" + + @property + def unique_id(self): + """Return a unique ID.""" + return f"ipwatch_{self._device_ip.replace('.', '_')}_cpu" + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return "%" + + @property + def icon(self): + """Return the icon.""" + return "mdi:cpu-64-bit" + + @property + def available(self): + """Return True if entity is available.""" + return self._available + + @callback + def _handle_status_update(self, msg): + """Handle MQTT status updates.""" + import json + payload = json.loads(msg.payload) + self._state = payload.get("cpu_percent") + self._available = True + self.async_write_ha_state() + + async def async_added_to_hass(self): + """Subscribe to MQTT topics.""" + await self.hass.components.mqtt.async_subscribe( + f"ipwatch/device/{self._device_ip}/status", + self._handle_status_update + ) +``` + +### 7.6 Button Platform + +**`button.py`** : +```python +"""Button platform for IPWatch.""" +from homeassistant.components.button import ButtonEntity +from .const import DOMAIN, COMMAND_SHUTDOWN, COMMAND_REBOOT +import json +from datetime import datetime + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up IPWatch buttons.""" + device_ip = config_entry.data[CONF_DEVICE_IP] + device_name = config_entry.data[CONF_DEVICE_NAME] + + buttons = [ + IPWatchShutdownButton(hass, device_ip, device_name), + IPWatchRebootButton(hass, device_ip, device_name), + ] + + async_add_entities(buttons) + +class IPWatchShutdownButton(ButtonEntity): + """Shutdown button for IPWatch device.""" + + def __init__(self, hass, device_ip, device_name): + """Initialize the button.""" + self.hass = hass + self._device_ip = device_ip + self._device_name = device_name + + @property + def name(self): + """Return the name.""" + return f"{self._device_name} Shutdown" + + @property + def unique_id(self): + """Return unique ID.""" + return f"ipwatch_{self._device_ip.replace('.', '_')}_shutdown" + + @property + def icon(self): + """Return icon.""" + return "mdi:power-off" + + async def async_press(self): + """Handle button press.""" + topic = f"ipwatch/device/{self._device_ip}/command" + payload = json.dumps({ + "command": COMMAND_SHUTDOWN, + "timestamp": datetime.now().isoformat() + }) + + await self.hass.components.mqtt.async_publish( + topic, + payload, + qos=1 + ) +``` + +## 8. Scripts Python pour Administration + +### 8.1 Script de Découverte Automatique + +**`scripts/publish_discovery.py`** : +```python +#!/usr/bin/env python3 +""" +Publie la configuration MQTT Discovery pour tous les équipements IPWatch +""" +import paho.mqtt.client as mqtt +import json +import sys + +def publish_discovery(broker, port, device_ip, device_name): + """Publie les configs Discovery pour un équipement""" + client = mqtt.Client() + client.connect(broker, port) + + unique_id = f"ipwatch_{device_ip.replace('.', '_')}" + + # Sensor système + config = { + "name": f"{device_name} - Système", + "unique_id": f"{unique_id}_system", + "state_topic": f"ipwatch/device/{device_ip}/status", + "value_template": "{{ value_json.hostname }}", + "json_attributes_topic": f"ipwatch/device/{device_ip}/status", + "availability": { + "topic": f"ipwatch/device/{device_ip}/availability", + "payload_available": "online", + "payload_not_available": "offline" + }, + "device": { + "identifiers": [unique_id], + "name": device_name, + "model": "IPWatch MQTT Agent v1.0", + "manufacturer": "IPWatch" + }, + "icon": "mdi:desktop-tower", + "qos": 1 + } + + topic = f"homeassistant/sensor/{unique_id}/system/config" + client.publish(topic, json.dumps(config), qos=1, retain=True) + print(f"✓ Published sensor config for {device_name}") + + # Binary sensor availability + config = { + "name": f"{device_name} - Disponibilité", + "unique_id": f"{unique_id}_availability", + "state_topic": f"ipwatch/device/{device_ip}/availability", + "payload_on": "online", + "payload_off": "offline", + "device_class": "connectivity", + "device": {"identifiers": [unique_id]}, + "icon": "mdi:lan-connect", + "qos": 1 + } + + topic = f"homeassistant/binary_sensor/{unique_id}/availability/config" + client.publish(topic, json.dumps(config), qos=1, retain=True) + print(f"✓ Published binary_sensor config for {device_name}") + + # Buttons + for cmd in ["shutdown", "reboot"]: + config = { + "name": f"{device_name} - {cmd.capitalize()}", + "unique_id": f"{unique_id}_{cmd}", + "command_topic": f"ipwatch/device/{device_ip}/command", + "payload_press": json.dumps({"command": cmd}), + "availability": { + "topic": f"ipwatch/device/{device_ip}/availability", + "payload_available": "online", + "payload_not_available": "offline" + }, + "device": {"identifiers": [unique_id]}, + "icon": f"mdi:{'power-off' if cmd == 'shutdown' else 'restart'}", + "qos": 1 + } + + topic = f"homeassistant/button/{unique_id}/{cmd}/config" + client.publish(topic, json.dumps(config), qos=1, retain=True) + print(f"✓ Published button {cmd} config for {device_name}") + + client.disconnect() + +if __name__ == "__main__": + if len(sys.argv) != 5: + print("Usage: python publish_discovery.py ") + sys.exit(1) + + publish_discovery(sys.argv[1], int(sys.argv[2]), sys.argv[3], sys.argv[4]) +``` + +**Utilisation** : +```bash +python3 scripts/publish_discovery.py localhost 1883 10.0.0.100 "Server 01" +``` + +## 9. Développement de l'App Home Assistant + +### 9.1 Recommandations + +Pour développer une application Home Assistant dédiée à IPWatch : + +1. **Utiliser MQTT Discovery** : Simplifie l'ajout d'équipements +2. **Créer un Custom Component** : Intégration native dans Home Assistant +3. **Implémenter Config Flow** : Configuration via UI (Settings → Integrations) +4. **Supporter Multiple Devices** : Un équipement IPWatch = un device HA +5. **Gérer les états** : Mapping clair entre MQTT et entités HA +6. **Ajouter des diagnostics** : Logs et debugging pour faciliter le support + +### 9.2 Fonctionnalités Avancées + +**WOL depuis Home Assistant** : +```yaml +# Configuration Wake-on-LAN +wake_on_lan: + +# Service call +service: wake_on_lan.send_magic_packet +data: + mac: "AA:BB:CC:DD:EE:FF" + broadcast_address: "192.168.1.255" +``` + +**Intégration avec Lovelace Dashboard** : +- Card personnalisée pour afficher grille IPWatch +- Actions rapides (shutdown, reboot, WOL) +- Graphiques historiques des métriques + +**Notifications Push** : +- Alertes sur changements d'état +- Notifications sur nouveaux équipements détectés + +## 10. Tests et Validation + +### 10.1 Tester Discovery + +```bash +# Écouter les messages Discovery +mosquitto_sub -h localhost -t "homeassistant/#" -v + +# Vérifier que Home Assistant a créé les entités +# Developer Tools → States +# Rechercher : sensor.server_01, binary_sensor.server_01, button.server_01 +``` + +### 10.2 Tester les Commandes + +```bash +# Tester shutdown via MQTT +mosquitto_pub -h localhost \ + -t "ipwatch/device/10.0.0.100/command" \ + -m '{"command":"shutdown","timestamp":"2025-12-23T10:30:00Z"}' + +# Vérifier la réponse +mosquitto_sub -h localhost \ + -t "ipwatch/device/10.0.0.100/response" -v +``` + +### 10.3 Tester les Automatisations + +1. Créer une automatisation simple +2. Déclencher manuellement +3. Vérifier les logs HA : Settings → System → Logs + +## 11. Dépannage + +### Entités non découvertes + +```bash +# Vérifier que Discovery est activé +# configuration.yaml +mqtt: + discovery: true + +# Republier les configs +python3 scripts/publish_discovery.py localhost 1883 10.0.0.100 "Server 01" + +# Redémarrer Home Assistant +``` + +### Boutons ne fonctionnent pas + +- Vérifier que l'agent MQTT est en ligne +- Vérifier les permissions sudo sur le client +- Consulter les logs de l'agent : `sudo journalctl -u ipwatch-mqtt-agent -f` + +### Sensors non mis à jour + +- Vérifier que le topic status est publié toutes les 30 secondes +- Vérifier le QoS (doit être 1) +- Vérifier la disponibilité du broker MQTT + +## 12. Références + +- **Home Assistant MQTT Discovery** : https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery +- **Home Assistant Developer Docs** : https://developers.home-assistant.io/ +- **MQTT Sensor** : https://www.home-assistant.io/integrations/sensor.mqtt/ +- **MQTT Button** : https://www.home-assistant.io/integrations/button.mqtt/ +- **Config Flow** : https://developers.home-assistant.io/docs/config_entries_config_flow_handler/ + +## 13. Checklist de Développement + +Avant de publier l'intégration Home Assistant : + +- [ ] MQTT Discovery implémenté pour tous les composants +- [ ] Config Flow pour configuration via UI +- [ ] Gestion des erreurs et retry automatique +- [ ] Documentation complète (README, HACS) +- [ ] Tests unitaires et d'intégration +- [ ] Validation HACS (Home Assistant Community Store) +- [ ] Icônes et traductions +- [ ] Changelog et versioning sémantique +- [ ] CI/CD pour tests automatisés +- [ ] Support multi-langues (i18n) diff --git a/mqtt/docs/MQTT_ARCHITECTURE.md b/mqtt/docs/MQTT_ARCHITECTURE.md new file mode 100644 index 0000000..abb9b0d --- /dev/null +++ b/mqtt/docs/MQTT_ARCHITECTURE.md @@ -0,0 +1,441 @@ +# 📐 Architecture MQTT pour IPWatch + +## 1. Vue d'ensemble + +L'architecture MQTT d'IPWatch implémente un système de contrôle à distance des équipements réseau basé sur le protocole MQTT (Message Queuing Telemetry Transport). + +### 1.1 Objectifs + +- **Contrôle centralisé** : Gérer shutdown/reboot depuis l'interface IPWatch +- **Légèreté** : Protocol MQTT léger et performant +- **Fiabilité** : QoS MQTT pour garantir la livraison des messages +- **Scalabilité** : Support de centaines d'équipements simultanément +- **Intégration** : Compatible Home Assistant et autres systèmes domotiques + +### 1.2 Composants + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ IPWatch Ecosystem │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───────────────┐ ┌──────────────┐ │ +│ │ Web Interface │────────►│ Backend │ │ +│ │ (Vue.js) │ HTTP │ (FastAPI) │ │ +│ └───────────────┘ API └──────┬───────┘ │ +│ │ │ +│ paho-mqtt (publish) │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ MQTT Broker (Mosquitto) │ │ +│ │ │ │ +│ │ Topics: │ │ +│ │ • ipwatch/device/{IP}/command │ │ +│ │ • ipwatch/device/{IP}/status │ │ +│ │ • ipwatch/device/{IP}/availability │ │ +│ └─────────────────┬───────────────────────────────┘ │ +│ │ MQTT (subscribe) │ +│ │ │ +│ ┌──────────┼──────────┬──────────┬──────────┐ │ +│ ▼ ▼ ▼ ▼ ▼ │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ Agent 1 │ │ Agent 2 │ │ Agent 3 │ │ ... │ │ Agent N │ │ +│ │ 10.0.0.1│ │ 10.0.0.2│ │ 10.0.0.3│ │ │ │ 10.0.0.N│ │ +│ │ │ │ │ │ │ │ │ │ │ │ +│ │ Python │ │ Python │ │ Python │ │ Python │ │ Python │ │ +│ │ systemd │ │ systemd │ │ systemd │ │ systemd │ │ systemd │ │ +│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## 2. Topics MQTT + +### 2.1 Hiérarchie des Topics + +``` +ipwatch/ +└── device/ + └── {IP_ADDRESS}/ + ├── command # Commandes envoyées par IPWatch + ├── status # Statut publié par l'agent + ├── availability # Disponibilité de l'agent (online/offline) + └── response # Réponses/acquittements de l'agent +``` + +### 2.2 Détail des Topics + +#### `ipwatch/device/{IP}/command` +- **Direction** : IPWatch Backend → Agent +- **QoS** : 1 (at least once) +- **Retain** : false +- **Payload** : JSON +- **Commandes supportées** : + - `shutdown` : Arrêt de la machine + - `reboot` : Redémarrage de la machine + - `status` : Demande de statut + +**Exemple** : +```json +{ + "command": "shutdown", + "timestamp": "2025-12-23T10:30:00Z" +} +``` + +#### `ipwatch/device/{IP}/status` +- **Direction** : Agent → IPWatch Backend +- **QoS** : 1 +- **Retain** : false +- **Payload** : JSON avec métriques système + +**Exemple** : +```json +{ + "hostname": "server-01", + "ip": "10.0.0.100", + "platform": "Linux", + "platform_version": "5.15.0-91-generic", + "uptime": 86400, + "cpu_percent": 45.2, + "memory_percent": 62.5, + "disk_percent": 78.3, + "timestamp": "2025-12-23T10:30:00Z" +} +``` + +#### `ipwatch/device/{IP}/availability` +- **Direction** : Agent → Tous +- **QoS** : 1 +- **Retain** : true (important!) +- **Payload** : Texte simple +- **Valeurs** : `online` | `offline` + +#### `ipwatch/device/{IP}/response` +- **Direction** : Agent → IPWatch Backend +- **QoS** : 1 +- **Retain** : false +- **Payload** : JSON avec résultat de la commande + +**Exemple** : +```json +{ + "success": true, + "message": "Shutdown en cours...", + "timestamp": "2025-12-23T10:30:05Z" +} +``` + +## 3. Flux de Communication + +### 3.1 Shutdown/Reboot + +```mermaid +sequenceDiagram + participant UI as IPWatch UI + participant BE as Backend + participant BR as MQTT Broker + participant AG as Agent (10.0.0.100) + + UI->>BE: POST /api/tracking/shutdown/10.0.0.100 + BE->>BR: PUBLISH ipwatch/device/10.0.0.100/command + Note over BR: QoS 1 + BR->>AG: {"command": "shutdown"} + AG->>BR: PUBLISH ipwatch/device/10.0.0.100/response + Note over AG: {"success": true, "message": "Shutdown en cours..."} + AG->>BR: PUBLISH ipwatch/device/10.0.0.100/availability + Note over AG: "offline" + AG->>AG: sudo shutdown -h now + BE-->>UI: 200 OK {"message": "Commande envoyée"} +``` + +### 3.2 Wake-on-LAN + +```mermaid +sequenceDiagram + participant UI as IPWatch UI + participant BE as Backend + participant AG as Agent (offline) + + UI->>BE: POST /api/tracking/wol/10.0.0.100 + BE->>BE: send_magic_packet(MAC) + Note over BE: Broadcast UDP + BE-->>UI: 200 OK + Note over AG: Machine démarre + AG->>AG: Agent systemd démarre + AG->>BR: CONNECT + AG->>BR: PUBLISH availability "online" + AG->>BR: PUBLISH status {...} +``` + +### 3.3 Monitoring périodique + +```mermaid +sequenceDiagram + participant AG as Agent + participant BR as MQTT Broker + participant HA as Home Assistant + + loop Chaque 30 secondes + AG->>BR: PUBLISH ipwatch/device/10.0.0.100/status + Note over AG: CPU, RAM, Disk, Uptime + BR->>HA: Forward status + Note over HA: Update sensors + end +``` + +## 4. Gestion des Erreurs + +### 4.1 Agent déconnecté + +**Problème** : L'agent perd la connexion MQTT + +**Solution** : +- **Last Will Testament** : Message `offline` publié automatiquement sur `availability` +- **Reconnexion automatique** : L'agent tente de se reconnecter (retry avec backoff) +- **QoS 1** : Garantit la livraison du message d'offline + +### 4.2 Commande non livrée + +**Problème** : La commande n'atteint pas l'agent + +**Solution** : +- **QoS 1** : Le broker MQTT garantit au moins une livraison +- **Timeout** : Le backend attend 5 secondes puis retourne une erreur +- **Retry** : L'utilisateur peut retenter l'opération + +### 4.3 Agent en erreur + +**Problème** : L'agent reçoit la commande mais ne peut l'exécuter + +**Solution** : +- **Message de réponse** : L'agent publie sur `response` avec `success: false` +- **Logging** : Logs détaillés dans journalctl +- **Notification** : L'UI affiche l'erreur à l'utilisateur + +## 5. Sécurité + +### 5.1 Authentification + +``` +┌──────────────────────┐ +│ MQTT Broker │ +│ (Mosquitto) │ +│ │ +│ password_file: │ +│ /etc/mosquitto/passwd│ +│ │ +│ Users: │ +│ • ipwatch (backend) │ +│ • agent (clients) │ +└──────────────────────┘ +``` + +**Configuration** : +```bash +# Créer les utilisateurs +mosquitto_passwd -c /etc/mosquitto/passwd ipwatch +mosquitto_passwd /etc/mosquitto/passwd agent + +# Dans mosquitto.conf +allow_anonymous false +password_file /etc/mosquitto/passwd +``` + +### 5.2 Chiffrement TLS/SSL + +``` +listener 8883 +cafile /etc/mosquitto/ca.crt +certfile /etc/mosquitto/server.crt +keyfile /etc/mosquitto/server.key +require_certificate false +``` + +### 5.3 ACL (Access Control List) + +``` +# /etc/mosquitto/acl +# Backend peut publier sur command +user ipwatch +topic write ipwatch/device/+/command + +# Agents peuvent publier sur status/response/availability +user agent +topic write ipwatch/device/+/status +topic write ipwatch/device/+/response +topic write ipwatch/device/+/availability +topic read ipwatch/device/+/command +``` + +## 6. Performance et Scalabilité + +### 6.1 Dimensionnement + +| Métrique | Valeur | Notes | +|----------|--------|-------| +| Agents max | 500 | Limité par le broker | +| Messages/sec | 1000 | Dépend du hardware | +| Latence | <100ms | Sur réseau local | +| Bande passante | ~10 KB/s par agent | Pour statut 30s | + +### 6.2 Optimisations + +**Payload compression** : +- JSON minifié (pas d'indentation) +- Champs courts +- Pas de données redondantes + +**Keep-alive** : +- 60 secondes (défaut) +- Réduit le trafic réseau + +**QoS adaptatif** : +- QoS 1 pour commandes critiques +- QoS 0 pour statut non-critique + +## 7. Compatibilité Home Assistant + +### 7.1 MQTT Discovery + +L'agent publie automatiquement sa configuration pour Home Assistant : + +```json +{ + "name": "Server 01", + "unique_id": "ipwatch_10_0_0_100", + "state_topic": "ipwatch/device/10.0.0.100/status", + "availability_topic": "ipwatch/device/10.0.0.100/availability", + "json_attributes_topic": "ipwatch/device/10.0.0.100/status", + "device": { + "identifiers": ["ipwatch_10_0_0_100"], + "name": "Server 01", + "model": "IPWatch MQTT Agent", + "manufacturer": "IPWatch" + } +} +``` + +**Topic Discovery** : +``` +homeassistant/sensor/ipwatch_10_0_0_100/config +``` + +### 7.2 Entités créées + +Home Assistant crée automatiquement : +- **Sensor** : CPU, RAM, Disk, Uptime +- **Binary Sensor** : Availability (online/offline) +- **Button** : Shutdown, Reboot + +## 8. Monitoring et Logs + +### 8.1 Logs Agent + +```bash +# Logs en temps réel +sudo journalctl -u ipwatch-mqtt-agent -f + +# Logs des dernières 24h +sudo journalctl -u ipwatch-mqtt-agent --since "24 hours ago" + +# Recherche d'erreurs +sudo journalctl -u ipwatch-mqtt-agent -p err +``` + +### 8.2 Logs Broker + +```bash +# Logs Mosquitto +sudo journalctl -u mosquitto -f + +# Activer le debug dans mosquitto.conf +log_type all +log_dest /var/log/mosquitto/mosquitto.log +``` + +### 8.3 Métriques + +**Collecte via MQTT** : +- Nombre de clients connectés +- Messages publiés/reçus +- Latence des messages + +**Grafana + Prometheus** (optionnel) : +- Dashboard pour visualiser les métriques +- Alertes sur déconnexions + +## 9. Diagrammes Détaillés + +### 9.1 État de l'Agent + +``` +┌─────────┐ +│ Start │ +└────┬────┘ + │ + ▼ +┌─────────────────┐ +│ Load Config │ +└────┬────────────┘ + │ + ▼ +┌─────────────────┐ +│ Connect MQTT │──┐ +│ Broker │ │ +└────┬────────────┘ │ + │ │ Retry + │ Success │ on error + ▼ │ +┌─────────────────┐ │ +│ Subscribe to │ │ +│ command topic │ │ +└────┬────────────┘ │ + │ │ + ▼ │ +┌─────────────────┐ │ +│ Publish │◄─┘ +│ availability │ +│ "online" │ +└────┬────────────┘ + │ + ▼ +┌─────────────────┐ +│ Main Loop │ +│ • Listen MQTT │ +│ • Publish status│ +│ every 30s │ +└────┬────────────┘ + │ + │ Receive shutdown + ▼ +┌─────────────────┐ +│ Publish │ +│ "offline" │ +└────┬────────────┘ + │ + ▼ +┌─────────────────┐ +│ Execute │ +│ shutdown │ +└─────────────────┘ +``` + +## 10. Références + +- **MQTT v3.1.1 Specification** : http://docs.oasis-open.org/mqtt/mqtt/v3.1.1/mqtt-v3.1.1.html +- **Mosquitto Documentation** : https://mosquitto.org/documentation/ +- **Paho MQTT Python** : https://github.com/eclipse/paho.mqtt.python +- **Home Assistant MQTT Discovery** : https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery + +## 11. TODO / Améliorations Futures + +- [ ] Support SSL/TLS par défaut +- [ ] Compression des payloads (gzip) +- [ ] Authentification par certificat client +- [ ] Métriques Prometheus +- [ ] Dashboard Grafana +- [ ] Support MQTT v5 +- [ ] Broadcast pour commandes multi-équipements +- [ ] Planification de shutdown/reboot (cron-like) diff --git a/mqtt/docs/MQTT_CODING_GUIDELINES.md b/mqtt/docs/MQTT_CODING_GUIDELINES.md new file mode 100644 index 0000000..3af289f --- /dev/null +++ b/mqtt/docs/MQTT_CODING_GUIDELINES.md @@ -0,0 +1,588 @@ +# 📝 Consignes de Codage MQTT pour IPWatch + +Ce document définit les standards de développement pour l'écosystème MQTT d'IPWatch. + +## 1. Conventions de Nommage + +### 1.1 Topics MQTT + +**Format** : `ipwatch/device/{IP_ADDRESS}/{category}` + +**Règles** : +- ✅ Utiliser des minuscules uniquement +- ✅ Utiliser `/` comme séparateur hiérarchique +- ✅ Pas d'espaces ni de caractères spéciaux +- ✅ Maximum 128 caractères +- ❌ Éviter les wildcards (`+`, `#`) dans les noms de topics + +**Exemples** : +``` +✅ ipwatch/device/192.168.1.100/command +✅ ipwatch/device/10.0.0.50/status +❌ IPWatch/Device/192.168.1.100/Command (majuscules) +❌ ipwatch-device-192.168.1.100-command (mauvais séparateur) +``` + +### 1.2 Noms de Variables (Python) + +```python +# ✅ Bon +mqtt_broker = "localhost" +command_topic = f"ipwatch/device/{ip}/command" +message_payload = json.dumps(data) + +# ❌ Mauvais +MQTTBroker = "localhost" # Pas de PascalCase pour variables +commandTopic = f"ipwatch/device/{ip}/command" # Pas de camelCase +msg = json.dumps(data) # Abréviation non claire +``` + +### 1.3 Constantes + +```python +# ✅ Constantes en MAJUSCULES +MQTT_QOS_COMMAND = 1 +MQTT_QOS_STATUS = 1 +DEFAULT_KEEPALIVE = 60 +MAX_RETRY_ATTEMPTS = 3 + +# Topics en constantes +TOPIC_PREFIX = "ipwatch/device" +TOPIC_COMMAND = "{}/command" +TOPIC_STATUS = "{}/status" +``` + +## 2. Structure du Code + +### 2.1 Organisation des Fichiers + +``` +mqtt/ +├── client/ # Agent MQTT côté client +│ ├── ipwatch_mqtt_agent.py # Agent principal +│ ├── mqtt-agent.conf.example # Configuration exemple +│ └── requirements.txt # Dépendances Python +├── docs/ # Documentation +│ ├── MQTT_ARCHITECTURE.md +│ ├── MQTT_CODING_GUIDELINES.md +│ └── HOMEASSISTANT_SPEC.md +└── systemd/ # Services systemd + └── ipwatch-mqtt-agent.service +``` + +### 2.2 Structure d'un Module Python + +```python +#!/usr/bin/env python3 +""" +Titre du module +Description détaillée + +Installation: + pip install dependencies + +Usage: + python module.py --help +""" + +# Imports standard library +import os +import sys +import json +import logging +from pathlib import Path + +# Imports third-party +import paho.mqtt.client as mqtt + +# Imports locaux +from backend.app.core.config import config_manager + +# Configuration du logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +# Constantes globales +MQTT_BROKER = "localhost" +MQTT_PORT = 1883 + +# Classes +class MyClass: + """Docstring de la classe""" + pass + +# Fonctions +def my_function(): + """Docstring de la fonction""" + pass + +# Point d'entrée +if __name__ == "__main__": + main() +``` + +## 3. Gestion MQTT + +### 3.1 Connexion au Broker + +```python +# ✅ Bon - Gestion robuste des erreurs +def connect_mqtt(broker, port, username=None, password=None): + """ + Connecte au broker MQTT avec gestion d'erreurs + + Args: + broker (str): Adresse du broker + port (int): Port MQTT + username (str, optional): Nom d'utilisateur + password (str, optional): Mot de passe + + Returns: + mqtt.Client: Client MQTT connecté + + Raises: + ConnectionError: Si la connexion échoue + """ + try: + client = mqtt.Client(client_id=f"ipwatch-{os.getpid()}") + + if username and password: + client.username_pw_set(username, password) + + client.connect(broker, port, keepalive=60) + logger.info(f"✓ Connecté au broker {broker}:{port}") + return client + + except Exception as e: + logger.error(f"✗ Erreur connexion MQTT: {e}") + raise ConnectionError(f"Impossible de se connecter à {broker}:{port}") + +# ❌ Mauvais - Pas de gestion d'erreur +def connect_mqtt(broker, port): + client = mqtt.Client() + client.connect(broker, port) + return client +``` + +### 3.2 Publication de Messages + +```python +# ✅ Bon - QoS et vérification +def publish_command(client, ip_address, command): + """ + Publie une commande MQTT + + Args: + client: Client MQTT + ip_address (str): IP de destination + command (str): Commande à envoyer + + Returns: + bool: True si publié avec succès + """ + topic = f"ipwatch/device/{ip_address}/command" + payload = json.dumps({ + "command": command, + "timestamp": datetime.now().isoformat() + }) + + result = client.publish(topic, payload, qos=1) + result.wait_for_publish(timeout=5) + + if result.is_published(): + logger.info(f"✓ Commande '{command}' envoyée à {ip_address}") + return True + else: + logger.error(f"✗ Échec publication commande vers {ip_address}") + return False + +# ❌ Mauvais - Pas de QoS, pas de vérification +def publish_command(client, ip, cmd): + client.publish(f"ipwatch/device/{ip}/command", cmd) +``` + +### 3.3 Souscription et Callbacks + +```python +# ✅ Bon - Callbacks avec gestion d'erreur +class MQTTAgent: + def __init__(self): + self.client = mqtt.Client() + self.client.on_connect = self.on_connect + self.client.on_message = self.on_message + self.client.on_disconnect = self.on_disconnect + + def on_connect(self, client, userdata, flags, rc): + """Callback lors de la connexion""" + if rc == 0: + logger.info("✓ Connecté au broker") + client.subscribe(self.command_topic, qos=1) + else: + logger.error(f"✗ Échec connexion, code: {rc}") + + def on_message(self, client, userdata, msg): + """Callback réception message""" + try: + payload = json.loads(msg.payload.decode('utf-8')) + command = payload.get('command') + self.execute_command(command) + except json.JSONDecodeError as e: + logger.error(f"✗ JSON invalide: {e}") + except Exception as e: + logger.error(f"✗ Erreur traitement message: {e}") + + def on_disconnect(self, client, userdata, rc): + """Callback déconnexion""" + if rc != 0: + logger.warning(f"⚠ Déconnexion inattendue (code {rc})") +``` + +## 4. Gestion des Erreurs + +### 4.1 Try/Except + +```python +# ✅ Bon - Gestion spécifique des erreurs +def execute_shutdown(): + """Exécute la commande shutdown""" + try: + subprocess.run( + ["sudo", "shutdown", "-h", "now"], + check=True, + timeout=10 + ) + logger.info("✓ Shutdown initié") + return True + + except subprocess.TimeoutExpired: + logger.error("✗ Timeout exécution shutdown") + return False + + except subprocess.CalledProcessError as e: + logger.error(f"✗ Erreur shutdown: {e}") + return False + + except PermissionError: + logger.error("✗ Permissions sudo insuffisantes") + return False + + except Exception as e: + logger.error(f"✗ Erreur inattendue: {e}") + return False + +# ❌ Mauvais - Catch all sans logging +def execute_shutdown(): + try: + subprocess.run(["sudo", "shutdown", "-h", "now"]) + except: + pass +``` + +### 4.2 Logging + +```python +# ✅ Bon - Niveaux de log appropriés +logger.debug("Payload: %s", payload) # Détails de debug +logger.info("✓ Connexion établie") # Opérations normales +logger.warning("⚠ Retry connexion...") # Avertissements +logger.error("✗ Échec publication") # Erreurs +logger.critical("🔴 Broker inaccessible") # Erreurs critiques + +# ❌ Mauvais - Mauvais niveaux +logger.info("Erreur critique!") # Devrait être error/critical +logger.error("Opération réussie") # Devrait être info +``` + +## 5. Format des Payloads JSON + +### 5.1 Commandes + +```python +# ✅ Bon - Structure claire +{ + "command": "shutdown", # string, required + "timestamp": "2025-12-23T10:30:00Z" # ISO 8601, required +} + +# Avec options +{ + "command": "shutdown", + "timestamp": "2025-12-23T10:30:00Z", + "delay": 60, # secondes avant exécution + "force": false # shutdown forcé ou non +} +``` + +### 5.2 Statut + +```python +# ✅ Bon - Métriques utiles +{ + "hostname": "server-01", + "ip": "10.0.0.100", + "platform": "Linux", + "platform_version": "5.15.0-91-generic", + "uptime": 86400, # secondes + "cpu_percent": 45.2, + "memory_percent": 62.5, + "disk_percent": 78.3, + "timestamp": "2025-12-23T10:30:00Z" +} + +# ❌ Mauvais - Données incomplètes +{ + "host": "server-01", + "status": "ok" +} +``` + +### 5.3 Réponses + +```python +# ✅ Bon - Succès/échec clair +{ + "success": true, + "message": "Shutdown en cours...", + "timestamp": "2025-12-23T10:30:05Z" +} + +# En cas d'erreur +{ + "success": false, + "message": "Permissions insuffisantes", + "error_code": "PERMISSION_DENIED", + "timestamp": "2025-12-23T10:30:05Z" +} +``` + +## 6. Tests + +### 6.1 Tests Unitaires + +```python +# tests/test_mqtt_agent.py +import pytest +from unittest.mock import MagicMock, patch +from ipwatch_mqtt_agent import IPWatchMQTTAgent + +def test_get_ip_address(): + """Test récupération adresse IP""" + agent = IPWatchMQTTAgent() + ip = agent.get_ip_address() + assert isinstance(ip, str) + assert len(ip.split('.')) == 4 + +def test_publish_status(): + """Test publication du statut""" + agent = IPWatchMQTTAgent() + agent.client = MagicMock() + + agent.publish_status() + + agent.client.publish.assert_called_once() + args = agent.client.publish.call_args + assert "ipwatch/device" in args[0][0] + +@patch('subprocess.run') +def test_shutdown(mock_subprocess): + """Test commande shutdown""" + agent = IPWatchMQTTAgent() + agent.shutdown() + + mock_subprocess.assert_called_once() + assert "shutdown" in mock_subprocess.call_args[0][0] +``` + +### 6.2 Tests d'Intégration + +```python +# tests/test_mqtt_integration.py +import time +import paho.mqtt.client as mqtt + +def test_command_flow(): + """Test flux complet command → response""" + received_messages = [] + + def on_message(client, userdata, msg): + received_messages.append(msg.payload.decode()) + + # Setup client test + client = mqtt.Client("test-client") + client.on_message = on_message + client.connect("localhost", 1883) + client.subscribe("ipwatch/device/+/response") + client.loop_start() + + # Envoyer commande + client.publish( + "ipwatch/device/10.0.0.100/command", + '{"command": "status"}', + qos=1 + ) + + # Attendre réponse + time.sleep(2) + + assert len(received_messages) > 0 + assert "success" in received_messages[0] + + client.loop_stop() + client.disconnect() +``` + +## 7. Sécurité + +### 7.1 Credentials + +```python +# ✅ Bon - Variables d'environnement +MQTT_USERNAME = os.getenv('MQTT_USERNAME') +MQTT_PASSWORD = os.getenv('MQTT_PASSWORD') + +if not MQTT_USERNAME or not MQTT_PASSWORD: + logger.warning("⚠ MQTT sans authentification") + +# ❌ Mauvais - Hardcodé +MQTT_USERNAME = "ipwatch" +MQTT_PASSWORD = "password123" +``` + +### 7.2 Validation des Entrées + +```python +# ✅ Bon - Validation stricte +def execute_command(command): + """Exécute une commande MQTT""" + ALLOWED_COMMANDS = ['shutdown', 'reboot', 'status'] + + if command not in ALLOWED_COMMANDS: + logger.error(f"✗ Commande refusée: {command}") + return False + + # Exécution sécurisée + if command == "shutdown": + return execute_shutdown() + # ... + +# ❌ Mauvais - Exécution directe +def execute_command(command): + subprocess.run(command, shell=True) # DANGER: injection! +``` + +## 8. Performance + +### 8.1 Optimisation des Payloads + +```python +# ✅ Bon - Payload compact +payload = json.dumps({ + "cmd": "status", # Abréviation pour réduire taille + "ts": int(time.time()) # Timestamp Unix +}, separators=(',', ':')) # Pas d'espaces + +# ❌ Mauvais - Payload volumineux +payload = json.dumps({ + "command": "status", + "timestamp": "2025-12-23T10:30:00.123456Z", + "extra_field_1": None, + "extra_field_2": None +}, indent=2) # Indentation inutile +``` + +### 8.2 Gestion de la Boucle + +```python +# ✅ Bon - Boucle non-bloquante +client.loop_start() # Thread séparé + +while True: + time.sleep(30) + publish_status() + +# ❌ Mauvais - Boucle bloquante +while True: + client.loop() # Bloque le thread principal + time.sleep(30) +``` + +## 9. Documentation + +### 9.1 Docstrings + +```python +# ✅ Bon - Docstring complet +def send_mqtt_command(ip_address, command): + """ + Envoie une commande MQTT à un équipement + + Cette fonction publie une commande sur le topic MQTT correspondant + à l'adresse IP fournie. La commande est envoyée avec QoS 1 pour + garantir la livraison. + + Args: + ip_address (str): Adresse IP de l'équipement cible + command (str): Commande à envoyer (shutdown, reboot, status) + + Returns: + bool: True si la commande a été publiée avec succès + + Raises: + ValueError: Si la commande n'est pas valide + ConnectionError: Si le broker MQTT est inaccessible + + Example: + >>> send_mqtt_command("192.168.1.100", "status") + True + """ + pass + +# ❌ Mauvais - Pas de documentation +def send_mqtt_command(ip, cmd): + pass +``` + +## 10. Checklist Pré-commit + +Avant de commit du code MQTT, vérifier : + +- [ ] Code formaté avec `black` (PEP 8) +- [ ] Pas de secrets/credentials hardcodés +- [ ] Logging approprié (info/error/debug) +- [ ] Gestion des erreurs avec try/except +- [ ] Docstrings sur fonctions/classes publiques +- [ ] Tests unitaires passent +- [ ] QoS 1 pour messages critiques +- [ ] Validation des payloads JSON +- [ ] Topics respectent la convention +- [ ] Variables d'environnement documentées + +## 11. Outils Recommandés + +```bash +# Formatage code +pip install black +black mqtt/client/ + +# Linting +pip install pylint +pylint mqtt/client/ipwatch_mqtt_agent.py + +# Tests +pip install pytest pytest-cov +pytest mqtt/tests/ --cov + +# Type checking +pip install mypy +mypy mqtt/client/ +``` + +## 12. Exemples de Code Complet + +Voir : +- `mqtt/client/ipwatch_mqtt_agent.py` - Agent MQTT client +- `backend/app/services/mqtt_client.py` - Client MQTT backend +- `backend/app/routers/tracking.py` - Endpoints API MQTT diff --git a/mqtt/systemd/ipwatch-mqtt-agent.service b/mqtt/systemd/ipwatch-mqtt-agent.service new file mode 100644 index 0000000..871e135 --- /dev/null +++ b/mqtt/systemd/ipwatch-mqtt-agent.service @@ -0,0 +1,26 @@ +[Unit] +Description=IPWatch MQTT Agent +After=network.target mosquitto.service +Wants=network.target + +[Service] +Type=simple +User=root +ExecStart=/usr/bin/python3 /usr/local/bin/ipwatch_mqtt_agent.py +Restart=always +RestartSec=10 + +# Logs +StandardOutput=journal +StandardError=journal +SyslogIdentifier=ipwatch-mqtt-agent + +# Sécurité +PrivateTmp=yes +NoNewPrivileges=false +ProtectSystem=strict +ProtectHome=read-only +ReadWritePaths=/var/log + +[Install] +WantedBy=multi-user.target diff --git a/nouvel_ajout.md b/nouvel_ajout.md new file mode 100644 index 0000000..f54b3d0 --- /dev/null +++ b/nouvel_ajout.md @@ -0,0 +1,4 @@ +✅ FAIT - deplacer les filtres en bas du volet gauche + +✅ FAIT - ajouter un petit rond rouge en bas de chaque cellule pour ce qui sont suivi +![alt text](image.png) \ No newline at end of file diff --git a/opensense_todo.md b/opensense_todo.md new file mode 100644 index 0000000..da60320 --- /dev/null +++ b/opensense_todo.md @@ -0,0 +1,263 @@ +# OPNsense - Brainstorming & Todo + +## Brainstorming + +### Contexte +L'API REST OPNsense (https://docs.opnsense.org/development/api.html) expose plus de 24 modules core et 80+ plugins. +Authentification par clé API (key + secret) en HTTPS Basic Auth. +Format d'appel : `https:///api////[]` + +### Endpoints API exploitables pour IPWatch + +#### 1. Table ARP (enrichissement des scans) +- `GET /api/diagnostics/interface/get_arp` : table ARP complète du firewall +- `GET /api/diagnostics/interface/search_arp` : recherche dans la table ARP +- **Interet** : OPNsense voit TOUT le trafic, sa table ARP est plus complète que celle de la machine IPWatch. Permet de detecter des equipements que le scan ping ne voit pas (equipements qui ne repondent pas au ping mais passent par le firewall). + +#### 2. DHCP Kea - Reservations statiques (gestion centralisee) +- `GET /api/kea/dhcpv4/search_reservation` : lister toutes les reservations +- `GET /api/kea/dhcpv4/get_reservation/{uuid}` : detail d'une reservation +- `POST /api/kea/dhcpv4/add_reservation` : creer une reservation (IP + MAC + hostname) +- `POST /api/kea/dhcpv4/set_reservation/{uuid}` : modifier une reservation +- `POST /api/kea/dhcpv4/del_reservation/{uuid}` : supprimer une reservation +- **Champs du modele reservation** : `subnet` (ref), `ip_address` (IPv4), `hw_address` (MAC), `hostname` (DNS), `description` (texte libre) +- **Reservation hors plage DHCP** : OUI, c'est meme recommande avec Kea. Les reservations doivent etre en dehors du pool dynamique pour eviter les collisions. Ex: pool=10.0.1.100-254, reservation=10.0.0.50 → OK. +- **Description "Ajout par IPWatch"** : OUI, le champ `description` est un texte libre non parse. On peut y mettre : `"Ajout par IPWatch - {nom_equipement} - {date}"` pour tracer l'origine de la reservation. +- **Interet** : Depuis IPWatch, on peut directement gerer les baux statiques DHCP. Quand un nouvel equipement est detecte, on peut lui attribuer une IP fixe en un clic, meme en dehors de la plage DHCP dynamique. + +#### 3. DNS Unbound - Host overrides (resolution noms) +- `GET /api/unbound/settings/search_host_override` : lister les overrides DNS +- `POST /api/unbound/settings/add_host_override` : ajouter un override +- `POST /api/unbound/settings/set_host_override/{uuid}` : modifier +- `POST /api/unbound/settings/del_host_override/{uuid}` : supprimer +- `POST /api/unbound/service/reconfigure` : appliquer les changements +- **Interet** : Gerer la resolution DNS locale directement depuis IPWatch. Quand on nomme un equipement dans IPWatch, on peut aussi creer l'entree DNS correspondante. + +#### 4. Aliases Firewall (groupes d'IP) +- `GET /api/firewall/alias/get` : lister les aliases +- `POST /api/firewall/alias/add_item` : creer un alias +- `POST /api/firewall/alias/set_item/{uuid}` : modifier +- `POST /api/firewall/alias/del_item/{uuid}` : supprimer +- `POST /api/firewall/alias/toggle_item/{uuid}` : activer/desactiver +- **Interet** : Creer/gerer des groupes d'IP depuis IPWatch (ex: "cameras", "IoT", "serveurs") qui se repercutent dans les regles firewall OPNsense. + +#### 5. Regles Firewall (visibilite et gestion) +- `GET /api/firewall/filter/search_rule` : lister les regles +- `POST /api/firewall/filter/add_rule` : ajouter une regle +- `POST /api/firewall/filter/toggle_rule/{uuid}` : activer/desactiver +- `POST /api/firewall/filter/apply/{revision}` : appliquer +- **Interet** : Voir les regles associees a une IP, bloquer/debloquer un equipement directement depuis IPWatch. + +#### 6. Interfaces et trafic (monitoring) +- `GET /api/diagnostics/interface/get_interface_statistics` : stats par interface +- `GET /api/diagnostics/traffic/stream` : flux temps reel +- `GET /api/diagnostics/traffic/_top` : top connexions +- `GET /api/diagnostics/interface/get_interface_config` : config interfaces +- **Interet** : Afficher dans IPWatch le trafic par equipement, detecter les anomalies de consommation, enrichir le panneau de details d'une IP. + +#### 7. Systeme OPNsense (dashboard) +- `GET /api/diagnostics/system/system_information` : info systeme +- `GET /api/diagnostics/system/system_resources` : CPU/RAM +- `GET /api/diagnostics/system/system_temperature` : temperatures +- `GET /api/diagnostics/system/memory` : memoire +- **Interet** : Afficher l'etat du firewall dans le dashboard IPWatch (sante du reseau). + +#### 8. VPN Status (Wireguard/OpenVPN) +- Endpoints Wireguard et OpenVPN pour voir les tunnels actifs +- **Interet** : Visualiser les connexions VPN, savoir quel equipement est connecte via VPN. + +#### 9. Network Insight (analyse historique) +- `GET /api/diagnostics/networkinsight/timeserie` : donnees chronologiques +- `GET /api/diagnostics/networkinsight/top` : classement trafic +- **Interet** : Historique de trafic par IP, graphiques d'utilisation dans le detail d'un equipement. + +--- + +### Idees d'integration UI dans IPWatch + +1. **Panneau de details IP (volet gauche)** : + - Section "OPNsense" avec : reservation DHCP, override DNS, aliases associes, regles firewall + - Boutons d'action : "Creer reservation DHCP", "Ajouter override DNS", "Bloquer cette IP" + - Graphique mini de trafic de cet equipement + +2. **Nouveau detection (volet droit)** : + - Quand un nouvel equipement est detecte, proposer directement : "Creer reservation DHCP statique" + - Pre-remplir avec le MAC et proposer une IP dans la plage appropriee + +3. **Dashboard / Stats systeme** : + - Widget sante OPNsense (CPU, RAM, temp) + - Widget trafic global + - Widget connexions VPN actives + +4. **Onglet Architecture** : + - Afficher le firewall OPNsense comme noeud central + - Visualiser les interfaces, VLANs, flux de trafic + +5. **Configuration** : + - Section `opnsense` dans config.yaml avec : host, api_key, api_secret, enabled, sync_interval + - Page parametres avec test de connexion + +--- + +### Onglet OPNsense - Tableau de bord dedie (ligne 9 amelioration.md) + +Vision : un onglet `/opnsense` dans IPWatch qui offre une vue claire et pedagogique de l'etat du firewall, ses services, et ses parametrages, sans avoir besoin d'ouvrir l'interface OPNsense. + +#### Section 0 : Detection automatique des services (au chargement) +Avant d'afficher l'onglet, IPWatch doit detecter quels services sont installes et actifs : +- **Endpoint** : `POST /api/core/service/search` → retourne `{id, name, running, locked, description}` pour chaque service +- **Endpoint** : `GET /api/core/firmware/info` → liste tous les paquets installes (nom, version, description) +- **Logique de detection DHCP** : + - Si service `kea-dhcp4` present et running → utiliser les endpoints Kea (`/api/kea/dhcpv4/...`) + - Si service `dhcpd` present et running → utiliser les endpoints ISC DHCP (+ avertir EOL) + - Si service `dnsmasq` present et running → utiliser les endpoints Dnsmasq (`/api/dnsmasq/...`) + - Si aucun → afficher "Aucun serveur DHCP detecte" +- **Detection generale** : stocker le resultat dans un objet `detectedServices` cote backend, reutilise partout : + ``` + { dhcp: "kea" | "isc" | "dnsmasq" | null, dns: "unbound" | "dnsmasq" | null, + vpn: ["wireguard", "openvpn"] | [], ids: true | false, ... } + ``` +- **Interet** : adapter dynamiquement l'UI et les endpoints selon la configuration reelle du serveur OPNsense +- **Couche d'abstraction backend** : les APIs DHCP sont tres differentes selon le service detecte : + ``` + opnsense_client.py + ├── dhcp_adapter.py # Interface commune (list, add, del, get reservation) + │ ├── kea_adapter.py # kea/dhcpv4/*_reservation (CRUD complet, champ description) + │ ├── dnsmasq_adapter.py # dnsmasq/settings/*_host (CRUD complet, tags, options, ranges) + │ └── isc_adapter.py # dhcpv4/leases/search_lease (lecture seule, PAS d'API pour static mapping) + ``` + - **Kea** : API complete → reservation (subnet, ip_address, hw_address, hostname, description) + - **Dnsmasq** : API complete → hosts statiques (ip, mac, hostname) + tags + options DHCP + ranges + - **ISC DHCP** : PAS d'API pour les static mappings (issue #4062), seulement lecture des baux. Afficher avertissement EOL + recommander migration vers Kea ou Dnsmasq + - Le frontend appelle toujours les memes routes IPWatch (`/api/opnsense/dhcp/reservations`), le backend route vers le bon adapter + +#### Section 1 : Etat des services (haut de page) +Grille de cartes affichant chaque service avec un indicateur visuel (vert/rouge/gris). +- **Endpoint** : `POST /api/core/service/search` (deja appele en Section 0) +- **Services cles a afficher** : Unbound (DNS), Kea DHCP, Firewall, OpenVPN, Wireguard, Syslog, IDS/IPS, NTP, HAProxy, etc. +- Chaque carte affiche : + - Icone du service + - Nom + description courte + - Badge etat : "Actif" (vert) / "Arrete" (rouge) / "Desactive" (gris) + - **Tooltip** : description detaillee du role du service, parametres principaux, derniere action + - Boutons : Start / Stop / Restart (avec confirmation modale) +- **Filtres** : Tous / Actifs / Arretes / Critiques + +#### Section 2 : Parametrages rapides (milieu de page) +Tableaux clairs par domaine, chaque ligne avec tooltips explicatifs : + +**DHCP (Kea)** : +- Tableau des reservations statiques : IP | MAC | Hostname | Subnet | Actions +- Endpoints : `search_reservation`, `add_reservation`, `del_reservation` +- Tooltip par champ : "L'adresse MAC identifie physiquement l'equipement", etc. +- Bouton "Ajouter reservation" avec formulaire modal + +**DNS (Unbound)** : +- Tableau des host overrides : Hostname | Domain | IP | Description | Actions +- Endpoints : `search_host_override`, `add_host_override`, `del_host_override` +- Tooltip : "Un host override force la resolution DNS locale d'un nom vers une IP specifique" + +**Firewall - Aliases** : +- Tableau des aliases : Nom | Type | Contenu | Enabled | Actions +- Endpoints : `get`, `add_item`, `toggle_item` +- Tooltip : "Un alias regroupe des IPs/reseaux/ports pour simplifier les regles firewall" + +**Firewall - Regles** : +- Tableau des regles (lecture seule recommandee) : # | Action | Interface | Source | Dest | Port | Proto | Enabled +- Endpoint : `search_rule` +- Tooltip : "Pass = autorise le trafic, Block = bloque, Reject = bloque et notifie" +- Statistiques par regle (hits) via `rule_stats` + +**Interfaces** : +- Tableau des interfaces : Nom | Status | IP | Gateway | Media | Debit In/Out +- Endpoints : `get_interface_config`, `get_interface_statistics` +- Tooltip : "WAN = connexion Internet, LAN = reseau local, OPT = interface supplementaire" + +**VLANs** : +- Tableau : Interface parent | VLAN ID | Description | Actions +- Endpoint : `search_item` (VlanSettingsController) + +#### Section 3 : Monitoring systeme (colonne droite ou section) +Mini-dashboard de sante OPNsense : +- **CPU** : jauge + pourcentage (`system_resources`) +- **RAM** : jauge + utilise/total (`memory`) +- **Temperature** : valeur + icone couleur (`system_temperature`) +- **Disque** : barre de progression (`system_disk`) +- **Uptime** : duree depuis dernier reboot (`system_information`) +- **Version** : OPNsense version + derniere MAJ firmware + +#### Section 4 : Logs et erreurs (bas de page) +Tableau de logs temps reel avec filtrage : +- **Sources de logs** : + - `GET /api/diagnostics/log/core/firewall` : logs firewall (block/pass) + - `GET /api/syslog/service/stats` : stats syslog + - Logs DHCP : attributions, expirations, conflits + - Logs IDS/IPS : alertes de securite +- **Colonnes** : Timestamp | Severite | Service | Message | IP source | IP dest +- **Filtres** : + - Par severite : erreur (rouge), warning (orange), info (bleu), debug (gris) + - Par service : Firewall, DHCP, DNS, VPN, IDS + - Par IP : filtrer les logs d'un equipement specifique + - Par periode : derniere heure, 24h, 7j +- **Recherche** : texte libre dans les messages +- **Tooltip sur les erreurs** : + - "Block" firewall → "Le firewall a bloque ce trafic car aucune regle ne l'autorise" + - "DHCP NAK" → "Le serveur DHCP a refuse la demande : l'IP demandee n'est pas disponible" + - "IDS Alert" → "Le systeme de detection d'intrusion a identifie un comportement suspect" +- **Auto-refresh** : polling configurable (5s, 15s, 30s, 1min) ou WebSocket si disponible + +#### Section 5 : Actions globales (header de l'onglet) +- Bouton "Test connexion API" avec indicateur vert/rouge +- Bouton "Rafraichir tout" pour recharger toutes les sections +- Indicateur "Derniere synchro : il y a X secondes" +- Lien direct vers l'interface web OPNsense (ouvre dans un nouvel onglet) + +#### Design et UX +- **Theme** : Monokai coherent avec le reste d'IPWatch +- **Tooltips** : icone (i) a cote de chaque element avec description claire et accessible + - Vocabulaire non-technique quand possible + - Exemples concrets ("Par exemple, si vous voulez que votre NAS ait toujours l'IP 10.0.0.50...") + - Lien vers la doc OPNsense pour approfondir +- **Responsive** : layout adaptatif (2 colonnes desktop, 1 colonne mobile) +- **Etats de chargement** : skeleton loaders pendant les appels API +- **Gestion d'erreurs** : + - Connexion impossible → message clair + bouton "Verifier la configuration" + - Timeout → retry automatique + notification + - Erreur API → affichage du code erreur + explication + +--- + +## Todo + +*(deplacer ici les elements valides du brainstorming)* + +- [ ] Ajouter section `opnsense` dans config.yaml (host, api_key, api_secret, enabled) +- [ ] Creer service backend `opnsense_client.py` (classe client API avec auth) +- [ ] Endpoint de test de connexion API OPNsense +- [ ] Detection auto des services installes (`core/service/search` + `core/firmware/info`) → objet `detectedServices` +- [ ] Couche d'abstraction DHCP : `dhcp_adapter.py` avec `kea_adapter`, `dnsmasq_adapter`, `isc_adapter` +- [ ] Routes IPWatch uniformes (`/api/opnsense/dhcp/reservations`) routees vers le bon adapter +- [ ] Integration table ARP OPNsense dans le scan reseau +- [ ] Lecture des reservations DHCP depuis OPNsense (Kea ou Dnsmasq selon detection) +- [ ] Bouton "Creer reservation DHCP" dans le volet details IP +- [ ] Lecture des host overrides Unbound +- [ ] Bouton "Creer override DNS" dans le volet details IP +- [ ] Afficher les aliases firewall associes a une IP +- [ ] Widget sante OPNsense dans dashboard +- [ ] Section OPNsense dans page Parametres (config + test connexion) +- [ ] Creer onglet OPNsense : vue `/opnsense` + route + lien navigation +- [ ] Section services : grille de cartes avec etat (actif/arrete) via `core/service/search` +- [ ] Section DHCP : tableau des reservations Kea avec CRUD +- [ ] Section DNS : tableau des host overrides Unbound avec CRUD +- [ ] Section Aliases : tableau des aliases firewall avec CRUD +- [ ] Section Regles Firewall : tableau lecture seule avec stats +- [ ] Section Interfaces : tableau avec stats trafic +- [ ] Section Monitoring systeme : jauges CPU/RAM/Temp/Disque +- [ ] Section Logs : tableau temps reel avec filtres severite/service/IP +- [ ] Tooltips pedagogiques sur tous les elements de l'onglet +- [ ] Gestion erreurs : connexion impossible, timeout, erreurs API +- [ ] Tuto migration ISC DHCP vers Kea : voir `tuto_migration_isc_vers_kea.md` + +## Done + +*(deplacer ici les elements termines)* diff --git a/prompt-claude-code.md b/prompt-claude-code.md new file mode 100755 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 100755 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 100755 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 100755 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 100755 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 100755 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 100755 index 0000000..a2823c4 --- /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.now(), + 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 100755 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 100755 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/todo.md b/todo.md new file mode 100755 index 0000000..f1aeaf1 --- /dev/null +++ b/todo.md @@ -0,0 +1,272 @@ +# TODO IPWatch - Fonctionnalités à développer + +## 🔧 Correctifs immédiats + +### 1. ✅ Fuseau horaire Europe/Paris +**Statut :** Terminé +- Remplacé tous les `datetime.utcnow()` par `datetime.now()` +- Le container utilise déjà `TZ=Europe/Paris` dans docker-compose.yml + +### 2. Section "autres" manquante dans subnets +**Problème :** Le fichier `IPGridTree.vue` (ligne 96-99) contient des subnets en dur sans "autres" +**Solution :** +- Charger dynamiquement les subnets depuis l'API `/api/config/options` +- Ajouter la section "autres" (10.0.3.0/24) dans la liste + +### 3. Alerte changement MAC address +**Problème :** Pas de détection quand une IP change de MAC address +**Couleur proposée :** Orange Monokai (#FD971F) - avertissement +**Solution :** +- Lors du scan, comparer la MAC détectée avec celle en DB +- Si différente (et MAC DB non vide), marquer l'IP avec état "mac_changed" +- Modifier IPCell.vue pour afficher la bordure orange +- Ajouter dans la légende : "MAC modifiée (attention)" + +--- + +## 🚀 Nouvelle fonctionnalité : Wake-on-LAN + +### Objectif +Permettre d'allumer/éteindre des machines depuis l'interface IPWatch avec un popup dédié. + +### Analyse technique + +#### 1. Configuration (config.yaml) +Ajouter une nouvelle section `wakeonlan` : + +```yaml +wakeonlan: + enabled: true + refresh_interval: 10 # Actualisation état toutes les 10 secondes dans le popup + machines: + - name: "Serveur Proxmox" + ip: "10.0.0.10" + mac: "AA:BB:CC:DD:EE:FF" + switch_on: + enabled: true + method: "wol" # wake-on-lan magicpacket + switch_off: + enabled: true + method: "ssh" # ou "api", "none" + command: "sudo poweroff" + user: "root" + port: 22 + - name: "PC Bureau" + ip: "10.0.1.50" + mac: "11:22:33:44:55:66" + switch_on: + enabled: true + method: "wol" + switch_off: + enabled: false # Pas d'extinction à distance +``` + +**Paramètres détaillés :** +- `name` : Nom affiché dans l'interface +- `ip` : Adresse IP de la machine +- `mac` : Adresse MAC (obligatoire pour WOL) +- `switch_on.enabled` : Activer le bouton "Allumer" +- `switch_on.method` : Toujours "wol" (magic packet) +- `switch_off.enabled` : Activer le bouton "Éteindre" +- `switch_off.method` : "ssh", "api", "none" +- `switch_off.command` : Commande à exécuter (pour SSH) +- `switch_off.user` : Utilisateur SSH +- `switch_off.port` : Port SSH (défaut 22) + +#### 2. Base de données (models/wol.py - NOUVEAU) +Créer une table pour logger l'historique des actions WOL : + +```python +class WOLHistory(Base): + __tablename__ = "wol_history" + + id = Column(Integer, primary_key=True) + machine_name = Column(String, nullable=False) + ip = Column(String, nullable=False) + mac = Column(String, nullable=False) + action = Column(String, nullable=False) # "power_on", "power_off" + status = Column(String, nullable=False) # "success", "failed", "timeout" + timestamp = Column(DateTime, default=datetime.now) + error_message = Column(String, nullable=True) +``` + +**Pas besoin de modifier la table IP** - Les infos WOL restent dans config.yaml + +#### 3. Backend (routers/wol.py - NOUVEAU) +Endpoints à créer : + +```python +# GET /api/wol/machines +# Retourne la liste des machines WOL depuis config.yaml avec leur état actuel + +# POST /api/wol/power-on/{machine_name} +# Envoie un magic packet WOL + +# POST /api/wol/power-off/{machine_name} +# Exécute la commande d'extinction (SSH/API selon config) + +# GET /api/wol/status/{machine_name} +# Vérifie l'état (online/offline) via ping + +# GET /api/wol/history +# Retourne l'historique des actions WOL +``` + +**Bibliothèque Python :** `wakeonlan` (à ajouter dans requirements.txt) + +#### 4. Frontend - Popup WOL (components/WOLPopup.vue - NOUVEAU) + +**Interface :** +``` +┌─────────────────────────────────────────┐ +│ Wake-on-LAN Manager [✕] │ +├─────────────────────────────────────────┤ +│ 🔄 Dernière actualisation: 12:34:56 │ +├─────────────────────────────────────────┤ +│ │ +│ 📟 Serveur Proxmox │ +│ • IP: 10.0.0.10 │ +│ • État: 🟢 En ligne │ +│ [💡 Allumer] [🔴 Éteindre] │ +│ │ +│ ───────────────────────────────────── │ +│ │ +│ 📟 PC Bureau │ +│ • IP: 10.0.1.50 │ +│ • État: ⚫ Hors ligne │ +│ [💡 Allumer] [Éteindre désactivé] │ +│ │ +└─────────────────────────────────────────┘ +``` + +**Fonctionnalités :** +- Actualisation automatique de l'état toutes les X secondes (config) +- Affichage horodatage dernière actualisation +- Boutons désactivés si action en cours +- Feedback visuel (spinner, couleur) pendant l'action +- Message d'erreur si échec + +#### 5. Frontend - Bouton d'ouverture (AppHeader.vue) +Ajouter un bouton dans le header à côté du bouton "Scan" : + +```vue + +``` + +#### 6. Services backend (services/wol.py - NOUVEAU) + +**Fonctions à implémenter :** + +```python +async def send_magic_packet(mac: str, ip: str = None, port: int = 9): + """Envoie un magic packet WOL""" + # Utiliser la lib wakeonlan + +async def power_off_ssh(ip: str, user: str, command: str, port: int = 22): + """Extinction via SSH""" + # Utiliser asyncssh ou subprocess + +async def check_machine_status(ip: str) -> bool: + """Vérifie si machine online via ping""" + # Réutiliser NetworkScanner.ping() +``` + +### Plan d'implémentation (ordre recommandé) + +1. **Backend - Configuration** + - Ajouter section `wakeonlan` dans config.yaml + - Mettre à jour config_manager pour parser cette section + - Créer endpoint `/api/wol/config` pour récupérer la config WOL + +2. **Backend - Base de données** + - Créer modèle `WOLHistory` + - Générer migration (si utilisé) ou créer table automatiquement + +3. **Backend - Service WOL** + - Installer lib `wakeonlan` + - Créer `services/wol.py` avec send_magic_packet, power_off_ssh, check_status + +4. **Backend - Endpoints** + - Créer `routers/wol.py` + - Implémenter tous les endpoints listés ci-dessus + - Ajouter logs dans WOLHistory + +5. **Frontend - Store** + - Créer `stores/wolStore.js` pour gérer état WOL + +--- + +## 🧭 Architecture réseau (à venir) + +### Objectif +Créer une vue dédiée pour modéliser l’arbre réseau de l’installation. + +### Premiers éléments +- [ ] Bouton "Architecture réseau" dans le header +- [ ] Page `/architecture` avec squelette (header + colonnes Édition / Action) +- [ ] Définir le modèle de données de l’arbre réseau +- [ ] Ajouter des outils d’édition (nœuds, liens, positions) + +--- + +## 🧪 Tests réseau (à venir) + +### Objectif +Proposer des tests réseau (ping, traceroute, DNS) depuis l’interface. + +### Premiers éléments +- [ ] Bouton "Test" dans le header +- [ ] Page `/test` avec squelette (Commandes / Test / Historique) +- [ ] Définir les types de tests et la sauvegarde d’historique + - Actions : fetchMachines, powerOn, powerOff, refreshStatus + +6. **Frontend - Popup** + - Créer `components/WOLPopup.vue` + - Implémenter UI avec liste des machines + - Actualisation auto avec setInterval + - Gestion des états (loading, success, error) + +7. **Frontend - Intégration** + - Ajouter bouton dans AppHeader.vue + - Gérer ouverture/fermeture popup + - WebSocket pour notifications temps réel (optionnel) + +### Dépendances Python à ajouter +```txt +wakeonlan==3.0.0 +asyncssh==2.13.2 # Pour extinction SSH +``` + +### Sécurité +- **SSH** : Utiliser authentification par clé (pas de mot de passe) +- **API** : Token d'authentification si extinction via API REST +- **Validation** : Vérifier que l'IP/MAC existent dans la config avant action + +### Tests +- Test magic packet envoyé correctement +- Test extinction SSH fonctionne +- Test détection état online/offline +- Test historique enregistré en DB +- Test UI : boutons désactivés pendant action + +--- + +## 📝 Notes +- Le WOL fonctionne uniquement sur le réseau local +- La machine cible doit avoir WOL activé dans le BIOS +- Pour l'extinction SSH, nécessite configuration SSH sans mot de passe (clé publique) +- alternatice service mqtt pour le shutdown + + + +### autre implementation: + +ajouter dans la base de donnée une section notes d estyle markdown, qui permet d'ajouter des notes styles: users et passwords et autres remarques + +### Architecture +- Étudier migration vers IndexedDB si l’app devient lente (cache local + sync backend). diff --git a/tuto_migration_isc_vers_kea.md b/tuto_migration_isc_vers_kea.md new file mode 100644 index 0000000..fcea39f --- /dev/null +++ b/tuto_migration_isc_vers_kea.md @@ -0,0 +1,283 @@ +# Tutoriel : Migration ISC DHCP vers Kea DHCP sur OPNsense + +## Pourquoi migrer ? + +ISC DHCP est **End-of-Life** (fin de vie) et ne recoit plus de mises a jour ni de correctifs de securite. +OPNsense recommande fortement la migration vers **Kea** (ou Dnsmasq). + +Kea apporte : +- Une API REST complete (CRUD sur les reservations, baux, subnets) +- La haute disponibilite (HA) native +- Un support actif et des mises a jour regulieres +- Une meilleure integration avec IPWatch (gestion des reservations via API) + +> **Important** : ISC DHCP n'a PAS d'API pour gerer les mappages statiques. +> Kea expose des endpoints complets (`add_reservation`, `del_reservation`, etc.) +> C'est la raison principale pour laquelle IPWatch necessite Kea. + +--- + +## Pre-requis + +- [ ] OPNsense version 24.x ou superieure (Kea est inclus depuis la v24) +- [ ] Acces administrateur a l'interface web OPNsense +- [ ] **Sauvegarder** la configuration actuelle avant toute modification +- [ ] Lister les mappages statiques actuels (pour verification post-migration) +- [ ] Prevoir une fenetre de maintenance (courte coupure DHCP possible) + +--- + +## Etape 1 : Inventaire de la configuration ISC actuelle + +Avant de toucher a quoi que ce soit, noter la configuration actuelle : + +1. Aller dans **Services > ISC DHCPv4 > [Interface LAN]** +2. Noter : + - **Plage DHCP** : IP debut et IP fin du pool dynamique + - **Serveurs DNS** distribues + - **Passerelle** (gateway) distribuee + - **Domaine** distribue + - **Duree du bail** (lease time) +3. Aller dans l'onglet **Static Mappings** (en bas de la page) +4. **Lister tous les mappages statiques** : + - Prendre une capture d'ecran ou noter : MAC | IP | Hostname | Description + - Ce sont les reservations a migrer vers Kea + +> **Astuce** : Si tu as beaucoup de mappages, exporte la config complete +> dans **System > Configuration > Backups > Download** (fichier `config.xml`). +> Ce fichier contient tous les static mappings dans les noeuds ``. + +--- + +## Etape 2 : Configurer Kea DHCP (sans l'activer) + +On prepare Kea AVANT de desactiver ISC. Ainsi la bascule sera quasi-instantanee. + +### 2.1 Creer les Subnets + +1. Aller dans **Services > Kea DHCP > DHCPv4 > Subnets** +2. Cliquer **+** pour ajouter un subnet +3. Remplir : + - **Subnet** : `10.0.0.0/22` (ton reseau, adapter selon ta config) + - **Pools** : definir la plage dynamique, ex: `10.0.1.100 - 10.0.1.254` + - ⚠️ Les reservations statiques doivent etre EN DEHORS de cette plage + - **Routers** : `10.0.0.1` (passerelle) + - **DNS Servers** : `10.0.0.1` (ou tes serveurs DNS) + - **Domain Name** : ton domaine local (ex: `local`) + - **Valid Lifetime** : duree du bail en secondes (ex: `86400` pour 24h) +4. **Sauvegarder** (ne pas encore activer le service) + +> **Note** : Si tu as plusieurs VLANs/sous-reseaux, creer un subnet par VLAN. + +### 2.2 Activer le Control Agent (recommande) + +1. Aller dans **Services > Kea DHCP > Control Agent** +2. Cocher **Enabled** +3. **Bind address** : `127.0.0.1` +4. **Bind port** : `8000` +5. Sauvegarder + +Le Control Agent expose l'API REST locale de Kea, utile pour le monitoring et la HA. + +--- + +## Etape 3 : Migrer les mappages statiques + +### Option A : Migration manuelle (peu de mappages, < 10) + +Pour chaque mappage statique ISC : + +1. Aller dans **Services > Kea DHCP > DHCPv4 > Reservations** +2. Cliquer **+** pour ajouter +3. Remplir : + - **Subnet** : selectionner le subnet cree a l'etape 2 + - **IP address** : l'IP fixe (ex: `10.0.0.5`) + - **MAC address** : l'adresse MAC du client (ex: `AA:BB:CC:DD:EE:FF`) + - **Hostname** : le nom d'hote (ex: `debian-vm5`) + - **Description** : `Migration depuis ISC DHCP` (ou description originale) +4. Sauvegarder +5. Repeter pour chaque mappage + +### Option B : Migration automatique avec l'outil communautaire (beaucoup de mappages) + +Un outil gratuit existe : [Migration Tool](https://github.com/EasyG0ing1/Migration) + +**Etapes :** + +1. **Exporter la config OPNsense** : + - Aller dans **System > Configuration > Backups** + - Cliquer **Download configuration** + - Tu obtiens un fichier `config.xml` + +2. **Telecharger l'outil Migration** : + ```bash + # Sur Linux : + wget https://github.com/EasyG0ing1/Migration/releases/latest/download/migrate-linux + chmod +x migrate-linux + ``` + +3. **Executer la migration** : + ```bash + # Placer config.xml dans le meme dossier + ./migrate-linux + ``` + - L'outil lit les `` de ISC DHCP dans le config.xml + - Il les convertit en reservations Kea + - Il genere un fichier `new_config.xml` + +4. **Importer dans OPNsense** : + - Aller dans **System > Configuration > Backups** + - Cliquer **Restore configuration** et selectionner `new_config.xml` + - ⚠️ **Decocher "Reboot after restore"** ! + - Cliquer **Restore** + +5. **Verifier** les reservations dans **Services > Kea DHCP > DHCPv4 > Reservations** + +### Option C : Migration via OPNsense 25.7+ (si disponible) + +A partir de la version 25.7, OPNsense propose des **options d'export integrees** +pour faciliter la transition des baux statiques. Verifier dans : +- **Services > ISC DHCPv4** → bouton Export (si present) + +--- + +## Etape 4 : Basculer d'ISC vers Kea + +C'est l'etape critique. Les deux services ne peuvent PAS tourner en meme temps sur la meme interface. + +### 4.1 Desactiver ISC DHCP + +1. Aller dans **Services > ISC DHCPv4 > [Chaque interface]** +2. **Decocher "Enable DHCP server on [interface]"** +3. Sauvegarder +4. Repeter pour chaque interface configuree + +### 4.2 Activer Kea DHCP + +1. Aller dans **Services > Kea DHCP > DHCPv4 > Settings** +2. Cocher **Enabled** +3. Selectionner les **Interfaces** (ex: LAN, VLAN10, etc.) +4. Cocher **Firewall rules** (ajoute automatiquement les regles necessaires) +5. Cliquer **Save** +6. Cliquer **Apply** pour demarrer le service + +> **Temps d'arret** : Quelques secondes entre la desactivation d'ISC et l'activation de Kea. +> Les clients existants conservent leur bail actuel, donc pas de coupure immediate. + +--- + +## Etape 5 : Verification post-migration + +### 5.1 Verifier le service + +1. Aller dans **Services > Kea DHCP > DHCPv4 > Settings** +2. Le badge doit afficher "Running" (vert) +3. Verifier aussi dans **System > Diagnostics > Services** que `kea-dhcp4` est actif + +### 5.2 Verifier les reservations + +1. **Services > Kea DHCP > DHCPv4 > Reservations** +2. Comparer avec ta liste de l'etape 1 : toutes les IPs/MAC sont presentes ? + +### 5.3 Tester l'attribution DHCP + +1. Sur un client, renouveler le bail : + - **Linux** : `sudo dhclient -r && sudo dhclient` + - **Windows** : `ipconfig /release && ipconfig /renew` + - **Mac** : Preferences Systeme > Reseau > Renouveler le bail DHCP +2. Verifier que le client recoit la bonne IP (surtout pour les reservations statiques) + +### 5.4 Verifier les logs + +1. **Services > Kea DHCP > Log File** +2. Chercher les messages d'attribution (DHCPACK) et d'erreur +3. Pas de warnings = migration reussie + +### 5.5 Verifier la resolution DNS + +Si tu utilises Unbound avec registrations DHCP : +- ⚠️ Kea ne synchronise PAS automatiquement les noms dynamiques avec Unbound +- Seules les reservations statiques sont synchronisees +- Pour les clients dynamiques, il faut creer des host overrides manuellement (ou via IPWatch !) + +--- + +## Etape 6 : Nettoyage + +1. **Verifier** pendant 24-48h que tout fonctionne (renouvellement des baux) +2. **Ne pas supprimer** ISC DHCP tout de suite (garder en backup) +3. Une fois satisfait, tu peux desinstaller le plugin ISC si souhaite + +--- + +## Etape 7 : Configurer l'API pour IPWatch + +Maintenant que Kea est actif, IPWatch peut utiliser l'API complete : + +### 7.1 Creer une cle API OPNsense (si pas deja fait) + +1. **System > Access > Users** +2. Creer un utilisateur dedie (ex: `ipwatch_api`) ou utiliser l'existant +3. Dans l'onglet **API Keys**, cliquer **+** pour generer une paire key/secret +4. **Sauvegarder** le key et le secret + +### 7.2 Tester la connexion API + +```bash +# Tester depuis la machine IPWatch : +curl -k -u "KEY:SECRET" https://10.0.0.1/api/core/service/search + +# Verifier que kea-dhcp4 apparait dans les services : +curl -k -u "KEY:SECRET" https://10.0.0.1/api/kea/dhcpv4/search_reservation +``` + +### 7.3 Configurer dans IPWatch + +Ajouter dans `config.yaml` : +```yaml +opnsense: + enabled: true + host: "10.0.0.1" + api_key: "ta_cle_api" + api_secret: "ton_secret_api" + verify_ssl: false # si certificat auto-signe + sync_interval: 300 # secondes entre chaque synchro +``` + +--- + +## Resume des endpoints Kea disponibles apres migration + +| Action | Endpoint | Methode | +|--------|----------|---------| +| Lister reservations | `/api/kea/dhcpv4/search_reservation` | GET/POST | +| Detail reservation | `/api/kea/dhcpv4/get_reservation/{uuid}` | GET | +| Ajouter reservation | `/api/kea/dhcpv4/add_reservation` | POST | +| Modifier reservation | `/api/kea/dhcpv4/set_reservation/{uuid}` | POST | +| Supprimer reservation | `/api/kea/dhcpv4/del_reservation/{uuid}` | POST | + +Champs : `subnet`, `ip_address`, `hw_address`, `hostname`, `description` + +--- + +## Depannage + +| Probleme | Solution | +|----------|----------| +| Kea ne demarre pas | Verifier les logs dans Services > Kea DHCP > Log File | +| Client ne recoit pas d'IP | Verifier que l'interface est selectionnee dans Settings | +| Reservation ne fonctionne pas | Verifier que l'IP est hors du pool dynamique | +| Conflit d'IP | Deux clients avec le meme MAC ? Verifier les doublons | +| API ne repond pas | Verifier le Control Agent (port 8000) et la cle API | +| DNS ne resout plus les noms | Creer des host overrides Unbound pour les clients dynamiques | + +--- + +## Sources + +- [Documentation officielle Kea DHCP OPNsense](https://docs.opnsense.org/manual/kea.html) +- [Documentation ISC DHCP (EOL)](https://docs.opnsense.org/manual/isc.html) +- [Guide migration homenetworkguy.com](https://homenetworkguy.com/how-to/migrate-from-isc-dhcp-to-dnsmasq-or-kea-dhcp-in-opnsense/) +- [Outil de migration communautaire](https://github.com/EasyG0ing1/Migration) +- [Forum OPNsense - Migration ISC vers Kea](https://forum.opnsense.org/index.php?topic=40359.0) +- [Forum OPNsense - Migration 25.7](https://forum.opnsense.org/index.php?topic=48030.0) diff --git a/workflow-scan.md b/workflow-scan.md new file mode 100755 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