Documentation J+SERVICES Guides Référence API

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 :

  1. 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.
  2. 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.
  3. 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. :error interrompt 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 OK ou 8081 : passerelle OK est 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 :

  1. Le on-event ne doit JAMAIS être vide — c’est exactement le critère de suppression.
  2. 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.
  3. 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-event sur 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


5. Ce qui n’est PAS une erreur


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 :


7. Voir aussi