Contrat API — Provisioning routeur : garantir l’accès de gestion (app mobile)
Le principe en une phrase : l’application est le seul composant autorisé à écrire la configuration d’un routeur client, et son provisioning doit garantir que les ports 8080 et 8081 mènent toujours au service
www— quel que soit le port réel de ce service.
🔄 RÉVISION DU 2026-08-26 — ce document remplace la convention précédente. L’accès de gestion a été refondu et déployé sur 76 routeurs. Trois choses changent, et elles sont contraignantes :
- Le port 80 n’est plus jamais capté. Une redirection sur le 80 intercepte la requête que le portail captif attend : elle a détourné le portail d’un client le 2026-08-24, qui voyait WebFig au lieu de sa page de connexion.
- Plus de
place-before. La règle se pose en bas de chaîne, donc APRÈS le saut hotspot. En position 0 elle passe devant lui, et c’est l’autre moitié du même incident.- Une seule convention de commentaire :
J+ACCES <port>, et une seule liste de portée :JS-ACCESS. Six conventions coexistaient — 174 règles là où 2 suffisent.
Ce guide s’adresse au développeur de l’application mobile. Il décrit ce que le script de provisioning doit poser sur le routeur, et comment l’application doit constater sa réussite.
1. Pourquoi 8081, et pourquoi une règle NAT
Le service www de RouterOS sert à la fois WebFig et l’API REST (/rest). C’est par lui
que passe toute la gestion distante.
Le problème : le port 80 est disputé. Un serveur hotspot actif l’occupe, et sur certains parcs des scripts de maintenance tiers le réinitialisent périodiquement.
D’où la convention :
VPN ──┐
LAN ──┼── :8080 ─┬──► redirection NAT ──► service www (port réel, quel qu'il soit)
└── :8081 ─┘ ◄── porte principale, celle qu'utilise la plateforme
✗ :80 — JAMAIS. C'est le port du portail captif.
Deux règles à ne pas confondre :
| Rôle | |
|---|---|
chain=dstnat … action=redirect |
déplace le trafic vers le port réel de www |
chain=input … action=accept |
autorise le trafic, ne le déplace pas |
Une configuration qui n’a que la seconde laisse le 8081 fermé.
Ce qu’il ne faut PAS faire
Ne pas forcer le port de www. Cette approche a été abandonnée : chez les clients à
double fournisseur VPN, des scripts concurrents remettaient le service sur 80 quelques
instants après le provisioning. On ne se bat plus contre le port par défaut — on l’accepte et
on redirige vers lui.
Ne pas capter le port 80. Une redirection dst-port=80 sur une interface qui porte un
hotspot mange la requête HTTP du client avant que le portail ne la voie : l’utilisateur reçoit
WebFig au lieu de sa page de connexion. Le 80 n’est pas un port de gestion.
Ne pas poser la règle en position 0. place-before=[:pick [/ip firewall nat find] 0] la
place devant le jump hotspot — même effet que ci-dessus, même si le filtrage d’interface est
correct. On ajoute en bas de chaîne, sans exception.
Ne pas limiter la portée au VPN. JS-MGMT ne contenait que les interfaces de tunnel : le
routeur restait injoignable depuis son propre LAN. La portée est JS-ACCESS — voir plus bas.
2. Le script à poser
Idempotent : il peut être rejoué sans effet de bord. Il ne modifie jamais le port de www.
# ── Passerelle de gestion — 8081 est la porte de convention ──
# 1. Port RÉEL de www, quel qu'il soit (80, 2020, 8080, 8081…). On ne le force JAMAIS.
:local wwwPort [/ip service get [:pick [/ip service find name="www"] 0] port]
# 2. Le service doit être joignable : on touche à l'adresse ET à l'état, jamais au port.
# ⚠️ `address=""` est indispensable. Un service `www` restreint à un sous-réseau LAN
# (address=192.168.88.0/24) reste injoignable depuis l'overlay quelles que soient les
# règles NAT — et le symptôme est, là encore, celui d'une passerelle absente.
/ip service set [:pick [/ip service find name="www"] 0] address="" disabled=no
# 3. Purge de TOUTES les générations de passerelles (idempotence).
# La liste doit couvrir chaque variante historique, sinon deux règles coexistent.
# Six écrivains ont été recensés le 2026-08-26 ; ce sont eux, tous.
:do { /ip firewall nat remove [/ip firewall nat find where \
comment~"MoailteAI: Auto-Healing WebBridge" or comment~"MoailteAI: WebBridge" \
or comment~"mgmt-80" or comment~"Moailte-Auto-Redirect-" \
or comment~"sys-gateway-redirect-"] } on-error={}
# 3 bis. La portée : liste JS-ACCESS = nos interfaces VPN + celles qui portent un hotspot
# + le « LAN » DU CLIENT (on ne redéfinit pas ce que LAN veut dire chez lui).
# Exclusions dures : tout ce qui est dans WAN — y exposer WebFig reviendrait à
# l'ouvrir sur Internet — et tout membre dynamique, volatil au redémarrage.
:if ([:len [/interface list find where name="JS-ACCESS"]] = 0) do={
/interface list add name=JS-ACCESS comment="J+SERVICES: acces de gestion (LAN + VPN)"
}
:local wanIf [:toarray ""]
:foreach m in=[/interface list member find where list="WAN"] do={
:set wanIf ($wanIf,[/interface list member get $m interface])
}
:local voulus {"wg-mikhmoai";"sstp-mikhmoai";"jms-sstp"}
:foreach h in=[/ip hotspot find] do={ :set voulus ($voulus,[/ip hotspot get $h interface]) }
:foreach m in=[/interface list member find where list="LAN" and !dynamic] do={
:set voulus ($voulus,[/interface list member get $m interface])
}
:foreach x in=$voulus do={
:if ([:typeof [:find $wanIf $x]] = "nothing" and [:len [/interface find where name=$x]] > 0 \
and [:len [/interface list member find where list="JS-ACCESS" and interface=$x]] = 0) do={
:do { /interface list member add list=JS-ACCESS interface=$x } on-error={}
}
}
# 4. Une passerelle par port de convention — 8080 et 8081, JAMAIS 80.
# Auto-neutralisée si elle viserait son propre port.
# ⚠️ AUCUN `place-before` : la règle doit atterrir APRÈS le saut hotspot.
# ⚠️ Rien ne s'écrit tant que JS-ACCESS ne résout aucune interface : une règle de portée
# vide « réussit » sans rien poser, et le routeur reste injoignable.
:if ([:len [/interface list member find where list="JS-ACCESS"]] > 0) do={
:foreach p in={8081;8080} do={
:local c ("J+ACCES " . $p)
:local r [/ip firewall nat find where comment=$c]
:if ([:len $r] = 0) do={
:if ($p != $wwwPort) do={
/ip firewall nat add chain=dstnat in-interface-list=JS-ACCESS \
protocol=tcp dst-port=$p action=redirect to-ports=$wwwPort comment=$c
}
} else={
# RÉCONCILIATION : le port de www a pu bouger sous la règle. Une redirection présente
# mais périmée est indiscernable d'une règle correcte pour un simple décompte.
/ip firewall nat set [:pick $r 0] to-ports=$wwwPort \
in-interface-list=JS-ACCESS disabled=no
}
}
}
# 5. L'accept d'entrée doit couvrir le port APRÈS réécriture.
:local ports "8080,8081,8291,8728"
:if ([:tonum $wwwPort] != 8080 and [:tonum $wwwPort] != 8081 \
and [:tonum $wwwPort] != 8291 and [:tonum $wwwPort] != 8728) do={
:set ports ($ports . "," . $wwwPort)
}
# ⚠️ ORDRE : on AJOUTE d'abord, on purge ensuite. Deux accept qui se recouvrent sont
# inoffensifs ; un routeur sans accept est définitivement injoignable (voir §4).
:do {
/ip firewall filter add chain=input <interface-selector> action=accept protocol=tcp \
dst-port=$ports comment="MoailteAI: Inbound Ports Access" \
place-before=[:pick [/ip firewall filter find] 0]
} on-error={
/ip firewall filter add chain=input <interface-selector> action=accept protocol=tcp \
dst-port=$ports comment="MoailteAI: Inbound Ports Access"
}
:if ([:len [/ip firewall filter find where comment="MoailteAI: Inbound Ports Access"]] = 0) do={
:error "MoailteAI: accept entree MANQUANT"
}
:do { /ip firewall filter remove [/ip firewall filter find where \
comment="J+SERVICES: mgmt-in" or comment~"Emergency Input Access"] } on-error={}
Sélecteur d’interface selon le transport
<interface-selector> se substitue ainsi :
| Usage | Sélecteur |
|---|---|
| Redirection de gestion (§2.4) et accept | in-interface-list=JS-ACCESS |
| Règles propres au tunnel (raw, mangle) | in-interface=[/interface find name="wg-mikhmoai"] |
Il n’y a plus qu’un seul sélecteur pour l’accès de gestion : JS-ACCESS. C’est une
interface list construite à l’étape 3 bis : interfaces VPN + interfaces portant un hotspot +
LAN du client, moins tout ce qui est dans WAN, moins les membres dynamiques.
Elle remplace JS-MGMT, qui ne contenait que les tunnels : avec elle, le routeur est joignable
depuis son propre LAN et par le VPN, sans jamais être exposé côté WAN.
🪤 La liste LAN n’existe que sur 45 routeurs sur 72. Poser directement une règle avec
in-interface-list=LAN est REFUSÉ par RouterOS sur les autres, silencieusement du point de vue
de l’utilisateur. C’est pourquoi on LIT ses membres pour les recopier dans JS-ACCESS, au lieu
de s’en servir comme portée.
🪤 L’interface du hotspot porte une dizaine de noms différents sur le parc (bridge1,
HOTSPOT, HOSTPOT…). Il faut la lire dans /ip hotspot, jamais la deviner.
🪤 :find sur un tableau rend un INDEX, qui vaut 0 pour le premier élément. Tester son
retour par :len déclarerait « absente de WAN » une interface trouvée en tête de WAN. Seul
:typeof … = "nothing" distingue les deux cas.
⚠️ Un sélecteur qui ne résout rien ne pose aucune règle. [/interface find name="…"] sur
un nom absent renvoie une valeur vide : RouterOS rejette alors la commande, et la variante
on-error échoue de la même façon puisqu’elle emploie le même sélecteur. Le contrôle du §3
transforme ce silence en échec explicite — c’est sa raison d’être.
Vérifier le nom réellement déployé avant de s’y fier :
/interface print where name~"sstp" or name~"wg"
/interface list print
🪤 Le piège de l’étape 5 — à lire même si le reste paraît évident
Le redirect agit en prerouting, donc avant chain=input. Quand le paquet arrive
dans la chaîne d’entrée, son port de destination a déjà été réécrit : ce n’est plus 8081,
c’est le port réel de www.
Un accept qui ne liste que 8080,8081,8291,8728 laisse donc passer la redirection… puis tue
le paquet juste après. Le symptôme est identique à celui d’une règle NAT absente, ce qui
rend le diagnostic très trompeur. C’est pourquoi le script ajoute $wwwPort à la liste.
3. Constater la réussite — obligatoire
À exécuter juste après le script, et à remonter dans le résultat du provisioning :
:local bridge8081Ok false
:local wwwPort [/ip service get [:pick [/ip service find name="www"] 0] port]
:put ("www actif sur : " . $wwwPort)
:put ("JS-ACCESS : " . [:len [/interface list member find where list="JS-ACCESS"]] . " membre(s)")
:foreach p in={8081;8080} do={
:if ($p = $wwwPort) do={
:put ($p . " : natif OK")
:if ($p = 8081) do={ :set bridge8081Ok true }
} else={
:local r [/ip firewall nat find where comment=("J+ACCES " . $p)]
# 🪤 Compter ne suffit PAS : une règle qui vise l'ANCIEN port de www est présente et
# inutile. C'est le `to-ports` qui fait foi.
:if ([:len $r] > 0 and [/ip firewall nat get [:pick $r 0] to-ports] = $wwwPort) do={
:put ($p . " : passerelle OK")
:if ($p = 8081) do={ :set bridge8081Ok true }
} else={
:put ($p . " : MANQUANT")
}
}
}
:if ($bridge8081Ok = false) do={ :error "J+ACCES 8081 MANQUANT" }
Le :error est ce qui rend le contrôle contraignant : sans lui, un script qui n’a rien
posé se termine en succès et l’utilisateur voit un provisioning réussi sur un routeur
inutilisable à distance.
⚠️ Ce bloc doit s’exécuter en DERNIER.
:errorinterrompt tout le script : placé avant la configuration du tunnel ou du pair, il laisserait le routeur à moitié provisionné.
Un provisioning qui ne renvoie pas
8081 : natif OKou8081 : passerelle OKest un provisioning ÉCHOUÉ. Il doit être présenté comme tel à l’utilisateur, et non comme un succès. Un routeur dont le tunnel monte mais dont le 8081 est fermé reste inutilisable à distance, et rien côté serveur ne pourra le réparer (voir §4).
3 bis. Le gardien embarqué — poser la config ne suffit pas
Une configuration posée une fois et jamais revérifiée finit par céder. Audit d’un routeur client le 2026-08-16, trois défaillances simultanées, aucune n’avait déclenché d’alerte :
| Ce qui avait lâché | Effet |
|---|---|
l’adresse /16 sur wg-mikhmoai |
routeur injoignable, tunnel pourtant vivant |
le planificateur Moailte_Backup_48H_Job |
appelle un script inexistant — se déclenche dans le vide, sans erreur |
| l’ordre des règles | notre accept repoussé en 4ᵉ position, derrière un jump inconnu |
Le cas du planificateur mérite d’être compris : le script s’appelle Moailte_Backup_30D_Job,
le planificateur en attend un nommé Moailte_Backup_48H_Job. Une dérive de nommage suffit —
RouterOS ne signale pas un planificateur dont le script est absent. Il se déclenche à
l’heure dite, ne trouve rien, et se tait.
Sur ce même routeur tournait ce planificateur, qui n’est pas de nous :
CLEAN_JOB interval=1d start=00:00:05
/sys sch rem [find where on-event=""] ; /sys scr job rem [find where owner~"sys"]
Il supprime chaque nuit les planificateurs dont le on-event est vide et tue des jobs de
script. D’où trois règles de conception, apprises de ce cas :
- Le
on-eventne doit JAMAIS être vide — c’est exactement le critère de suppression. - Le code vit dans le
on-event, jamais dans un script séparé — nos sauvegardes prouvent qu’un script référencé peut disparaître en laissant le planificateur orphelin et silencieux. - Réaffirmer, pas seulement vérifier — un contrôle qui constate sans corriger n’aurait rien changé ici.
Le bloc à installer
Idempotent, sans effet de bord, exécuté toutes les 5 minutes. <TUNNEL_IP> est l’adresse de
tunnel du routeur, substituée par l’application au provisioning.
:local wgIf "wg-mikhmoai"
:local myIp "<TUNNEL_IP>"
# 1. L'adresse de tunnel — sans elle, le handshake WireGuard réussit mais le routeur est sourd.
:if ([:len [/ip address find where interface=$wgIf and address~$myIp]] = 0) do={
:do { /ip address add address=($myIp . "/16") interface=$wgIf comment="MoailteAI: IP" } on-error={}
}
# 2. La liste de portée de l'accès de gestion (même construction qu'au §2.3 bis).
:if ([:len [/interface list find where name="JS-ACCESS"]] = 0) do={
:do { /interface list add name=JS-ACCESS comment="J+SERVICES: acces de gestion (LAN + VPN)" } on-error={}
}
:local wanIf [:toarray ""]
:foreach m in=[/interface list member find where list="WAN"] do={
:set wanIf ($wanIf,[/interface list member get $m interface])
}
:local voulus {"wg-mikhmoai";"sstp-mikhmoai";"jms-sstp"}
:foreach h in=[/ip hotspot find] do={ :set voulus ($voulus,[/ip hotspot get $h interface]) }
:foreach m in=[/interface list member find where list="LAN" and !dynamic] do={
:set voulus ($voulus,[/interface list member get $m interface])
}
:foreach x in=$voulus do={
:if ([:typeof [:find $wanIf $x]] = "nothing" and [:len [/interface find where name=$x]] > 0 \
and [:len [/interface list member find where list="JS-ACCESS" and interface=$x]] = 0) do={
:do { /interface list member add list=JS-ACCESS interface=$x } on-error={}
}
}
# 3. L'accept de gestion, EN TÊTE de chaîne d'INPUT.
# Être présent ne suffit pas : une règle placée devant peut nous couper sans nous toucher.
# ⚠️ `chain=input` seulement. En tête de chaîne INPUT c'est une protection ; en tête de
# dstnat (§4) ce serait le détournement du portail captif.
:local wwwPort [/ip service get [:pick [/ip service find name="www"] 0] port]
:local acc [/ip firewall filter find where comment="J+ACCES input"]
:if ([:len $acc] = 0) do={
:do { /ip firewall filter add chain=input in-interface-list=JS-ACCESS action=accept \
protocol=tcp dst-port=$wwwPort comment="J+ACCES input" place-before=0 } on-error={}
} else={
:do { /ip firewall filter set [:pick $acc 0] dst-port=$wwwPort \
in-interface-list=JS-ACCESS disabled=no } on-error={}
:if ([:pick [/ip firewall filter find] 0] != [:pick $acc 0]) do={
:do { /ip firewall filter move [:pick $acc 0] destination=0 } on-error={}
}
}
# 4. La passerelle 8081/8080 vers le port RÉEL de www (cf. §1). JAMAIS le 80.
# Aucun `place-before` ici : la règle doit rester APRÈS le saut hotspot.
:if ([:len [/interface list member find where list="JS-ACCESS"]] > 0) do={
:foreach p in={8081;8080} do={
:local c ("J+ACCES " . $p)
:local r [/ip firewall nat find where comment=$c]
:if ([:len $r] = 0) do={
:if ($p != $wwwPort) do={
:do { /ip firewall nat add chain=dstnat in-interface-list=JS-ACCESS protocol=tcp \
dst-port=$p action=redirect to-ports=$wwwPort comment=$c } on-error={}
}
} else={
:do { /ip firewall nat set [:pick $r 0] to-ports=$wwwPort \
in-interface-list=JS-ACCESS disabled=no } on-error={}
}
}
}
🪤 Le gardien réellement installé sur le parc n’est PAS celui de ce document. Vérifié le 2026-08-26 : la version présente sur les routeurs est plus récente que celle du dépôt. Pour savoir ce qui tourne chez un client, lire son
on-eventsur le routeur — jamais le dépôt.
L’installer
:if ([:len [/system scheduler find where name="MoailteAI-Guard"]] = 0) do={
/system scheduler add name="MoailteAI-Guard" interval=5m \
comment="MoailteAI: auto-diagnostic" on-event="<le bloc ci-dessus, échappé>"
}
Chaque add est enveloppé dans :do {} on-error={} : un routeur où une des quatre pièces
existe déjà ne doit pas faire échouer les trois autres.
La limite, dite franchement
Un planificateur ne peut pas se réinstaller lui-même s’il est supprimé. Le gardien protège la configuration ; il ne se protège pas lui. Sa présence est donc vérifiée à chaque provisioning par l’application, et son absence doit remonter côté serveur — c’est le seul moyen de savoir qu’un routeur a perdu son filet.
3 ter. Transmettre le rapport du gardien au backend
Un planificateur ne peut pas signaler sa propre disparition. Si le gardien est supprimé, plus rien sur le routeur ne le dira. Le rapport que l’application transmet après chaque provisioning ou repair est donc le seul moyen pour nous de l’apprendre.
Il passe par le canal d’événements existant :
POST /api/v1/clients/remote-access/provisioning-events
{
"event_type": "GUARD_REPORT",
"state": "SUCCEEDED",
"allocation_id": "…",
"router_serial": "…",
"details": {
"guard_present": true,
"guard_enabled": true,
"guard_interval": "5m", // ou "guard_interval_ok": true
"guard_on_event_present": true,
"webbridge_8081_ok": true,
"mgmt_accept_ok": true,
"mgmt_accept_first": true,
"js_mgmt_ok": true
}
}
Réponse 202 — l’événement est pris en compte, le traitement continue en arrière-plan
(cf. CONTRAT-API-REPONSES-APP-MOBILE).
Trois règles de lecture côté serveur
Un signal absent vaut false, jamais true. Une clé non transmise signifie « non
vérifié ». L’optimisme par défaut est précisément ce qui a laissé le parc dériver sans alerte.
Présent n’est pas opérant. Le serveur calcule guard_effectif = présent et activé
et à la bonne cadence et avec un on-event non vide. Un gardien désactivé compté
comme actif donnerait un parc rassurant et faux.
L’absence du gardien ne déclenche aucune réparation serveur. Elle produit la consigne convenue : « Repasser dans l’app mobile pour réinstaller le gardien routeur ».
Quota
Cette route est bornée à 60 événements par minute et par compte (TELEMETRY_RATE_LIMIT).
La limite est large pour ne pas perdre de télémétrie légitime — un provisioning en émet
plusieurs d’affilée — mais elle arrête une boucle. Regroupez les signaux en un seul
GUARD_REPORT plutôt qu’un événement par vérification.
4. Ce que le serveur fait — et ne fera pas
-
Le backend n’écrit jamais sur la configuration sensible d’un routeur : pare-feu, adresses, routes,
/ip service, interfaces. C’est une décision d’architecture, pas une limitation temporaire.La raison : le backend est distant et aveugle. S’il casse la joignabilité d’un routeur, il perd du même coup le seul chemin pour le réparer. L’application, elle, est sur le LAN avec un humain présent capable de rattraper une erreur.
-
Conséquence directe : une règle manquante après provisioning ne sera pas rattrapée côté serveur. Elle nécessite un nouveau passage de l’application. D’où le caractère bloquant de la vérification du §3.
-
Le serveur publie les ports et route le trafic vers le routeur. L’application n’a pas à gérer la correspondance entre port public et port local — voir
CONTRAT-API-ACCES-DISTANT-PORTS.
5. Ce qui n’est PAS une erreur
wwwsur un port autre que 8081. C’est le cas nominal depuis l’abandon du forçage. Tant que la passerelle 8081 existe, la configuration est conforme. Ne pas « corriger » en déplaçant le service.- Une passerelle absente pour un port égal à
$wwwPort. Le script la saute volontairement : rediriger un port vers lui-même est un no-op, et RouterOS refuserait la règle. Le bloc de vérification répond alorsnatif OK. - Un routeur injoignable quelques minutes après un provisioning réussi. Les équipements derrière le NAT d’un opérateur ne sont atteignables depuis le serveur que tant qu’ils émettent. Ce n’est pas un échec de configuration.
6. Quota — à lire avant d’implémenter une boucle de vérification
100 requêtes / 15 min par IP, partagées par TOUTES les routes /api.
Une vue qui sondait une route toutes les 15 secondes a consommé à elle seule la totalité du
quota, et ce sont les autres appels — licence, VPN — qui ont échoué en 429. La route
fautive, elle, semblait fonctionner.
Pour le provisioning :
- vérifiez une fois, à la fin du script, pas en boucle ;
- une nouvelle vérification se déclenche sur action explicite de l’utilisateur ;
- une seule sonde à la fois — annulez la précédente avant d’en lancer une nouvelle.
7. Voir aussi
- Ports publics et matrice de redirection :
https://live.jmoai.net/docs/CONTRAT-API-ACCES-DISTANT-PORTS - Choix du transport VPN :
https://live.jmoai.net/docs/CONTRAT-API-TRANSPORT-MOBILE-2026-07-27 - Référence complète des routes :
https://live.jmoai.net/api-docs - Source Markdown de ce guide :
https://live.jmoai.net/docs/CONTRAT-API-PROVISIONING-ROUTEUR-MOBILE/raw