Documentation J+SERVICES Guides Référence API

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_mode puis 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 secret RADIUS 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


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 :

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_ENABLED n’est posé nulle part. Ne construisez aucun écran en supposant qu’un routeur puisse s’enrôler tout seul.