""" 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)}")