# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Synchronisation automatique

**CRITIQUE** : toujours dans cet ordre — commit **puis** sync :

```bash
git add -A && git commit -m "..."
bash ~/sync.sh dhcpman
```

Cela synchronise le projet vers `/home/algalord/smbhome/192.168.2.1/algalord/www/dhcpman`.

**Attention `git add -A`** : `.claude/settings.local.json` est réécrit de façon atomique
(fichier `.tmp.*` transitoire) ; `git add -A` peut échouer avec
`fatal: unable to stat '.claude/...tmp...'`. Le `.gitignore` couvre `.claude/`, `memory/`
et `*.tmp.*`, mais en cas d'échec, commiter des fichiers **ciblés** plutôt que `-A`.

## Mémoire de session

**La mémoire de session Claude est stockée dans `memory/` à la racine du repo**, PAS dans
`~/.claude/projects/.../memory`. Ce choix la fait suivre le projet via `~/sync.sh` (partage
entre machines). L'index est `memory/MEMORY.md`. Le dossier est dans `.gitignore` (pas
committé) mais copié par rsync. Écrire les nouveaux souvenirs ici, ne pas recréer de dossier
mémoire sous `~/.claude`.

**À l'ouverture d'une nouvelle session** : annoncer que la mémoire est dans `memory/` (repo),
lire `memory/MEMORY.md` et vérifier que les notes listées existent et sont cohérentes (rien de
perdu). Voir `memory/project_session_start_check.md`.

## Contrainte offline

**L'application doit fonctionner sans accès internet.**
- Aucune dépendance CDN externe (Bootstrap, fonts, icons, etc.)
- Tous les assets statiques sont servis localement depuis `public/assets/`
- Ne jamais ajouter de lien vers un CDN externe

## Accès MySQL direct (migrations)

Pour appliquer les migrations SQL sans intervention de l'utilisateur :

```bash
mysql -h 192.168.1.2 -u claude -pclaudedev123 kea < sql/ma_migration.sql
```

- Hôte : `192.168.1.2`
- Utilisateur : `claude`
- Mot de passe : `claudedev123`
- Base : `kea`

## Project Overview

**DHCPMan** — Application web PHP de gestion des réservations DHCP pour Kea DHCP Server.

- Écrit directement dans le schéma MySQL natif de Kea (table `hosts`)
- Deux modes : AlmaLinux (prod, API Kea directe) et FreeBSD (lab, via `kea-sync-from-db.py`)
- Pas de framework PHP — PHP vanilla + PDO + Bootstrap 5 (servi en local depuis `public/assets/`)
- Contrôle d'accès par rôles : admin / tech / viewer

## Structure du projet

```
config/config.php               — Credentials MySQL (non versionné, créer depuis config.example.php)
lib/                            — Classes PHP (non accessibles via le web)
  Database.php                  — Singleton PDO
  Auth.php                      — Login/logout, rôles, gestion utilisateurs
  Settings.php                  — Lecture/écriture app_settings
  Subnet.php                    — CRUD subnets (app_subnets + syncToKea vers tables natives Kea)
  Reservation.php               — CRUD réservations (table Kea hosts + app_reservation_meta)
  SyncService.php               — Déclenche reload Kea (selon freebsd_mode)
  LeaseService.php              — Lecture des leases via API Kea (tous les modes)
  AuditLog.php                  — Écriture dans app_audit_log
  helpers.php                   — macToBinary(), binaryToMac(), ipToInt(), intToIp(), h()
  init.php                      — Bootstrap: config, session, autoload
  layout_header.php             — HTML head + sidebar (Navigation/Administration figées, liste subnets scrollable + filtre rapide)
  layout_footer.php             — Fermeture HTML commune
public/                         — DocumentRoot Apache
  index.php                     — Page login
  setup.php                     — Création premier compte admin (désactivé si users existent)
  logout.php
  dashboard.php
  subnet.php                    — Détail subnet + liste réservations (rendu JS, 2 modes groupés/plat, tri IP, filtre multi-champs + syntaxe range IP, sélection multiple + popup mass update, bargraph de remplissage dans le header)
  subnets.php                   — Liste et gestion des subnets (badge inactif/public, toggle, bouton import global)
  subnet_add.php                — [admin|tech] (pré-remplit kea_subnet_id via nextAvailableId, checkbox is_public)
  subnet_edit.php               — [admin|tech] (checkbox is_public)
  subnet_delete.php             — [admin|tech] (option cascade suppression réservations si subnet non vide)
  subnet_toggle.php             — [admin|tech] Activation/désactivation subnet (POST uniquement)
  subnet_export.php             — Téléchargement CSV des réservations d'un subnet (?template=1 pour modèle vide)
  subnet_import.php             — [admin|tech] Import CSV mono ou multi-subnet avec gestion des conflits (3 étapes)
  leases.php                    — Leases DHCP actifs (tous rôles, lecture seule)
  reservation_add.php           — [admin|tech] (GET params mac=, ip=, subnet_id=, description=, request_id= pour pré-remplissage)
  reservation_edit.php          — [admin|tech]
  reservation_delete.php        — [admin|tech]
  reservation_bulk.php          — [admin|tech] Mass update de réservations sélectionnées (POST uniquement)
  api_check_duplicate.php       — Endpoint AJAX vérification doublons MAC/IP
  change_password.php           — Changement de mot de passe (tous les rôles)
  settings.php                  — [admin] Paramètres app + gestion utilisateurs + onglet Debug API Kea
  user_add.php                  — [admin] Création utilisateur
  user_role.php                 — [admin] Changement de rôle (POST uniquement)
  user_toggle.php               — [admin] Activation/désactivation compte (POST uniquement)
  user_reset_password.php       — [admin] Réinitialisation mot de passe (POST uniquement)
  user_delete.php               — [admin] Suppression compte (POST uniquement)
  audit.php                     — Journal des actions (tous les rôles)
  reservation_request.php       — Formulaire public de demande de réservation (sans auth)
  requests.php                  — [admin|tech] Liste des demandes en attente + accepter/refuser
  request_delete.php            — [admin|tech] Suppression demande (POST uniquement)
  assets/css/app.css
  assets/js/subnet-bargraph.js        — Composant bargraph de remplissage subnet (métier/layout/rendu, inspiré Titan)
  assets/js/subnet-bargraph.test.html — Tests navigateur de la couche métier du bargraph
  assets/js/subnet-bargraph-demo.html — Démo statique /23
sql/
  init_app_tables.sql           — Tables supplémentaires complètes v0.9 (installation fraîche)
  add_role_column.sql           — Migration v0.2 : ajout colonne role dans app_users
  add_is_active_subnet.sql      — Migration v0.3 : ajout colonne is_active dans app_subnets
  add_relay_mode.sql            — Migration v0.8 : relay_address + paramètres relay_mode/relay_interface
  add_public_subnet.sql         — Migration v0.9 : is_public + table app_reservation_requests
kea-sync-from-db/
  kea-sync-from-db.py           — Serveur HTTP Python (FreeBSD uniquement)
  kea-sync-from-db.conf.example — Exemple de configuration
  kea-sync-from-db.rc           — Script rc.d FreeBSD
```

