# Libération de leases DHCP — Design

**Date :** 2026-07-15
**Statut :** Design validé, en attente de relecture

## Contexte et problème

DHCPMan ne manipule jamais les leases actifs : `LeaseService` sait seulement les lister
(`lease4-get-all`). Or les **leases** (état runtime : « qui utilise quelle IP maintenant »)
et les **réservations** (config : « quelle IP pour quelle MAC », en base) sont deux backends
distincts. Dans ce déploiement, les leases vivent en **memfile** (`/var/db/kea/kea-leases4.csv`),
géré par le démon Kea — jamais en MySQL.

Cas déclencheur : remplacement d'un appareil. L'ancien avait une réservation + un lease actif
encore valide plusieurs heures. Mettre à jour la MAC de la réservation ne suffit pas : le vieux
lease `IP → ancienne MAC` reste vivant dans le memfile, et le nouvel appareil n'obtiendra pas
son IP réservée tant que ce bail n'a pas expiré. La seule façon propre de purger un lease est
l'API Kea (`lease4-del`) — **surtout pas** l'édition du CSV (écrasé par le démon).

## Objectifs

1. **Bouton « Libérer le lease »** sur la page Leases (action manuelle, à tout moment).
2. **Warning « lease actif »** à la création/édition d'une réservation : si un lease actif
   est impacté, avertir l'utilisateur AVANT toute écriture, avec 3 choix.

Les deux s'appuient sur une brique commune `lease4-del`.

## Contraintes

- **Tout passe par l'API kea-ctrl-agent** (`lease4-del`). Aucune écriture SQL, aucune migration.
  Le hook `lease_cmds` (déjà requis pour `lease4-get-all`) fournit la commande.
- Fonctionne en mode AlmaLinux ET FreeBSD (appel direct à kea-ctrl-agent, comme `getLeases`).
- Ne jamais éditer le memfile CSV directement.
- Contrainte offline conservée (aucune dépendance externe).

## Architecture

Approche retenue : **brique de service partagée + handler POST dédié + confirmation intra-page**,
cohérente avec les patterns existants (handlers POST-only, confirmation à la `subnet_delete.php`,
`SyncService`/`LeaseService` comme services). Pas de routeur/contrôleur (étranger au codebase
PHP vanilla).

### Brique : `LeaseService` (nouvelles méthodes)

```php
LeaseService::releaseLease(string $ip): array
// POST { "command": "lease4-del", "service": ["dhcp4"], "arguments": { "ip-address": $ip } }
// Réutilise l'URL, l'auth Basic et httpPost() existants.
// Retour ['success' => bool, 'existed' => bool, 'message' => string] :
//   result 0 → success=true,  existed=true   (lease supprimé)
//   result 3 → success=true,  existed=false  (déjà parti/expiré — PAS une erreur)
//   autre    → success=false + message Kea
//   API injoignable / réponse invalide → success=false

LeaseService::findActiveLeases(array $ips): array
// Retourne les leases ACTIFS (state=0) trouvés sur les IP données.
// Implémentation : un seul appel getLeases(), filtrage côté PHP (évite N appels).
// Retour : [ ip => ['mac'=>string, 'hostname'=>string, 'expiry'=>int], ... ]
// N'inclut que state=0 (un lease declined/expired n'a pas à être « libéré »).
```

`result 3 = succès silencieux` est un choix délibéré : si le lease a expiré entre l'affichage
du warning et l'action, l'objectif (IP libre) est atteint, on ne plante pas.

### Détection des leases orphelins (helper)

Appelé dans `reservation_add.php` et `reservation_edit.php`, **après** `Reservation::validate()`
et **avant** toute écriture. Soit la résa à écrire `MAC_new / IP_new` :

- **`IP_new`** : lease actif tenu par une MAC **≠ `MAC_new`** → l'IP cible est occupée par un
  autre appareil.
