666 lines
20 KiB
Python
Executable File
666 lines
20 KiB
Python
Executable File
"""
|
|
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)}")
|