## Configuration Apache (VirtualHost)

```apache
<VirtualHost *:80>
    ServerName dhcpman.local
    DocumentRoot /home/algalord/www/dhcpman/public
    <Directory /home/algalord/www/dhcpman/public>
        AllowOverride All
        Require all granted
    </Directory>
</VirtualHost>
```

## Installation

```bash
# 1. Copier la config
cp config/config.example.php config/config.php
# Éditer config/config.php avec les credentials MySQL

# 2. Initialiser les tables supplémentaires (schéma Kea déjà en place via kea-admin db-init)
# init_app_tables.sql est complet v0.9 — un seul fichier suffit pour une installation fraîche
mysql -u kea -p kea < sql/init_app_tables.sql

# 3. Créer le premier compte admin via le navigateur (rôle admin attribué automatiquement)
http://dhcpman.local/setup.php

# 4. (AlmaLinux uniquement) Autoriser Apache à contacter l'API Kea en localhost
# SELinux bloque par défaut les connexions réseau sortantes d'Apache
setsebool -P httpd_can_network_connect on
```

### Migration sur installation existante (v0.12 → v0.13)

```bash
# Ajoute group_name dans app_reservation_meta (regroupement des réservations)
mysql -u kea -p kea < sql/add_reservation_group.sql
```

Ajoute une métadonnée applicative de groupe (texte libre) sur chaque réservation.
Aucune table native Kea n'est modifiée.

### Migration sur installation existante (v0.11 → v0.12)

```bash
# Ajoute short_lease_pool_start et short_lease_pool_end dans app_subnets
mysql -u kea -p kea < sql/add_short_lease_pool.sql
```

Ajoute un pool d'adresses dédié aux hôtes short-lease sur chaque subnet (optionnel).
Quand défini, l'IP d'une réservation short-lease devient optionnelle (allocation dynamique depuis ce pool).

### Migration sur installation existante (v0.10 → v0.11)

```bash
# Ajoute short_lease dans app_reservation_meta
mysql -u kea -p kea < sql/add_short_lease.sql
```

La client class `short-lease` est gérée automatiquement :
- **AlmaLinux** : upsertée dans `dhcp4_client_class` + liée à `dhcp4_client_class_server` (server_id=1) à chaque `syncToKea()`
- **FreeBSD** : générée dans `kea-dhcp4.conf` par `kea-sync-from-db.py` à chaque sync

Aucune intervention manuelle requise.

### Migration sur installation existante (v0.9 → v0.10)

```bash
# Aucune migration SQL requise.
# Aucune modification de configuration requise.
```

### Migration sur installation existante (v0.8 → v0.9)

```bash
# Ajoute is_public dans app_subnets + table app_reservation_requests
mysql -u kea -p kea < sql/add_public_subnet.sql
```

### Migration sur installation existante (v0.7 → v0.8)

```bash
# Ajoute relay_address dans app_subnets + paramètres relay_mode / relay_interface dans app_settings
mysql -u kea -p kea < sql/add_relay_mode.sql
```

### Migration sur installation existante (v0.1 → v0.2)

```bash
# Ajoute la colonne role et passe id=1 en admin
mysql -u kea -p kea < sql/add_role_column.sql
```

### Migration sur installation existante (v0.5 → v0.6)

```bash
# Aucune migration SQL requise.
# Aucune modification de configuration requise.
# Les options hôte utilisent la table dhcp4_options native Kea (scope_id = 4), déjà existante.
```

### Migration sur installation existante (v0.4 → v0.5)

```bash
# Aucune migration SQL requise.
# Aucune modification de configuration requise.
```

### Migration sur installation existante (v0.3 → v0.4)

```bash
# Aucune migration SQL requise.
# Ajouter lease_cmds_lib dans /usr/local/etc/kea/kea-sync-from-db.conf :
#   lease_cmds_lib = /usr/local/lib/kea/hooks/libdhcp_lease_cmds.so
# Puis déclencher un sync depuis Paramètres → Tester la synchronisation.
```

### Migration sur installation existante (v0.2 → v0.3)

```bash
# Ajoute la colonne is_active (défaut 1 = actif) dans app_subnets
mysql -u kea -p kea < sql/add_is_active_subnet.sql
```

## Base de données

### Schéma Kea natif (déjà existant)
Table principale : `hosts`
- `dhcp_identifier` — VARBINARY(128), MAC en binaire (`UNHEX('bcdd...')`)
- `dhcp_identifier_type` — TINYINT, **0 = hw-address (MAC)** — utiliser `Reservation::HW_ADDR_TYPE`
- `dhcp4_subnet_id` — INT, référence le subnet Kea
- `ipv4_address` — INT UNSIGNED, IP stockée via `INET_ATON()`
- `hostname` — VARCHAR(255)

### Conversions critiques
```php
// MAC string → binaire pour DB
macToBinary('bc:dd:c2:47:3d:b6')  // hex2bin(str_replace(':', '', $mac))

// Binaire → MAC string pour affichage
binaryToMac($binary)  // implode(':', str_split(bin2hex($bin), 2))
// Depuis HEX() SQL : formatMacHex($row['mac_hex'])

// IP string → INT UNSIGNED pour DB
ipToInt('192.168.1.111')  // sprintf('%u', ip2long($ip))

// INT UNSIGNED → IP string
intToIp(3232235887)  // long2ip($int)
```