- **`IP_old`** (édition, si l'IP a changé) : lease actif dessus → bail fantôme sur l'ancienne IP.

Un seul appel `findActiveLeases([IP_new, IP_old])`, puis filtrage selon ces règles. IP vide
(short-lease sans IP fixe) → détection sautée.

Ces règles sont encapsulées dans `LeaseService::orphanLeasesForReservation($macNew, $ipNew,
$ipOld = null): array` (retourne la liste des leases à proposer), partagée par `reservation_add`
(appelée avec `$ipOld = null`) et `reservation_edit`.

## Composants et flux

### 1. Bouton « Libérer » — page Leases

**UI (`leases.php`)** :
- Une action « libérer » par ligne de lease **actif** (state=0), visible admin|tech uniquement
  (masquée pour viewer). Absente sur les leases expirés/refusés.
- Clic → **confirmation JS** : « Libérer le lease `IP` (MAC …) ? L'IP redeviendra allouable. »
  [Annuler] [Libérer].
- Confirmé → **POST** vers `lease_release.php` (formulaire caché : `ip`, `mac`). Pas de GET.

**Handler (`lease_release.php`)** — nouveau, modèle des handlers POST-only :
```
- Auth::requireLogin() + Auth::requireRole('admin','tech')
- POST uniquement (GET → redirect leases.php)
- LeaseService::releaseLease($ip)
- AuditLog::log('lease.release', 'lease', null, "IP X (MAC A) — depuis leases.php")
- Flash success / warning (déjà expiré) / danger (échec API) → redirect leases.php
```

Après libération : redirect `leases.php` → `getLeases()` rafraîchit la liste. Aucune
suppression de la réservation associée (on libère le bail runtime, pas la config ; un nouveau
bail identique sera recréé au prochain DHCP si l'IP est réservée — voulu).

### 2. Warning « lease actif » — création / édition de réservation

Flux en deux temps, **rien écrit avant décision** (marqueur POST `lease_action`) :

```
1. Reservation::validate() — inchangé.
2. Détecter les leases orphelins (helper ci-dessus).
3a. Aucun lease → écrire directement (flux actuel, aucune étape en plus).
3b. Lease(s) détecté(s) ET pas de champ lease_action (premier submit)
      → RÉAFFICHER le formulaire en « mode confirmation » :
          - valeurs saisies conservées (repostées, champs pré-remplis)
          - encart d'avertissement listant chaque lease : IP, MAC, temps restant
          - 3 boutons :
              [Libérer et enregistrer]   → resubmit lease_action=release
              [Enregistrer sans libérer] → resubmit lease_action=ignore
              [Annuler]                  → retour formulaire éditable (ou liste)
      → RIEN n'est écrit.
4. Resubmit avec lease_action défini :
      - Écrire la réservation (hosts + meta) — flux normal.
      - Si lease_action=release → releaseLease() sur chaque IP concernée
        + AuditLog::log('lease.release', ..., "depuis reservation_edit|add").
      - SyncService::sync() (une fois). Flash + redirect habituel.
```

**Points de conception :**
- Confirmation **intra-page** (réaffichage du même formulaire en mode warning), pas une nouvelle
  page : l'utilisateur garde contexte et valeurs.
- `lease_action` (`release`/`ignore`) distingue « premier submit » de « décision prise » — même
  pattern à un cran que la checkbox `required` de `subnet_delete.php`.
- IP concernées transportées en champs cachés au resubmit, mais **re-détection au moment du
  release** (le lease a pu bouger) — `releaseLease` gère `result 3` sans erreur.
- Couvre **add ET edit** : à la création, un lease actif sur une IP hors pool avec bail résiduel
  est rare mais possible (l'IP du pool dynamique est déjà bloquée par `checkPoolConflict`). Même
  code de détection, faible surcoût, ferme le trou.

## Gestion des erreurs et cas limites

- **API injoignable au moment de libérer** :
  - Page Leases → flash danger « Impossible de joindre l'API Kea, lease non libéré ». Rien changé.
  - Édition/création avec `lease_action=release` → **la réservation est quand même écrite**
    (action principale) ; flash **warning** « Réservation enregistrée, mais le lease n'a pas pu
    être libéré (API injoignable) ». Pas de rollback (cohérent avec l'échec de sync = warning).
- **Lease déjà expiré entre warning et action** (`result 3`) → succès silencieux.
- **Détection impossible** (API injoignable à la soumission) → **on n'interrompt pas** : écriture
  normale sans warning (le filet est best-effort, il ne doit jamais bloquer une résa valide).
  Flash info optionnel « leases non vérifiés (API injoignable) ».
- **viewer forgeant un POST** vers `lease_release.php` → bloqué par `requireRole` côté handler.
- **IP vide** (short-lease sans IP fixe) → détection sautée proprement.

## Tests et vérification

- **`LeaseService::releaseLease`** : mapping `result` (0/3/erreur/injoignable) → retour, testable
  en isolation en mockant `httpPost` (payload `lease4-del` + parsing).
- **Détection des leases orphelins** : logique pure (règles MAC≠ / IP changée) testable avec des
  jeux de leases fictifs.
- **Vérification manuelle réelle** : pilotage end-to-end seulement si l'API Kea est joignable ;
  sinon signalé franchement, validation navigateur côté utilisateur.

## Livrables

| Fichier | Changement |
|---|---|
| `lib/LeaseService.php` | + `releaseLease($ip)`, + `findActiveLeases($ips)` |
| `lib/LeaseService.php` | + `orphanLeasesForReservation($macNew, $ipNew, $ipOld=null)` — applique les règles MAC≠ / IP changée au-dessus de `findActiveLeases` ; partagé par add & edit |
| `public/lease_release.php` | **nouveau** — handler POST-only (page Leases) |
| `public/leases.php` | + bouton « Libérer » (admin\|tech, confirmation JS) |
| `public/reservation_add.php` | + flux warning 3 choix à la soumission |
| `public/reservation_edit.php` | + flux warning 3 choix à la soumission |
| `CLAUDE.md` | doc du nouveau comportement + action `lease.release` |

**Aucune migration SQL. Aucune table `app_*` modifiée.**

## Hors périmètre (YAGNI)

- Marquage `declined` d'un lease (cas sécurité, pas le besoin).
- Détection AJAX temps réel au blur (l'utilisateur a choisi le warning à la soumission).
- Suppression automatique de la réservation lors d'une libération de lease.
