Contrat API — Équipements du client, accès à distance
Pour l’équipe application mobile. Comment permettre à un client d’ajouter ses points d’accès, antennes, CPE, caméras et switches, puis d’ouvrir leur interface web depuis n’importe où — à travers le tunnel, sans ouvrir un seul port public.
Base : https://live.jmoai.net/api/v1/clients/topology
1. Ce qui existe déjà, et qu’il ne faut pas refaire
Tout le chemin réseau est construit, déployé et prouvé de bout en bout :
| brique | état |
|---|---|
| balayage du LAN, tri du bruit | livré |
| réservation d’un port privé par équipement | livré |
règle dst-nat posée sur le routeur |
livré (posée par le backend) |
| relais 443 authentifié, isolation par locataire | livré |
| vérification et plan de réparation | livré |
| épinglage du bail DHCP en statique | livré |
| sonde de surveillance posée sur le routeur | livré (posée par le backend) |
| notification push à la chute et au retour | livré |
Ce qui manquait n’était pas du réseau : c’était l’entrée. Au 23/08, un seul équipement était déclaré sur tout le parc en un mois. Depuis, le backend pose la règle lui-même — il ne reste plus qu’un écran à brancher.
2. Le problème que l’utilisateur doit résoudre — et l’outil qui le résout
Un routeur hotspot voit des centaines d’adresses. Sur un routeur réel du parc, le balayage brut remonte 493 adresses. Le client, lui, ne veut ajouter qu’un point d’accès.
Ne pas lui montrer la liste brute. Le tri est déjà fait côté backend :
GET /api/v1/clients/topology/scan?router_serial=HH40AF7YVNP
{
"discovered": [
{
"lan_ip": "10.100.4.1",
"interface": "ether1-TP-LinkGiga",
"mac": "3C:6A:D2:7C:11:18",
"hostname": "TendaAP",
"already_added": false
}
],
"hotspot_clients_hidden": 492
}
493 → 1. Sur ce routeur, l’équipement à ajouter est le seul de la liste.
Comment le tri fonctionne, pour que l’écran sache quoi dire
Le backend croise quatre lectures du routeur — table ARP, baux DHCP, adresses du routeur, serveurs hotspot — puis écarte :
- tout ce qui est branché sur une interface portant un serveur hotspot. C’est le critère
décisif : les clients d’un hotspot sont des passants, jamais des équipements. Ils sont
comptés dans
hotspot_clients_hidden, pas listés ; - les interfaces
container/docker; - les adresses du routeur lui-même ;
- tout ce qui n’est pas une adresse privée valide.
Ce qui reste est enrichi du nom d’hôte DHCP (hostname) et de la MAC. C’est ce nom
qui permet à l’utilisateur de reconnaître son matériel sans connaître les adresses IP —
affichez-le en premier, l’adresse en second.
La marque, l’application n’a rien à envoyer
Chaque ligne du balayage porte déjà :
{
"lan_ip": "10.100.4.1",
"mac": "3C:6A:D2:7C:11:18",
"vendor": "TP-Link",
"vendor_logo": "tp-link",
"vendor_reason": null
}
vendor est déduit de la MAC — ses trois premiers octets sont attribués au constructeur
par l’IEEE. Demander la marque à l’utilisateur reviendrait à lui demander de savoir : il se
tromperait, ou laisserait vide. vendor_logo est l’identifiant stable auquel accrocher le
SVG (tp-link, ubiquiti, cudy, mikrotik, hikvision…) — séparé du nom affiché, pour
que renommer l’un ne casse pas l’autre.
Le même champ est présent sur chaque équipement de la topologie (§5).
⚠️
vendor: nullarrive souvent, et ce n’est pas un défaut
vendor_reasonce que ça veut dire MAC_ALEATOIREMAC tirée au hasard par le téléphone — aucune marque n’existe CONSTRUCTEUR_INCONNUpréfixe hors de notre table MAC_ABSENTEpas de MAC relevée Les téléphones modernes changent de MAC par réseau pour ne pas être suivis. Sur le parc mesuré, plusieurs préfixes sont dans ce cas. Leur chercher une marque produirait une correspondance fausse, puisque le préfixe n’appartient à personne.
Afficher une icône générique. Jamais un logo approchant : un mauvais logo fait douter de tout le reste de l’écran.
already_added: true signale un équipement déjà enregistré : le griser plutôt que de laisser
l’utilisateur créer un doublon.
?all=true rend la liste brute. À réserver au diagnostic — ce n’est pas un écran client.
3. Ajouter un équipement
POST /api/v1/clients/topology/devices
Authorization: Bearer <jeton du client>
{
"router_serial": "HH40AF7YVNP",
"name": "AP Salle 2",
"device_type": "AP",
"lan_ip": "10.100.4.1",
"service_protocol": "http",
"service_port": 80,
"mac_address": "3C:6A:D2:7C:11:18"
}
router_serial suffit — l’application n’a pas besoin de connaître l’allocation_id. Le
backend résout le numéro de série vers son propriétaire actuel et refuse si le registre et
l’allocation divergent. Un numéro inconnu et le numéro d’un tiers rendent la même erreur
404 ROUTER_NOT_OWNED : la réponse ne dit jamais si le matériel existe ailleurs.
Envoyez la mac_address du balayage. Elle sert à retrouver l’équipement le jour où son
adresse change, et c’est elle qui rend la réparation automatique possible.
La réponse, dans le cas normal
{
"id": "b6347768-…",
"status": "ACTIVE",
"access_endpoint": {
"dstnat_port": 34001,
"nat_managed_by": "backend",
"proxy_path": "/api/v1/clients/topology/access/b6347768-…"
},
"nat_rule": {
"_": "rendu quand même : c'est le plan de secours si le routeur était injoignable",
"mikrotik_commands": [
"/ip dhcp-server lease make-static [find where address=\"10.100.4.1\"]",
"/ip firewall nat remove [find comment=TOPO_b6347768]",
"/ip firewall nat add chain=dstnat dst-address=10.255.0.15 protocol=tcp dst-port=34001 action=dst-nat to-addresses=10.100.4.1 to-ports=80 comment=TOPO_b6347768"
],
"remove_command": "/ip firewall nat remove [find comment=TOPO_b6347768]"
}
}
Depuis le 23/08, le backend met TOUT en service lui-même quand il joint le routeur :
- la règle d’accès à distance sur le routeur ;
- la sonde de surveillance sur le port de service de l’équipement ;
- un push au client —
✅ <nom> ajouté · Accès à distance et surveillance en service.
L’utilisateur nomme son équipement, et il apparaît. L’application n’a rien à exécuter, rien à enchaîner, et aucun appel supplémentaire pour activer la surveillance — c’était jusqu’ici un appel séparé que personne n’effectuait.
La réponse le dit sans ambiguïté :
status |
nat_managed_by |
ce qui s’est passé | ce que l’application fait |
|---|---|---|---|
ACTIVE |
backend |
la règle est sur le routeur | rien, afficher « Ouvrir » |
PENDING |
app |
routeur injoignable à cet instant | exécuter nat_rule.mikrotik_commands si elle est sur le LAN, sinon laisser la vérification reprendre plus tard |
Un routeur hors ligne ne fait pas échouer l’ajout : l’équipement est enregistré, la place est réservée, et la saisie de l’utilisateur n’est pas perdue pour une raison qui n’a rien à voir avec lui.
pushNat: false dans le corps force l’ancien comportement — au cas où l’application préfère
poser depuis le LAN. Ce n’est plus nécessaire pour que la fonctionnalité marche.
⚠️ La première commande n’est pas optionnelle
make-staticépingle le bail DHCP de l’équipement. Sans elle, un point d’accès en adresse dynamique change d’IP au premier redémarrage, la règledst-natpointe dans le vide, et l’accès casse sans aucun message. La commande est idempotente et sans effet sur un équipement déjà en adresse fixe.
4. Confirmer que l’accès existe vraiment
POST /api/v1/clients/topology/devices/:id/verify
À appeler seulement si l’application a posé la règle elle-même (status: "PENDING").
C’est ce qui fait alors passer l’équipement en ACTIVE. Utile aussi comme diagnostic à tout
moment : c’est le seul appel qui va vraiment LIRE le routeur.
{
"ok": true,
"reason": null,
"checks": {
"router_reachable": true,
"nat_rule_present": true,
"nat_rule_enabled": true,
"nat_rule_matches": true,
"lease_static": true,
"mac_matches_ip": true
},
"repair": { "repairable": false }
}
⚠️
ACTIVEveut dire « le routeur porte la règle », pas « c’est enregistré »Un équipement créé est
PENDING: la place est réservée, le routeur n’en sait rien encore. Seule la vérification, qui va lire la règle sur le routeur, promeut enACTIVE.Constaté le 23/08 avant correction : deux équipements
ACTIVEen base, zéro règle sur le routeur. L’écran aurait montré deux accès prêts qui échouaient au clic.N’affichez un bouton « Ouvrir » que sur un équipement
ACTIVE.
Réparer en un appel
POST /api/v1/clients/topology/devices/:deviceId/repair
Un seul appel : le backend diagnostique, pose ce qui manque sur le routeur, puis revérifie. « J’ai lancé la réparation » n’est pas « c’est réparé ».
{
"repaired": true,
"reason_before": "NAT_RULE_MISSING",
"reason_after": null,
"status": "ACTIVE",
"verify": { "ok": true, "…": "…" }
}
Trois réponses possibles, et l’écran doit les distinguer :
| réponse | sens | ce que l’écran affiche |
|---|---|---|
already_healthy: true |
rien à réparer | « tout va bien » — ne pas proposer le bouton |
repaired: true |
c’est réparé, et revérifié | « accès rétabli » |
needs_human: true |
la correction demande une décision | le champ note, tel quel |
⚠️
needs_humann’est pas un échecLe cas type est
DEVICE_IP_CHANGED: l’équipement ne vit plus à l’adresse enregistrée. Le backend pourrait poser une règle vers la nouvelle adresse — mais rien ne prouve que la machine trouvée là est celle que l’utilisateur avait choisie. Réparer, ici, ce serait ouvrir un accès vers un appareil que personne n’a désigné.La
noteest écrite pour être affichée sans reformulation. Exemple réel :L’équipement répond maintenant sur 10.100.90.125. Faites confirmer la nouvelle adresse à l’utilisateur, puis ré-enregistrez-le : rejouer les commandes d’origine recréerait la redirection vers l’ancienne adresse.
L’appel rend 200 dans les trois cas. Ce n’est pas une erreur d’appel, c’est un diagnostic — et l’écran a de quoi expliquer plutôt que d’afficher « échec ».
Les motifs, et ce que l’écran doit en faire
reason |
sens | action proposée |
|---|---|---|
NAT_RULE_MISSING |
la commande n’a pas été exécutée | appeler /repair |
NAT_RULE_DISABLED |
règle présente mais désactivée | appeler /repair |
NAT_RULE_MISMATCH |
la règle vise une autre adresse ou un autre port | appeler /repair |
DEVICE_IP_CHANGED |
l’équipement vit à une autre adresse | afficher note, puis ré-enregistrer |
DEVICE_NOT_SEEN |
plus vu dans la table ARP | équipement éteint ou débranché |
ROUTER_UNREACHABLE |
tunnel tombé | ne rien conclure sur l’équipement |
ROUTER_CHANGED_OWNER · ROUTER_REALLOCATED |
le routeur a changé de main | revalidation nécessaire |
repair.repairable: false n’est pas un échec : c’est le cas où la correction demande une
décision humaine. Un changement d’adresse en fait partie — le backend ne réécrit pas tout seul
une donnée que l’utilisateur a saisie.
⚠️ Un routeur injoignable ne rétrograde rien. Une coupure de trois minutes ferait sinon
basculer toute une installation en erreur. « Je n’ai pas pu regarder » n’est pas « c’est
cassé ». N’affichez pas d’alarme sur ROUTER_UNREACHABLE.
5. Où l’utilisateur les retrouve
Depuis le 23/08, les équipements apparaissent dans la topologie RADIUS, sous leur routeur. Un seul appel suffit à peindre l’écran complet :
GET /api/v1/radius/topology
{
"primary": {
"nasname": "10.255.0.15",
"shortname": "J+Pro Ax3",
"model": "hAP ax^3",
"status": "ACTIVE",
"tunnel_status": "CONNECTED",
"web_access": {
"webfig": "http://vpn.mikhmoai.com:17012",
"winbox": "vpn.mikhmoai.com:19012",
"api": "vpn.mikhmoai.com:22012",
"ssh": "vpn.mikhmoai.com:23012",
"reachable": true
},
"devices": [
{
"id": "c58f77aa-…",
"name": "Ubiquiti Lite AP 120",
"type": "AP",
"lan_ip": "10.100.88.16",
"mac_address": "24:5A:4C:B2:AB:53",
"vendor": "Ubiquiti",
"vendor_logo": "ubiquiti",
"protocol": "https",
"port": 443,
"status": "ACTIVE",
"pending_reason": null,
"online": true,
"online_changed_at": "2026-08-23T21:38:42Z",
"monitored": true,
"access_path": "/api/v1/clients/topology/access/c58f77aa-…",
"access_session_path": "/api/v1/clients/topology/devices/c58f77aa-…/access-session"
}
]
},
"members": [],
"warnings": [],
"capabilities": {
"can_add_devices": true,
"reason": null,
"enforced": false,
"max_devices": 10,
"devices_used": 2,
"devices_remaining": 8
}
}
capabilities — l’accès aux équipements est un abonnement à part
L’accès et la surveillance des équipements du LAN forment un abonnement mensuel vendu
séparément (suivi_pro). Il ne vient ni avec la licence RADIUS, ni avec le VPN.
| champ | à quoi il sert |
|---|---|
can_add_devices |
le client a-t-il l’abonnement ? null = droits illisibles |
reason |
SUIVI_PRO_REQUIRED quand il ne l’a pas |
enforced |
la restriction est-elle RÉELLEMENT appliquée aujourd’hui ? |
max_devices |
plafond du palier souscrit |
devices_used |
équipements déjà déclarés |
devices_remaining |
ce qu’il reste avant le refus |
Lire les deux champs, jamais un seul. Tant que enforced vaut false, l’appel d’ajout
passe quand même — la restriction est en observation. Griser le bouton sur le seul
can_add_devices cacherait une fonction qui marche.
enforced: false → bouton actif
enforced: true + can_add_devices: false → bouton grisé + proposer l'abonnement
can_add_devices: null → bouton actif (on ne prive pas sur un doute)
Un refus au clic ne vend rien. Un bouton grisé qui explique pourquoi devient une proposition.
Les deux paliers
| palier | prix | équipements |
|---|---|---|
| Essentiel | 3 100 XOF / mois | 10 |
| Étendu | 10 000 XOF / mois | 30 |
Afficher le compteur en permanence, pas seulement le blocage. « 7 sur 10 » prévient ;
« limite atteinte » ne fait que constater. C’est devices_used / max_devices.
Au-delà du plafond, l’ajout rend :
402 Payment Required
{ "code": "DEVICE_QUOTA_REACHED",
"error": "Votre abonnement couvre 10 équipements et vous en avez 10. Passez au palier supérieur pour en ajouter davantage.",
"details": { "max_devices": 10, "devices_used": 10 } }
402 et non 403 : ce n’est pas un droit manquant, c’est un palier atteint — deux
situations qui n’appellent pas le même écran. Le message porte les deux nombres et la sortie ;
l’afficher tel quel suffit.
web_access concerne le routeur lui-même : ces quatre services existent nativement sur
tout MikroTik, les ports sont déjà alloués et servis. reachable suit l’état du tunnel —
grisez le bouton quand il est faux plutôt que d’ouvrir une page morte.
⚠️ Le schéma de l’URL WebFig n’est pas toujours
httpsLe port public est un flux TCP brut, sans terminaison TLS côté serveur. Le schéma est déjà calculé par le backend à partir du port réel du routeur (80 →
http, 8081 →https). Ouvrez l’URL telle qu’elle est renvoyée. La forcer enhttpsproduit une page noire — le bug a déjà été vécu côté Desktop.
devices[] concerne ce qu’il y a derrière le routeur.
Deux états à ne surtout pas confondre
| champ | ce qu’il dit |
|---|---|
status |
l’ACCÈS est-il en place ? ACTIVE = la règle est sur le routeur |
online |
l’ÉQUIPEMENT répond-il ? mesuré par la sonde, sur son port |
Un équipement peut être ACTIVE et online: false : l’accès est prêt, mais l’appareil est
éteint. C’est le cas le plus fréquent, et l’écran doit le dire ainsi — « accès configuré,
appareil hors ligne », jamais « erreur ».
⚠️
online: nullveut dire « pas surveillé », pas « hors ligne »Personne n’a encore regardé. À afficher en gris, jamais en rouge. Un point rouge sur un équipement qui fonctionne parfaitement fait perdre confiance dans tous les autres points rouges.
online_changed_at porte l’instant du dernier changement : c’est ce qui permet d’afficher
« hors ligne depuis 2 h 15 » sur la fiche, sans attendre une notification.
6. Ouvrir l’interface web d’un équipement
GET /api/v1/clients/topology/access/:deviceId
Authorization: Bearer <jeton du client>
C’est un relais, pas une redirection. La requête traverse : backend → tunnel du routeur →
règle dst-nat → équipement. Le locataire est revérifié à chaque appel.
Le bouton « Ouvrir » — deux appels, pas un
access_path exige l’en-tête d’authentification, qu’un navigateur ou une WebView ne portera
pas. Pour ouvrir l’équipement, demandez d’abord une URL signée :
POST /api/v1/clients/topology/devices/:deviceId/access-session
{
"access_url": "https://dev-<id>.topo.jmoai.net/?st=<signature>",
"access_path": "/api/v1/clients/topology/access/<id>?st=<signature>",
"ttl_seconds": 300
}
Ouvrez access_url : c’est le sous-domaine, qui sert l’interface complète de l’équipement
(les images, le style et les scripts se chargent, ce qui n’est pas le cas d’un accès par
chemin). La topologie donne ce chemin dans access_session_path — pas besoin de le composer.
Demandez le lien au CLIC, jamais à l’affichage de la liste. Il vaut 5 minutes. En émettre un par équipement à chaque lecture de la topologie sèmerait des URL signées dans les journaux et les caches pour un seul clic réel.
Pourquoi pas
vpn.mikhmoai.com:PORT, comme pour le routeur ?Les quatre services du routeur (WebFig, Winbox, API, SSH) passent par un flux TCP nginx sur un port public. C’est délibérément l’inverse pour les équipements du client, et pour trois raisons :
- un port public, c’est Internet entier. La caméra ou le point d’accès d’un client serait joignable par n’importe quel scanner, avec pour seule défense son mot de passe d’usine ;
- un flux TCP brut ne sait pas qui parle. Aucune vérification de locataire n’est possible dessus. Le relais 443, lui, revérifie le propriétaire à chaque requête ;
- ça ne tient pas à l’échelle. Quatre ports par routeur, c’est gérable. Autant de ports que d’équipements sur tout le parc ne l’est pas.
Le prix à payer est l’URL signée à durée de vie courte. C’est ce qui permet de garder zéro port public pour le matériel des clients.
N’exposez jamais tunnel_ip:dstnat_port dans l’application. C’est une adresse interne ;
la publier contournerait le contrôle d’accès, et quiconque l’aurait vue atteindrait
l’équipement.
Aucun port public n’est ouvert pour cette fonctionnalité. C’est le point de conception : un équipement domestique — un AP, une caméra — n’est jamais joignable depuis Internet.
7. Les notifications que l’application recevra
Le client n’a rien à configurer — ni Telegram, ni webhook, ni seuil. La surveillance part avec l’ajout, et les notifications arrivent en push et dans la liste in-app.
data.type |
quand | exemple |
|---|---|---|
EQUIPMENT_ADDED |
l’équipement vient d’être mis en service | ✅ Ubiquiti Lite AP 120 ajouté |
EQUIPMENT_MONITOR |
l’équipement tombe | 🔴 Ubiquiti Lite AP 120 hors ligne |
EQUIPMENT_MONITOR |
il est toujours tombé (15 min, 2 h) | 🔴 … toujours hors ligne · est OFF depuis 17 min |
EQUIPMENT_MONITOR |
il revient | ✅ … de nouveau en ligne après 12 min d'interruption |
Chaque message porte device_id : ouvrez la fiche de l’équipement au tap, pas la liste.
Ce que l’application ne doit PAS reconstruire
La durée. Elle est déjà dans le texte, et elle est mesurée, pas estimée. Au retour, le backend connaît l’instant exact de la chute. À la chute, il ne sait pas depuis combien de temps l’appareil est muet — la sonde vient de le constater — et il ne l’invente donc pas. Recalculer une durée côté application produirait un chiffre différent de celui du push, pour le même incident.
La relance. Deux paliers seulement, 15 minutes puis 2 heures, puis plus rien. Ce n’est pas un oubli : un rappel toutes les dix minutes sur un équipement débranché pour l’hiver ferait couper toutes les notifications par le client — y compris celles qui comptent. Ne pas y ajouter de rappel local.
La déduplication. Elle est faite côté serveur, par transition. Deux pushs identiques à une seconde d’intervalle ne remonteront pas.
8. Les autres appels
| appel | usage |
|---|---|
GET /topology/devices |
la liste du client, avec vpn_health et hardware{anchored, needs_revalidation, reason} |
POST /topology/sync |
déclarer plusieurs équipements en une fois, après un balayage |
DELETE /topology/devices/:id |
retire l’équipement et rend la remove_command à exécuter |
POST /topology/devices/:id/monitor |
surveillance netwatch sur le routeur |
hardware.needs_revalidation signale qu’un routeur a changé de propriétaire ou d’allocation
depuis l’enregistrement. Un routeur se vend ; les points d’accès, eux, restent sur le site.
Un équipement dans cet état ne doit plus offrir d’accès tant que l’utilisateur n’a pas
reconfirmé.
9. Le parcours complet, dans l’ordre
GET /topology/scan— l’utilisateur voit une liste courte, pas 493 lignes.- Il choisit un équipement et le nomme (« Ubiquiti Lite AP 120 »).
POST /topology/devices— le backend réserve le port, pose la règle d’accès, pose la sonde, et envoie le push✅ … ajouté.- Il apparaît sous son routeur dans la topologie, avec
statusETonline. - Un bouton Ouvrir demande l’URL signée puis l’ouvre.
- À la moindre chute, le client reçoit un push. Sans avoir rien configuré.
Pour l’utilisateur, c’est un seul geste : il nomme son équipement, il apparaît.
Les deux étapes de secours, quand le routeur n’était pas joignable à cet instant
(status: "PENDING") : exécuter nat_rule.mikrotik_commands, puis appeler
POST /topology/devices/:id/verify.
Mesuré de bout en bout le 23/08
balayage 493 adresses → 492 masquées → 1 équipement
ajout règle posée par le backend, status ACTIVE
règle sur routeur TOPO_99824df5 chain=dstnat dst-address=10.255.0.15
dst-port=34001 to-addresses=10.100.4.1 to-ports=80
accès HTTP 200 en 0,65 s
10. Les pièges déjà payés
N’affichez jamais la liste brute du balayage. 493 lignes dont 492 passants : l’utilisateur ne trouvera pas son point d’accès, et il conclura que la fonctionnalité ne marche pas.
N’affichez pas « Ouvrir » sur un équipement PENDING. Le clic échouera, et rien à
l’écran n’expliquera pourquoi. Affichez le motif et le bouton « Réparer ».
Ne peignez pas online: null en rouge. C’est « pas surveillé », pas « hors ligne ». Un
point rouge sur un équipement qui marche fait perdre confiance dans tous les autres.
Ne confondez pas status et online. Un équipement ACTIVE avec online: false n’est
pas en erreur : son accès est prêt et l’appareil est éteint. Le dire comme une erreur amène
l’utilisateur à « réparer » ce qui n’est pas cassé.
N’oubliez pas verify quand VOUS avez posé la règle. Dans le cas PENDING, sans cet
appel l’équipement reste en attente pour toujours, même quand l’accès fonctionne
parfaitement. Quand le backend a posé la règle (nat_managed_by: "backend"), l’équipement
est déjà ACTIVE — l’appel est inutile.