### Tables supplémentaires (préfixe `app_`)

**`app_users`**
```sql
id, username, password_hash, is_active,
role ENUM('admin','tech','viewer') DEFAULT 'tech',
created_at, last_login
```

**`app_subnets`**
```sql
id, kea_subnet_id, name, cidr, interface, gateway,
relay_address VARCHAR(45),                 -- v0.8 : GIADDR (IP interface VLAN du switch en mode relay)
dns_servers, ntp_servers,
range_start, range_end, has_dynamic_pool TINYINT(1),
is_active TINYINT(1) NOT NULL DEFAULT 1,   -- v0.3 : 0 = désactivé (exclu de syncToKea)
is_public TINYINT(1) NOT NULL DEFAULT 0,   -- v0.9 : 1 = visible dans le formulaire public de demande
notes,
reserved_ips TEXT NULL,                    -- v0.16 : liste informative d'IP/ranges réservés hors DHCP (1 par ligne, format <ip-or-range>[#description])
short_lease_pool_start VARCHAR(45),        -- v0.12 : IP de début du pool réservé aux hôtes short-lease
short_lease_pool_end   VARCHAR(45)         -- v0.12 : IP de fin du pool réservé aux hôtes short-lease
```

**`app_reservation_requests`** — demandes de réservation soumises par des utilisateurs non authentifiés
```sql
id, requester_name, requester_email, mac_address,
subnet_id (FK app_subnets.id),
description, status ENUM('pending') DEFAULT 'pending',
created_at
```

