Contrat API — Enrôlement RADIUS d’un routeur (NAS)
Public : équipe app mobile MikhmoAI. Mis à jour le 2026-08-20.
Ce document décrit comment un routeur devient un NAS RADIUS, et surtout pourquoi la
demande échoue dans la grande majorité des cas aujourd’hui. Il complète
CONTRAT-API-RADIUS-TOPOLOGIE.md, qui couvre la lecture de la topologie.
L’état réel du parc (mesuré le 2026-08-17)
| Mesure | Valeur |
|---|---|
| Licences J+RADIUS actives | 469 |
| Clients avec un NAS utilisable | 1 |
| Licences vendues sur 7 jours | 124 |
| NAS créés sur 7 jours | 0 |
| Dernier NAS créé | 2026-07-27 |
Ce n’est pas une panne d’API : les points d’entrée répondent. C’est un enchaînement de préconditions qu’il faut pouvoir montrer à l’utilisateur — l’app ne le peut pas aujourd’hui, faute de motif exploitable.
GET /api/v1/radius/nas — liste des NAS
Répond toujours 200, même sans licence. Ne traitez pas la liste vide comme une erreur.
{
"nas": [],
"license_status": null,
"reason": "RADIUS_LICENSE_MISSING",
"message": "Aucune licence J+RADIUS pour ce compte."
}
reason et message sont nouveaux (2026-08-17) et additifs : le contrat précédent
({"nas": []} + 200) reste valable, rien n’a été retiré.
reason |
Sens | Écran attendu |
|---|---|---|
| absent | Le client a une licence active, il n’a simplement pas encore de routeur | Écran d’ajout de routeur |
RADIUS_LICENSE_MISSING |
Aucune licence J+RADIUS | Proposer la souscription |
RADIUS_LICENSE_INACTIVE |
Licence suspendue ou expirée | Proposer la réactivation |
Pourquoi ce changement. Sans motif, « pas encore de routeur » et « pas de licence » produisaient le même écran vide. C’est ainsi que 468 licences actives sur 469 sont restées sans NAS sans que personne ne voie pourquoi.
POST /api/v1/radius/nas — enrôler un routeur
{ "router_serial": "HH40AF7YVNP" }
router_serial suffit. N’envoyez pas nasname : il est dérivé de l’allocation VPN, et
toute valeur divergente est rejetée en 409 RADIUS_NASNAME_DERIVED. C’est délibéré — sans
cela, un client pourrait enrôler le routeur d’un autre en fournissant son adresse.
apply_mode — qui écrit sur le routeur
Ce champ facultatif décide qui applique le script, et c’est le choix le plus structurant de l’appel. Il était absent de ce contrat jusqu’au 2026-08-20 : omission de notre côté.
apply_mode |
Qui injecte | Durée de l’appel | Verrou de déploiement |
|---|---|---|---|
"app_apply" |
Vous, en contact avec le routeur | immédiate | aucun |
| absent (défaut) | Le backend, en SSH/API native | 10 à 13 s | posé, cf. plus bas |
Si votre application applique elle-même le script, envoyez apply_mode: "app_apply".
Le backend enregistre alors le NAS auprès de FreeRADIUS et répond aussitôt, sans toucher au
routeur. Vous récupérez les commandes sur GET /nas/{id}/provisioning, vous les appliquez,
puis vous confirmez avec POST /nas/{id}/verify.
Sans ce champ, le backend considère que c’est à lui d’écrire : il ouvre une session
SSH/API vers le routeur et pousse le script avant de répondre. L’appel dure alors 10 à 13 s,
et un 409 DEPLOYMENT_IN_PROGRESS devient possible.
Ne faites pas les deux. Omettre
apply_modepuis appliquer quand même le script récupéré sur/provisioning, c’est injecter deux fois la même configuration et subir l’attente pour rien.
En app_apply, le backend n’a aucun moyen de rattraper une injection ratée : il n’y a
pas de rollback, le NAS existe et le routeur n’est pas configuré. C’est exactement ce que
POST /nas/{id}/verify sert à détecter — ne présentez jamais le routeur comme actif avant
sa réponse.
| Code | Sens |
|---|---|
201 |
NAS créé |
200 |
NAS déjà existant pour cette allocation (idempotent) — pas une création |
Distinguez bien les deux : un 200 sur un flux « ajouter mon routeur » ne doit pas afficher
« routeur ajouté » si l’utilisateur croyait en enrôler un nouveau.
Préconditions, dans l’ordre où elles sont vérifiées
| Code d’erreur | HTTP | Ce que l’utilisateur doit faire |
|---|---|---|
RADIUS_LICENSE_MISSING / _INACTIVE / _EXPIRED |
403 | Souscrire ou réactiver J+RADIUS |
ROUTER_SERIAL_REQUIRED |
400 | (défaut d’appel — toujours envoyer le numéro de série) |
VPN_ALLOCATION_REQUIRED |
404 | Le routeur n’a pas d’allocation VPN : le provisionner d’abord |
VPN_ALLOCATION_NOT_OWNED |
403 | Ce routeur appartient à un autre compte |
VPN_ALLOCATION_INACTIVE |
409 | Le VPN du routeur n’est pas actif |
VPN_TUNNEL_NOT_CONNECTED |
409 | Allumer le routeur et attendre la connexion du tunnel |
ROUTER_IDENTITY_NOT_LOCKED |
409 | Le routeur n’a pas encore verrouillé son identité matérielle |
ROUTER_SERIAL_MISMATCH |
409 | Le numéro de série ne correspond pas au routeur du tunnel |
VPN_TUNNEL_IP_REQUIRED |
409 | Allocation sans adresse de tunnel — remonter au support |
RADIUS_ALLOCATION_CONFLICT / _DUPLICATE |
409 | Réconciliation administrative requise |
DEPLOYMENT_IN_PROGRESS |
409 | Rien — le déploiement est en cours (voir plus bas) |
VPN_TUNNEL_NOT_CONNECTED est de loin le premier motif de refus. Sur 810 allocations
actives, 95 seulement ont un tunnel connecté — et cette valeur est fiable : le
recoupement avec les handshakes WireGuard réels donne une seule divergence sur 740.
Les routeurs sont réellement éteints ou injoignables.
L’app doit donc afficher ce cas comme un état temporaire du matériel, pas comme une erreur de l’application : « Votre routeur n’est pas connecté. Allumez-le et réessayez. »
409 DEPLOYMENT_IN_PROGRESS — le travail est en cours, pas en échec
L’enrôlement est synchrone et dure 10 à 13 secondes : le backend synchronise les
comptes hotspot, puis pousse le script sur le routeur, et ne répond qu’ensuite. Mesuré en
production le 2026-08-20 : POST à 05:41:37, déploiement réussi à 05:41:47.
Posez un timeout client d’au moins 60 s sur cet appel. Un timeout court produit la séquence suivante, observée en production :
05:41:37 POST /radius/nas → connexion coupée par le client
05:41:40 POST /radius/nas → 409 DEPLOYMENT_IN_PROGRESS (le retry)
05:41:47 Déploiement RADIUS réussi
Le 409 n’est alors pas une tentative antérieure : c’est votre propre requête, trois secondes plus tôt. Nginx laisse 600 s, ce n’est jamais l’infrastructure qui coupe.
Le refus est délibéré et ne sera pas levé : confirmer « créé » pendant qu’un push est en vol permettrait à un échec SSH de supprimer, par rollback, un NAS déjà annoncé comme créé. Le verrou est borné à 180 s — un processus mort n’enferme jamais le client dehors.
Le corps porte de quoi reprendre la séquence sans relancer de push :
{
"success": false,
"code": "DEPLOYMENT_IN_PROGRESS",
"error": {
"code": "DEPLOYMENT_IN_PROGRESS",
"message": "Le script RADIUS est en cours d’application sur le routeur. Patientez avant de réessayer."
},
"idempotent": true,
"nas": {
"id": "…",
"nasname": "10.255.0.15",
"shortname": "…",
"status": "…",
"provisioning": {
"required": true,
"endpoint": "/api/v1/radius/nas/{id}/provisioning",
"verify_endpoint": "/api/v1/radius/nas/{id}/verify"
}
},
"details": {
"nas_id": "…",
"started_at": "…",
"retry_after_seconds": 15,
"deployment": { "status": "DEPLOYING", "started_at": "…" }
}
}
idempotent et nas sont à la racine, à l’identique de la réponse de succès. En-tête
Retry-After: 15 également.
Conduite à tenir : ne pas rejouer POST /nas. Attendre retry_after_seconds, puis
appeler POST /nas/{id}/verify. Le NAS de la réponse est le vôtre, déjà créé — le
déploiement qui l’accompagne aboutira ou sera annulé par le backend, jamais laissé à
moitié.
Le
secretRADIUS n’est pas dans ce corps, et n’y sera pas : un corps d’erreur finit dans les journaux du client.
Après l’enrôlement
La réponse porte les points d’entrée à appeler ensuite :
{
"provisioning": {
"required": true,
"endpoint": "/api/v1/radius/nas/{id}/provisioning",
"verify_endpoint": "/api/v1/radius/nas/{id}/verify"
}
}
required: true signifie que le NAS n’authentifie encore personne. Tant que le
provisionnement n’est pas appliqué puis vérifié, ne présentez pas le routeur comme actif.
DELETE /api/v1/radius/nas/{id} — retirer un NAS
Ce point d’entrée est IDEMPOTENT depuis le 2026-08-20. Rejouer le retrait d’un NAS déjà
retiré répond 200, plus 404 :
{
"success": true,
"result": { "deleted": true, "idempotent": true, "code": "NAS_ALREADY_ABSENT" }
}
Pourquoi ce changement — ce que les journaux ont montré
Quatre séquences identiques mesurées en production le 2026-08-20 sur un même compte :
16:08:36 DELETE /nas/1e00da23 → «-» la requête ABOUTIT, mais l'app a coupé la connexion
16:08:38 DELETE /nas/1e00da23 → 404 le rejeu de l'app, sur un NAS DÉJÀ retiré
16:09:20 POST /nas → 201 l'assistant se relance et recrée un NAS
Le retrait réussissait à chaque fois. L’app abandonnait à ~2 s une requête qui en prenait
~2,5, lisait le 404 de son propre rejeu comme « NAS introuvable », déclarait l’échec, et
réenrôlait — si bien que GET /topology renvoyait de nouveau un primaire. Pour l’utilisateur,
le routeur était impossible à retirer.
C’est la même cause que le 409 d’enrôlement : un délai d’attente côté app plus court que
l’opération. Voir la section apply_mode ci-dessus.
Ce que l’app doit faire
200avecidempotent: trueest un SUCCÈS. Affichez le retrait comme réussi, ne relancez pas l’assistant d’enrôlement.- N’abandonnez pas avant
20 s. Le retrait est plus rapide depuis le 2026-08-20 (la régénération des huntgroups est passée hors du chemin de réponse), mais le bridge RADIUS reste un appel réseau. 404subsiste pour un identifiant qui n’a jamais été un NAS.403signifie que le NAS appartient à un autre compte — celui-là n’est jamais retiré.409 PRIMARY_NAS_REASSIGN_REQUIREDreste possible : on ne retire pas le routeur principal tant qu’un secondaire actif existe.details.candidate_nas_idsdonne les routeurs promouvables. Un primaire seul se retire sans condition.
POST /api/v1/radius/profiles — profils de vente
Renvoie 409 RADIUS_NAS_REQUIRED tant qu’aucun NAS actif n’existe sur le tenant. C’est
attendu, et c’est l’origine des 409 observés en production : ce n’est pas un doublon de
profil, c’est l’absence de routeur enrôlé. Le message à afficher est celui de l’étape
précédente, pas « ce profil existe déjà ».
L’enrôlement est MANUEL — décision produit
C’est l’utilisateur qui enrôle et organise ses NAS depuis l’application. Le backend n’enrôle jamais un routeur à sa place et ne le fera pas : il n’y a pas de provisionnement automatique à attendre, ni de course entre l’app et une tâche de fond.
Concrètement, pour l’app :
- rien n’apparaît sans action de l’utilisateur — si
GET /nasrenvoie une liste vide, c’est l’état réel et définitif tant que personne n’appellePOST /nas; - le
reasonest donc l’unique moyen de dire à l’utilisateur ce qui l’empêche d’avancer. C’est le cœur de ce contrat ; - l’app est le seul écrivain : pas besoin de re-lire la liste en boucle pour détecter un enrôlement venu d’ailleurs.
Un chemin automatique (
RADIUS_AUTO_DEPLOY) existe dans le code, livré le 2026-07-09. Il est désactivé et le reste :RADIUS_AUTO_DEPLOY_ENABLEDn’est posé nulle part. Ne construisez aucun écran en supposant qu’un routeur puisse s’enrôler tout seul.