**`app_reservation_meta`** — description/notes par réservation (clé `host_id`)
- `short_lease TINYINT(1) DEFAULT 0` — v0.11 : lease court 1h via client class `short-lease` dans `hosts.dhcp4_client_classes`
- `group_name VARCHAR(64) NULL` — v0.13 : nom de groupe applicatif (regroupement d'affichage)

**`app_audit_log`** — historique des actions (user_id, username, action, object_type, object_id, details, ip)

**`app_settings`** — configuration clé/valeur :
- `app_name`, `domain_name`, `default_lease_time`
- `freebsd_mode` — `'0'` = AlmaLinux, `'1'` = FreeBSD
- `kea_api_url`, `kea_api_user`, `kea_api_password` — Kea Control Agent (leases = tous modes ; config-reload = mode AlmaLinux uniquement)
- `sync_server_url`, `sync_token` — mode FreeBSD
- `relay_mode` — `'0'` = direct (une interface par subnet), `'1'` = relay (switch avec `ip helper-address`)
- `relay_interface` — interface Kea pour les paquets UDP relayés (ex: `igc0`) — mode relay uniquement

## Mode Relay DHCP (v0.8)

Paramètre global `relay_mode`. Quand activé :
- Kea ne se lie plus qu'à une seule interface (`relay_interface`)
- Chaque subnet déclare son adresse relay = IP de l'interface VLAN du core switch (GIADDR dans le paquet)
- Côté switch : `ip helper-address <ip_kea>` sur chaque interface VLAN

### Colonne `app_subnets.relay_address`
IP de l'interface VLAN du switch qui relaie ce subnet (ex: `192.168.10.1` pour le VLAN 10).

### Comportement par mode

| | Mode direct (`relay_mode=0`) | Mode relay (`relay_mode=1`) |
|-|-----------------------------|-----------------------------|
| Kea `interfaces-config` | une interface par subnet | `relay_interface` + `dhcp-socket-type: udp` |
| Subnet : clé `interface` | `app_subnets.interface` | absent |
| Subnet : clé `relay` | absent | `["relay_address"]` (Kea 3.0+) |
| `dhcp4_subnet.relay` (CB) | NULL | JSON array avec GIADDR |

**Format relay Kea 3.0** : `dhcp4_subnet.relay` doit contenir `["x.x.x.x"]` (tableau JSON simple).
L'ancien format Kea 2.x `{"ip-addresses":["x.x.x.x"]}` n'est plus accepté.

**Mode AlmaLinux + relay** : `dhcp-socket-type: udp` dans `interfaces-config` est hors Config Backend MySQL.
Le paramètre doit être ajouté manuellement dans `kea-dhcp4.conf`. L'UI affiche un avertissement.

**Mode FreeBSD + relay** : `kea-sync-from-db.py` génère automatiquement le bon `interfaces-config`
avec `dhcp-socket-type: udp` et les blocs `relay` par subnet.

## Contrôle d'accès par rôles

| Rôle   | Accès |
|--------|-------|
| admin  | Tout, y compris settings et gestion utilisateurs |
| tech   | Tout sauf gestion utilisateurs (subnet + réservations en lecture/écriture) |
| viewer | Lecture seule (dashboard, subnets, réservations, journal) |

### Méthodes Auth importantes
```php
Auth::requireLogin()                    // Redirige vers index.php si non connecté
Auth::requireRole('admin', 'tech')      // Redirige vers dashboard si rôle insuffisant
Auth::currentUserRole()                 // Retourne 'admin', 'tech' ou 'viewer'
Auth::currentUserId()                   // int|null
Auth::currentUsername()                 // string
Auth::setRole(int $userId, string $role)
Auth::createUser(string $u, string $p, string $role = 'tech')
Auth::changePassword(int $userId, string $newPassword)
Auth::toggleActive(int $userId, bool $active)
```

### Règles protégées
- `Auth::requireRole('admin', 'tech')` en tête de : subnet_add/edit/delete, reservation_add/edit/delete
- `Auth::requireRole('admin')` en tête de : settings, user_add, user_role, user_toggle, user_reset_password, user_delete
- Boutons d'action masqués côté UI pour les viewers (`Auth::currentUserRole() !== 'viewer'`)
- Lien "Paramètres" dans la sidebar masqué pour non-admins

### Garde-fous user_delete et user_role
- Impossible de supprimer/désactiver/dégrader son propre compte
- Impossible de supprimer le dernier admin

## Modes de synchronisation

Contrôlé par `app_settings.freebsd_mode` :

**Mode AlmaLinux (`freebsd_mode = 0`)** :
```
Modification SQL → SyncService::sync()
  1. Subnet::syncToKea()          — sync app_subnets (actifs) → tables natives Kea
  2. POST http://[kea_api_url]/   — config-reload dhcp4
     { "command": "config-reload", "service": ["dhcp4"] }
     Authorization: Basic [base64(kea_api_user:kea_api_password)]  (si user non vide)
```

**Mode FreeBSD (`freebsd_mode = 1`)** :
```
Modification SQL → SyncService::sync() → POST http://[sync_server_url]
Header: X-Token: [sync_token]
Réponses possibles :
  { "status": "ok" }                              → succès complet
  { "status": "partial", "message": "..." }       → config écrite, Kea API KO
  { "status": "error",   "message": "..." }       → erreur sync_server
```

`SyncService::sync()` retourne `['success' => bool, 'message' => string]`.
Un échec du sync est affiché comme **warning** (orange) mais ne rollback pas la modification SQL.
Le statut `partial` est traité comme `success=false` avec un message explicite
("Config synchronisée — Kea API non joignable").

## Tables natives Kea Config Backend (mode AlmaLinux)

`Subnet::syncToKea()` synchronise `app_subnets` (où `is_active = 1`) vers les tables MySQL natives
de Kea. **Règles critiques** à respecter :

### 1. `createAuditRevisionDHCP4()` obligatoire avant tout DML

```php
$db->exec("CALL createAuditRevisionDHCP4('$now', 'all', 'DHCPMan sync', 0)");
```

- Cette procédure Kea initialise un état interne de session requis par tous les triggers
  (`@kea_audit_revision_id`, etc.)
- **NE PAS** tenter de setter `@kea_audit_revision_id` manuellement via `SET` ou `SELECT ... :=`
  cela ne fonctionne pas (variable NULL dans le trigger malgré la valeur définie côté PHP)
- L'argument `server_tag = 'all'` correspond à `dhcp4_server.id = 1`

### 2. Ordre de suppression (éviter SQLSTATE 1442)

Les triggers `BDEL` de Kea créent une dépendance circulaire si l'on supprime depuis `dhcp4_subnet`
directement (le trigger supprime les enfants, dont les triggers tentent de modifier `dhcp4_subnet`
— MySQL refuse). **Toujours supprimer dans cet ordre** :

```php
$db->exec("DELETE FROM dhcp4_options      WHERE dhcp4_subnet_id IN ($in) AND scope_id = 1");
$db->exec("DELETE FROM dhcp4_pool         WHERE subnet_id        IN ($in)");
$db->exec("DELETE FROM dhcp4_subnet_server WHERE subnet_id       IN ($in)");
$db->exec("DELETE FROM dhcp4_subnet       WHERE subnet_id        IN ($in)");
```

### 3. Codes d'options et scope_id

| Table         | Colonne clé         | Valeur          |
|---------------|---------------------|-----------------|
| dhcp4_options | `scope_id`          | `1` = subnet, `4` = host |
| dhcp4_options | `code = 3`          | routers (gateway) |
| dhcp4_options | `code = 6`          | domain-name-servers |
| dhcp4_options | `code = 121`        | classless-static-routes (host uniquement) |
| dhcp4_pool    | `start/end_address` | via `INET_ATON()` |
| dhcp4_subnet_server | `server_id`   | `1` (tag 'all') |

## Méthodes clés Subnet.php

```php
Subnet::getAll()                        // Liste tous les subnets avec reservation_count
Subnet::getById(int $id)                // Retourne null si inexistant
Subnet::getPublic(): array              // Subnets is_public=1 et is_active=1 (id, name uniquement)
Subnet::getByKeaSubnetId(int $keaId)    // Lookup par kea_subnet_id natif
Subnet::add(array $data): int           // INSERT + retourne l'id applicatif (inclut is_public)
Subnet::update(int $id, array $data)    // (inclut is_public)
Subnet::delete(int $id)
Subnet::toggle(int $id)                 // is_active = 1 - is_active
Subnet::nextAvailableId(): int          // Premier kea_subnet_id libre (sans trou, depuis 1)
Subnet::syncToKea(): void               // Sync app_subnets (actifs) → tables natives Kea
Subnet::validate(array $data, ?int $excludeId): array  // Retourne tableau d'erreurs
```

## LeaseService.php

```php
LeaseService::getLeases(): array
// Retourne ['success' => bool, 'leases' => array, 'message' => string]
// Appelle lease4-get-all sur kea_api_url (avec Basic Auth si kea_api_user non vide)
// Fonctionne en mode AlmaLinux ET FreeBSD (appel direct à kea-ctrl-agent)
// result=0 ou result=3 (vide) → success=true ; autres → success=false
```

Champs d'un lease retourné par Kea :
- `ip-address` — string
- `hw-address` — string MAC (format bc:dd:c2:47:3d:b6)
- `hostname` — string
- `subnet-id` — int (kea_subnet_id)
- `cltt` — int (unix ts de la dernière transaction)
- `valid-lft` — int (durée en secondes)
- `state` — int : **0** = actif, **1** = refusé (declined), **2** = expiré-reclaimé

Expiry = `cltt + valid-lft`. Calculé côté JS pour que le "temps restant" s'actualise sans rechargement.

## leases.php (page Leases)

- Tous les rôles (lecture seule)
- PHP enrichit les données : `ip_int` (tri numérique), `subnet_name`, `subnet_app_id`, `host_id` (null si pas de réservation)
- Rendu entièrement côté JS (données injectées via `json_encode`)
- Filtres : recherche texte (IP/MAC/hostname), subnet, état
- Tri : clic sur n'importe quel en-tête de colonne
- **Toujours groupé par subnet** (en-tête `tr.lease-group-row` réutilisant `.group-header`),
  subnets triés alpha par nom. Le PHP charge aussi `group_name` (jointure `app_reservation_meta`).
- **Bouton Groupés / Plat** persisté en `localStorage` (`leases_view_mode`), **groupé par
  défaut** — ne contrôle QUE l'intérieur d'un subnet :
  - Groupés : sous-groupes par groupe de réservation (`group_name`), sous-en-tête indenté
    `.group-subheader`, `(sans groupe)` en dernier, triés alpha. Comme `subnet.php`.
  - Plat : liste simple des leases du subnet (pas de sous-groupes).
  - Le tri de colonne s'applique au niveau le plus fin (dans le sous-groupe, ou dans le subnet).
- Stats : Total affiché / Actifs / Refusés / Expirés (identiques dans les deux modes)
- Actions par ligne :
  - Si `host_id` connu → lien vers `reservation_edit.php?id=X`
  - Sinon → lien vers `reservation_add.php?subnet_id=X&mac=...&ip=...&hostname=...`
- Actualisation automatique du temps restant toutes les 30 secondes

## Import/Export CSV (v0.5, étendu v0.10)

### subnet_export.php

- Tous les rôles (lecture seule)
- `?subnet_id=X` — télécharge les réservations du subnet en CSV (UTF-8 BOM pour compatibilité Excel)
- `?subnet_id=X&template=1` — télécharge un fichier CSV avec uniquement les en-têtes (modèle vide)
- Colonnes : `mac`, `ip`, `hostname`, `description`, `group`, `gateway`, `routes`, `short_lease`, `no_gateway`
- `group` : nom de groupe applicatif de la réservation (texte libre, colonne `app_reservation_meta.group_name`), vide si sans groupe.
- `no_gateway` : `1` si l'hôte a l'option "ne pas envoyer de gateway" (option 3 = `0.0.0.0`), `0` sinon.
  Quand `no_gateway=1`, la colonne `gateway` est exportée vide.
- Nom de fichier : `reservations-{slug-du-nom-subnet}.csv`

### subnet_import.php (v0.10 — mode multi-subnet)

- Rôles `admin` et `tech` uniquement
- `?subnet_id=X` → import mono-subnet (comportement historique)
- Sans `subnet_id` → import global multi-subnet (bouton "Import CSV global" dans subnets.php)
- 3 étapes :
  1. **Étape 0 (GET)** : formulaire d'upload CSV + lien vers modèle vide
  2. **Étape 1 (POST fichier)** : parse CSV, analyse des conflits, affiche un tableau de prévisualisation
  3. **Étape 2 (POST confirm)** : applique l'import avec les résolutions choisies, appelle `SyncService::sync()`

### Mode multi-subnet (v0.10)

- Le CSV doit contenir une colonne `subnet` avec le nom exact du subnet de destination
- Résolution par nom (insensible à la casse) dans `subnetsByName`
- Nom vide ou inconnu → statut `invalid`
- `$batchIps[$keaSubnetId][$ip]` et `$existingIpsCache[$keaId]` indexés par kea_subnet_id (évite les faux positifs cross-subnet)
- JS `validateIp()` scopé par `data-subnet` : vérification doublons dans le même subnet uniquement
- Colonne "Subnet" visible dans la prévisualisation si mode multi ou colonne détectée

### Colonnes optionnelles d'options DHCP

- `gateway` — gateway spécifique (option 3)
- `routes` — routes statiques (option 121), format `CIDR-gw` séparé par `|`
- `short_lease` — `1` pour activer le lease court 1h
- `no_gateway` — `1` pour l'option "ne pas envoyer de gateway" (option 3 = `0.0.0.0`).
  **`no_gateway=1` l'emporte** : si la ligne a aussi une `gateway` renseignée, celle-ci est ignorée
  (cohérent avec l'UI où les deux sont mutuellement exclusifs). La priorité est appliquée à la fois
  dans `analyzeRows()` (aperçu) et à l'étape 2 (application), en vidant `gateway`.
- Ces valeurs sont propagées telles quelles dans `$data` passé à `Reservation::add()/update()`.

### Statuts de conflit

| Statut | Condition | Action proposée |
|--------|-----------|-----------------|
| `clean` | Ni MAC ni IP connus | Ajout direct |
| `skip` | MAC **et** IP déjà réservés ensemble | Ignoré (doublon exact) |
| `ip_conflict` | IP déjà assignée à **une autre** MAC | Saisir une nouvelle IP |
| `mac_conflict` | MAC déjà réservée avec **une autre** IP | `update` (prendre l'IP importée) / `keep` (garder l'existante) / `custom_ip` (saisir une autre IP) |
| `invalid` | Format MAC/IP invalide, IP hors CIDR, subnet inconnu | Toujours ignoré |
| `pool` | IP dans le pool dynamique | Toujours ignoré |

### Fonctions internes subnet_import.php

```php
normalizeMac(string $raw): string       // → 'bc:dd:c2:47:3d:b6'
inPool(string $ip, array $subnet): bool
parseCsv(string $path): array           // BOM UTF-8, auto-délimiteur , ou ;
                                        // Retourne ['rows'=>[], 'has_subnet_col'=>bool] ou ['error'=>string]
analyzeRows(
    array $rawRows,
    ?array $defaultSubnet,              // null en mode global
    array $subnetsByName,               // tous les subnets indexés par name lowercase
    bool $hasSubnetCol
): array
```

## Demandes de réservation publiques (v0.9)

Permet à des utilisateurs non authentifiés de soumettre une demande de réservation DHCP.

### Workflow

1. Visiteur soumet le formulaire `reservation_request.php` (nom, email, MAC, subnet, description)
2. La demande est insérée dans `app_reservation_requests` (status = `pending`)
3. Un badge rouge apparaît dans la sidebar des admin/tech avec le nombre de demandes
4. Admin/tech consulte `requests.php` et choisit :
   - **Accepter** → redirect vers `reservation_add.php?subnet_id=X&mac=...&description=...&request_id=X`
     - À la sauvegarde, `reservation_add.php` supprime automatiquement la demande (`ReservationRequest::delete($requestId)`)
   - **Refuser** → POST vers `request_delete.php` (supprime la demande + AuditLog `requests.refuse`)

### Subnets publics

- Colonne `is_public` dans `app_subnets` (checkbox dans subnet_add/edit)
- `Subnet::getPublic()` — retourne uniquement `id` et `name` (pas de détails réseau)
- `reservation_request.php` n'affiche que les subnets `is_public = 1 AND is_active = 1`
- Badge "public" (vert) dans `subnets.php` pour les subnets publics

### Classe ReservationRequest

```php
ReservationRequest::getAll(): array         // Toutes les demandes avec subnet_name
ReservationRequest::getById(int $id): ?array
ReservationRequest::add(array $data): int   // INSERT, retourne id
ReservationRequest::delete(int $id): void
ReservationRequest::count(): int            // Pour le badge sidebar
ReservationRequest::validate(array $data): array  // Retourne erreurs
// validate() vérifie : name (≤100 car.), email (filter_var), MAC (isValidMac),
//                      subnet_id (existe + is_public=1)
```

## Suppression de subnet avec réservations (v0.10)

`subnet_delete.php` propose une suppression en cascade au lieu de bloquer.

- Si le subnet a des réservations → affiche une checkbox `required` : "Supprimer aussi les N réservation(s)"
- La checkbox doit être cochée pour valider le formulaire (attribut HTML `required`)
- Guard côté POST : si des réservations existent et la checkbox n'est pas cochée → flash danger, redirection
- `Reservation::deleteBySubnet(int $keaSubnetId): int` — supprime hosts + meta + options hôte (scope_id=4), retourne le count
- L'AuditLog mentionne le nombre de réservations supprimées dans `details`

## Options DHCP par hôte (v0.6)

Stockées dans `dhcp4_options` (table native Kea) avec `scope_id = 4` et `host_id` comme clé.
**Aucune table `app_*` supplémentaire.**

| Option | Code | Format `formatted_value`                              |
|--------|------|-------------------------------------------------------|
| Gateway spécifique | 3   | `192.168.1.254`                          |
| Routes statiques   | 121 | `10.0.0.0/8 - 192.168.1.254, 172.16.0.0/12 - 192.168.1.254` |

### Règles RFC 3442

Quand l'option 121 est présente, les clients DHCP **ignorent l'option 3** (RFC 3442 §3).
- L'UI masque et vide le champ gateway dès qu'une route est ajoutée
- Un bouton génère automatiquement la route par défaut `0.0.0.0/0 → gateway subnet`

### Méthodes Reservation.php

```php
// $data accepte 'gateway' (string), 'routes' (array of ['dest', 'gw']), 'ntp_value' (string)
Reservation::add(int $keaSubnetId, array $data): int
Reservation::update(int $hostId, array $data): void
Reservation::delete(int $hostId): void          // supprime aussi dhcp4_options scope_id=4
Reservation::deleteBySubnet(int $keaSubnetId): int  // supprime toutes les réservations d'un subnet (v0.10)
Reservation::getById(int $hostId): ?array        // retourne gateway + routes + ntp
Reservation::getBySubnet(int $keaSubnetId): array  // has_options, has_ntp, group_name + détail options (gateway, no_gateway, routes, ntp_enabled) en une requête groupée
Reservation::getGroups(int $keaSubnetId): array   // noms de groupe distincts non vides du subnet (autocomplétion)
Reservation::massUpdate(array $hostIds, array $changes): int  // n'écrit QUE les champs de $changes ; recompose les options ; retourne le count
```

`$data` accepte aussi `'group_name'` (string) dans `add()`/`update()` (vide → `NULL` en base).

## Mass update des réservations (v0.14)

`subnet.php` permet de sélectionner plusieurs réservations (cases à cocher, case « tout »
par tableau/groupe) et de modifier en une fois leurs options via un popup (modale Bootstrap
construite côté JS, instantané). Barre d'action en bas de la liste (admin|tech uniquement).

- **Champs mass-updatables** : groupe, lease court, gateway, no-gateway, routes, NTP.
  Pas MAC/IP/hostname (identité unique). Sélection limitée à un subnet (subnet.php est mono-subnet).
- **États initiaux** reflètent la sélection : valeur commune affichée ; case cochée/décochée si
  homogène, indéterminée si « valeurs mêlées » ; routes « divergentes » avec bouton
  « Définir pour tous… » qui aligne toute la sélection.
- **Cases 3 états** (lease court, no-gateway, NTP) : clic = ON → OFF ; seul le **reset** revient
  à indéterminé (« ne pas changer »). Le ↺ reset d'un champ n'apparaît que s'il a été modifié.
- **Seuls les champs modifiés sont écrits** (marqueur `changed[<champ>]` côté POST). Un champ non
  touché reste intact sur chaque réservation.
- **Exclusivité RFC 3442** arbitrée par `Reservation::massUpdate()` : le champ **explicitement
  modifié** l'emporte sur un champ non modifié (no-gateway imposé vide routes/gateway ; routes
  imposées vident gateway/no-gateway ; etc.).
- **Application** : POST vers `reservation_bulk.php` (admin|tech, vérifie l'appartenance des
  host_ids au subnet), qui appelle `massUpdate()`, un seul `SyncService::sync()`, et un
  `AuditLog::log('reservation.bulk_update', ...)`.

Format des routes dans `$data['routes']` : `[['dest' => '10.0.0.0/8', 'gw' => '192.168.1.254'], ...]`

## Bargraph de remplissage de subnet (v0.15)

Barre horizontale unique dans le header du tableau de `subnet.php`, inspirée de la
vue Patch d'Avolites Titan. 100 % côté client, aucune table/migration.

- Module `public/assets/js/subnet-bargraph.js` en 3 couches pures :
  - **métier** : `computeBlocks` (fusion des réservations contiguës par type + injection
    des pools), `computeHoles` (trous dérivés), `largestAlignedCidr`, `maskForCount`.
  - **layout** : `layoutSegments` — proportionnel + plancher 5px sur les blocs occupés +
    renormalisation globale (Σ = 100 %). Les trous ne sont jamais planchés. Cas saturé
    (planchers > 100 %, barre étroite + beaucoup de micro-blocs) : mise à l'échelle uniforme
    finale pour garantir Σ = 100 % (plancher sacrifié).
  - **rendu** : `init({mount, rows, subnet, onFilter, canEdit})`, infobulle maison au survol.
- Couleurs par **type** : réservation (bleu), pool dynamique (ambre), pool short-lease
  (violet) ; trous hachurés. Une réservation short-lease à IP fixe reste bleue (statut
  dans l'infobulle). Fusion **par type** (pas par groupe) ; l'infobulle liste les groupes.
- **Clic sur un bloc réservation** → remplit le filtre existant avec la plage
  (`a.b.c.d-a.b.c.d`). Le filtre de `subnet.php` comprend désormais une **syntaxe de
  range** (`parseRange`), aussi tapable au clavier (forme courte `30-80` = octets de fin).
- **Clic sur un trou** (admin|tech) → `reservation_add.php` avec la 1re IP libre.
- Objet `SUBNET` injecté par `subnet.php` (bornes entières + pools + addUrl).
  Pour un /23 : 512 adresses, **510 utilisables** (`total`).
- Tests : ouvrir `public/assets/js/subnet-bargraph.test.html` au navigateur (vert/rouge).
  Démo : `public/assets/js/subnet-bargraph-demo.html` (/23, plusieurs trous, tous les types).

## IP réservées hors DHCP (v0.16)

Liste informative d'IP fixes (attribuées manuellement hors du serveur DHCP) attachée à
chaque subnet, pour éviter de créer une réservation DHCP sur une adresse déjà utilisée
ailleurs. Champ texte multiligne dans la config du subnet (`subnet_add.php` / `subnet_edit.php`),
**normalisé à l'enregistrement** (tri + reformatage canonique) avant écriture dans
`app_subnets.reserved_ips` — on peut saisir en vrac, à la relecture c'est propre.

### Migration
```bash
mysql -u kea -p kea < sql/add_reserved_ips.sql   # ADD COLUMN reserved_ips TEXT NULL AFTER notes
```

### Format d'une ligne
`<ip-or-range>[#description]` — une IP ou un range par ligne.
- Range : `192.168.2.60-192.168.2.70`, ou forme **courte** comme le filtre de `subnet.php`
  (octets de fin complétés par le préfixe réseau : `60-70` sur un /23 en 192.168.2.0 → 192.168.2.60–70).
- Description optionnelle après un `#` (précédé ou non d'un espace). Apparaît dans l'infobulle
  du bargraph.
- Lignes vides et lignes de commentaire pur (`# …`) ignorées.
- **Normalisation à l'enregistrement** (`ReservedIps::normalize`) : quelle que soit la saisie,
  le champ relu est trié par IP croissante et reformaté — `60` → `192.168.2.60`,
  `60-70` → `192.168.2.60-70`, `192.168.2.60-192.168.2.70` → `192.168.2.60-70`,
  desc en ` # desc`. Pas de fusion des plages (1 ligne saisie = 1 ligne, sa desc préservée).

### Classe `ReservedIps` (`lib/ReservedIps.php`) — source de vérité serveur, pure
```php
ReservedIps::parse(string $raw, int $networkInt): array
// → [['startInt','endInt','desc','line'], ...] (entiers non signés 32 bits)
ReservedIps::validate(string $raw, array $subnet): array
// → [['line'=>int, 'message'=>string], ...] : format, bornes CIDR (inclusives),
//   chevauchement pool dynamique / short-lease. Ligne 0 = CIDR subnet invalide.
ReservedIps::contains(string $raw, int $networkInt, string $ip): ?array
// → l'entrée qui couvre $ip (avec sa desc), ou null
ReservedIps::toBlocks(string $raw, int $networkInt): array
// → blocs triés + fusionnés (chevauchants ou adjacents), desc concaténées avec " · "
ReservedIps::normalize(string $raw, int $networkInt): string
// → texte canonique trié SANS fusion (1 ligne saisie = 1 ligne) : début en IP complète,
//   fin en forme courte (minimum d'octets communs, ex. 192.168.2.60-70 / 192.168.2.250-3.10),
//   description en " # desc". Appelé par Subnet::add/update après validate().
ReservedIps::networkIntFromCidr(string $cidr): ?int
// → int réseau non signé, ou null si préfixe > 32
```

### Validation à l'enregistrement du subnet (`Subnet::validate`)
- `ReservedIps::validate` → erreurs préfixées `IP réservées, ligne N : MESSAGE.`
- **Cross-check** : bloque l'enregistrement si une réservation DHCP existante du subnet
  (`hosts`) tombe dans un range réservé (message avec hostname, `(sans nom)` si vide).

### Blocage à la création d'une réservation
- `Reservation::validate` appelle `checkReservedConflict($subnetContext, $ip)` (le subnet
  complet est déjà passé en contexte, pas de re-fetch). Une IP dans un range réservé est
  refusée, comme le pool dynamique (`checkPoolConflict`).
- Feedback AJAX temps réel dans `api_check_duplicate.php` (type=ip) : message
  `IP réservée (hors DHCP) : <desc>`.

### UI formulaire (`reserved-ips-form.js` + CSS `.rip-*`)
Textarea redimensionnable/scrollable avec une **gouttière** à gauche affichant un `!` rouge
sur chaque ligne invalide ; le détail de l'erreur s'affiche **au survol du `!`** (infobulle
positionnée en bas-droite du curseur pour laisser la ligne visible). La gouttière se
synchronise au scroll et à la frappe (`translateY(-scrollTop)`). La validation JS
(`parseRange` forme courte, split sur le **premier** tiret comme `explode(..., 2)` en PHP)
reproduit fidèlement le verdict serveur. ES5 (var, IIFE).

### Bargraph (`subnet-bargraph.js`)
4e type de bloc `reserved` (gris ardoise `#64748b`, `--sbg-reserved`). `parseReserved`
(même forme courte + tri/fusion) alimente `computeBlocks` ; `reservedBlock` porte la `desc`
affichée dans l'infobulle. Bloc **non cliquable** (informatif). Compté comme occupé dans
le taux de remplissage. `subnet.php` injecte `reservedIps` dans l'objet `SUBNET`.

### kea-sync-from-db.py (v0.6)

`fetch_host_options(cnx)` — lit `dhcp4_options` scope_id=4, retourne `{host_id: {code: value}}`.
`build_config()` injecte `"option-data"` dans les objets hôte si options présentes :
```json
{ "name": "routers", "data": "192.168.1.254" }
{ "name": "classless-static-route", "data": "10.0.0.0/8 - 192.168.1.254" }
```

## Validation des réservations (Reservation::validate)

Vérifications dans l'ordre :
1. Format MAC (`isValidMac`)
2. Format IP (`isValidIp`)
3. Hostname obligatoire — regex `^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$` (pas de point, pas de FQDN)
4. Unicité MAC sur le subnet (`checkDuplicateMac`)
5. Unicité IP sur le subnet (`checkDuplicateIp`)
6. **IP hors pool dynamique** (`checkPoolConflict`) — si `has_dynamic_pool=1`, l'IP ne doit pas être dans `[range_start, range_end]`
7. **Gateway** — format IPv4 valide si renseignée (optionnelle)
8. **Routes** — chaque route : CIDR valide + IPv4 gateway valide (`isValidCidr` privée)

La vérification pool est aussi faite en AJAX dans `api_check_duplicate.php` (type=ip) pour le feedback temps réel.

## reservation_add.php — comportement autofocus

- `autofocus` sur MAC uniquement si le champ est **vide** (formulaire normal)
- Si MAC pré-rempli (GET param depuis leases.php) : pas d'autofocus → évite le problème de blur parasite au clic sur "Enregistrer"
- Dans les deux cas, `dispatchEvent('blur')` est déclenché au chargement sur MAC et IP s'ils ont une valeur : les vérifications AJAX s'exécutent avant toute interaction

## Flux d'une modification de subnet

1. Validation (`Subnet::validate()`)
2. Écriture dans `app_subnets`
3. `SyncService::sync()` — déclenché par add / edit / delete / toggle
4. `AuditLog::log()`
5. Flash success/warning selon résultat sync

## Flux d'une modification de réservation

1. Validation des inputs (`Reservation::validate()`)
2. Vérification unicité MAC + IP sur le subnet
3. Écriture dans `hosts` + `app_reservation_meta`
4. `SyncService::sync()`
5. `AuditLog::log()`
6. Flash success/warning selon résultat sync

## Vérification AJAX des doublons (api_check_duplicate.php)

Appelé au `blur` sur les champs MAC et IP des formulaires de réservation.

```
GET api_check_duplicate.php?type=mac|ip&value=...&subnet_id=...&exclude_host_id=...
Réponse : { "duplicate": bool, "message": "...", "invalid": bool }
```

- `exclude_host_id` : utilisé sur reservation_edit pour ne pas se signaler soi-même
- Retourne `invalid: true` si le format est invalide (pas de feedback UI dans ce cas)
- Feedback : `.is-valid` (vert) ou `.is-invalid` (rouge) Bootstrap sur le champ

## kea-sync-from-db.py (FreeBSD uniquement)

- Python 3.11+, dépendance unique : `mysql-connector-python`
- Config : `/usr/local/etc/kea/kea-sync-from-db.conf` (INI)
  ou variable d'env `SYNC_SERVER_CONF`
- Port : configurable (défaut 8765)
- Endpoints : `POST /sync` (X-Token requis), `GET /health`

### Options de lancement
```bash
# Mode serveur HTTP (normal)
python3.11 kea-sync-from-db.py

# Mode one-shot : regénère kea-dhcp4.conf et quitte (sans démarrer le serveur)
python3.11 kea-sync-from-db.py --oneshot
```

### Section [kea] du fichier de config
```ini
[kea]
api_url        = http://localhost:8000/
api_user       =                              # Basic Auth kea-ctrl-agent (vide = pas d'auth)
api_password   =
config_file    = /usr/local/etc/kea/kea-dhcp4.conf
control_socket = /tmp/kea-dhcp4-ctrl.sock     # Vide = bloc non généré
lease_cmds_lib = /usr/local/lib/kea/hooks/libdhcp_lease_cmds.so  # Vide = hook non chargé
```

`lease_cmds_lib` est requis pour que `lease4-get-all` fonctionne (page Leases).
Si non défini, le hook `hooks-libraries` n'est pas généré dans `kea-dhcp4.conf`.

### Ce que génère build_config()
- `interfaces-config` — interfaces des subnets
- `lease-database` — memfile `/var/db/kea/kea-leases4.csv`
- `valid-lifetime` — 86400s
- `control-socket` — si `control_socket` renseigné (requis pour kea-ctrl-agent → kea-dhcp4)
- `subnet4` — **subnets `is_active = 1` uniquement** (fetch_subnets filtre les inactifs)
  avec option-data (routers, DNS), pools dynamiques, réservations
  - Chaque réservation inclut `"option-data"` si des options hôte existent (scope_id=4)
- `hooks-libraries` — uniquement si `lease_cmds_lib` est renseigné dans la config
- `loggers` — syslog INFO

### Service rc.d FreeBSD

```
/usr/local/etc/rc.d/kea_sync_from_db
kea_sync_from_db_enable="YES"   dans /etc/rc.conf
```

- Tourne en tant que **root** (pas de `-u`)
- Utilise `daemon -r -P pidfile` : `-P` (majuscule) = PID du superviseur `daemon` dans le fichier
  (pas du child python) — **ne pas utiliser `-p` minuscule** (écrit le PID child → `rc.subr` échoue)
- Implémente `start_cmd`, `stop_cmd`, `status_cmd` comme fonctions shell custom pour contourner
  les problèmes de `rc.subr` avec `pgrep -F / -x daemon`
- `stop_cmd` : lit le PID depuis le fichier, vérifie via `kill -0`, envoie SIGTERM
- `status_cmd` : vérifie via `kill -0` si le processus superviseur est vivant
- Le rc.d exporte `SYNC_SERVER_CONF` avec le chemin du fichier de config

```bash
# Installation
cp kea-sync-from-db/kea-sync-from-db.rc /usr/local/etc/rc.d/kea_sync_from_db
chmod +x /usr/local/etc/rc.d/kea_sync_from_db
# Ajouter dans /etc/rc.conf : kea_sync_from_db_enable="YES"
service kea_sync_from_db start
```

## Sécurité

- Passwords hachés avec `password_hash($pass, PASSWORD_BCRYPT)`
- Toutes les requêtes DB via PDO + paramètres liés (pas de concaténation SQL)
- `Auth::requireLogin()` en tête de chaque page protégée
- `Auth::requireRole(...)` pour les pages sensibles
- Token kea-sync-from-db transmis dans header HTTP `X-Token` (pas en query string)
- `setup.php` refuse l'accès si des utilisateurs existent déjà
- `api_check_duplicate.php` protégé par `requireLogin()`
- Tous les handlers POST-only (user_role, user_toggle, etc.) redirigent sur GET
