Documentation J+SERVICES Guides Référence API

Contrat API — choix du transport VPN (app mobile / front client)

Date : 2026-07-27 Pour : l’app mobile et le front client Endpoints concernés : tous les endpoints qui délivrent un bundle prêt, notamment POST /api/v1/clients/remote-access/resolve, la liste et la validation routeur. Statut : le champ protocol existait déjà et arrivait bien jusqu’au backend — il n’était simplement jamais suivi d’effet. Il l’est depuis ce jour.

Révision 2026-08-12 — livraison conjointe avec le backend : un changement de transport est désormais une préparation de candidat. L’ancien chemin ne peut être retiré qu’après la sonde candidate-specific ci-dessous. Cette révision ne crée pas de tunnel OOB : elle sécurise WireGuard/SSTP sur la passerelle existante.

Révision 2026-09-17 — slot explicite et liaison dynamique : quand allocation_id est fourni et appartient au client, il est la seule source de vérité pour le bundle (IP, peer, clés et ports). Le router_serial sert uniquement à mettre à jour la liaison temporaire du routeur ; il ne peut pas rediriger la réponse vers un autre slot du registre. Les profils et adresses restent attachés à leur allocation et ne sont jamais désactivés lors d’un déplacement. Si plusieurs slots du même client portent encore le même serial et qu’aucun allocation_id n’est fourni, le serveur répond 409 REMOTE_ACCESS_ALLOCATION_SELECTION_REQUIRED avec la liste des IDs à choisir. Un slot occupé par un autre routeur vivant reste protégé par 409 ROUTER_SLOT_OCCUPIED.

Révision 2026-09-18 — lecture de bundle par série routeur : GET /api/v1/clients/remote-access accepte aussi router_serial (routerSerial ou sn) quand allocation_id est absent. Cette lecture retourne alors l’allocation canonique du registre routeur, à condition qu’elle appartienne au client. But : éviter qu’une page web ou app SSH affiche un ancien slot encore présent dans l’historique du client. Cette règle ne remplace pas le provisioning explicite : si l’écran connaît l’allocation_id, il doit toujours l’envoyer.

Révision 2026-09-25 — numérotation métier des allocations VIP : chaque bundle portant une allocation expose allocation_order. Le port VPN, l’IP tunnel et l’UUID ne sont pas des numéros métier. L’ordre est celui de la licence/achat quand elle est connue, puis celui de l’arrivée de l’allocation en repli. L’app doit afficher allocation_order.label (VPN 1, VPN 2, …), signaler allocation_order.is_latest pour le dernier achat/dernier arrivé, et ouvrir le détail avec l’allocation_id de la ligne choisie.


1. Le modèle en une phrase

Un transport actif, un candidat éventuellement préparé. WireGuard est le chemin principal ; SSTP est un repli pour le matériel qui n’a pas WireGuard (RouterOS < 7) ou pour un réseau qui ne laisse pas passer l’UDP. Pendant une bascule, les deux peuvent coexister brièvement afin de ne jamais couper le routeur avant preuve du nouveau chemin.

Le backend n’invente rien : il applique ce que l’app déclare, et il le dit dans sa réponse.

2. Ce que l’app envoie

POST /api/v1/clients/remote-access/resolve
{
  "allocation_id":    "14730c71-5b69-4d9f-ab1b-bbd58707c587",  // optionnel
  "routeros_version": "6.49.13 (long-term)",                   // ⬅️ requis en pratique
  "protocol":         "SSTP",                                  // ⬅️ optionnel, voir §3
  "router_serial":    "82536525862",
  "router_identity":  "MV7-CHR-v6",
  "router_mac":       "52:54:00:12:34:56",
  "router_model":     "CHR",
  "api_mode":         "native"
}

routeros_version sert à décider seule quand protocol est absent. La version majeure suffit : 6.x → pas de WireGuard, 7.x → WireGuard disponible.

3. Quand envoyer protocol, et quand ne pas l’envoyer

Situation protocol force_transport Ce que fait le backend
Provisionnement normal ne pas l’envoyer absent v7 → WireGuard, v6 → SSTP
Le client est déjà installé et ça marche ne pas l’envoyer absent rien ne change — le transport en place est conservé
WG jamais monté, repli SSTP demandé "SSTP" absent crée un candidat SSTP, sans retrait immédiat
WG encore vivant, bascule SSTP explicitement confirmée "SSTP" true autorise la préparation du candidat, jamais le retrait immédiat

Ne jamais envoyer protocol ni force_transport « par sécurité ». Sans protocol, le backend sélectionne le chemin normal. Avec protocol: "SSTP", il prépare un candidat SSTP. Si WireGuard est encore vivant, force_transport: true est obligatoire : c’est un accusé explicite de l’utilisateur ou du flux de reprise, pas un booléen à envoyer automatiquement. Dans tous les cas, le transport précédent reste actif jusqu’à une confirmation candidate.

force_transport n’est donc pas un ancien champ à ignorer. Il protège contre une coupure causée par un écran ou un retry qui demanderait SSTP par erreur. Le client le transmet uniquement après une confirmation visible, avec un operation_id stable sur les retries.

4. Ce que le backend répond

Deux champs s’ajoutent au bundle existant :

{
  "allocation_id": "...",
  "status": "ACTIVE",
  "protocols": ["SSTP", "WIREGUARD"], // ce qui est provisionné (inchangé)
  "active_transport": "SSTP", // ⬅️ NOUVEAU : ce qui SERT réellement
  "active_transport_reason": "ROUTEROS_V6_NO_WIREGUARD",
  "allocation_order": {
    "index": 3,
    "total": 5,
    "label": "VPN 3",
    "is_first": false,
    "is_latest": false,
    "ordered_by": "license_chronology",
    "order_at": "2026-09-14T10:53:12.505Z",
    "allocation_created_at": "2026-09-14T11:21:54.386Z",
    "license_starts_at": "2026-09-14T10:53:12.505Z",
    "license_created_at": "2026-09-14T10:53:12.613Z",
    "license_id": "LIC_...",
    "subscription_id": "...",
  },
  "tunnel": {},
  "redirection": {},
  "warnings": [],
}

protocols liste ce qui existe en base ; active_transport dit ce qui sert. Ne pas confondre les deux : c’est précisément cette confusion qui a produit 254 profils SSTP pour 3 routeurs réellement connectés.

Valeurs de active_transport_reason

Ces raisons servent au diagnostic et au support, pas à l’utilisateur final. Le client final n’a pas à connaître le protocole qui le transporte : la plateforme choisit toujours le meilleur pour lui, et n’utilise le repli que lorsque son matériel ne laisse pas le choix. Ce n’est pas une option, c’est une contrainte technique — l’exposer ne ferait qu’inquiéter sans rien apporter.

Valeur Sens À afficher au client ?
DEFAULT premier provisionnement, matériel capable non
UNCHANGED transport déjà en place, conservé non
ROUTEROS_V6_NO_WIREGUARD repli imposé par le matériel non
APP_REQUESTED l’app a demandé ce transport non
WIREGUARD_NEVER_CONNECTED repli après échec réseau non

Pour les anciennes allocations jamais réalignées, le serveur projette le protocole sélectionné dans active_transport sans écrire en base ; le client reçoit donc le même contrat complet.

Numérotation des allocations

GET /api/v1/clients/remote-access/list est l’écran de maîtrise du parc VPN client. Pour un client multi-licences, chaque entrée porte :

Champ Sens
allocation_order.index rang humain, dérivé de la chronologie de licence puis de l’arrivée de l’allocation
allocation_order.total nombre total d’allocations du client dans la liste
allocation_order.label libellé prêt à afficher, par exemple VPN 4
allocation_order.is_latest true pour la dernière allocation arrivée
allocation_order.is_archived true pour une allocation historique/expirée qui ne compte pas dans la numérotation active
allocation_order.ordered_by source de tri : license_chronology ou allocation_created_at
allocation_order.order_at date réellement utilisée pour classer la ligne

L’app ne doit pas utiliser port_slot, router_tunnel_ip, license_id ou l’ordre visuel local comme numéro client. Pour ouvrir/configurer une ligne, elle transmet toujours son allocation_id. Une ligne allocation_order.is_archived:true peut être affichée dans l’historique, mais pas comme VPN actif à choisir.

Révision 2026-09-16 — compatibilité de contrat : un bundle prêt expose toujours active_transport et active_transport_reason, quelle que soit la route qui le fournit. Pour une allocation historique dont le transport n’est pas encore persisté, le serveur projette le protocole déjà sélectionné pour cette réponse, avec la raison DEFAULT (ou ROUTEROS_V6_NO_WIREGUARD / APP_REQUESTED). Cette projection ne modifie pas l’allocation. Une réponse PROVISIONING reste explicitement incomplète (protocols: []) et ne doit pas être traitée comme un bundle prêt.

Révision 2026-09-17 — invariants de sélection : pour chaque bundle prêt, active_transport est obligatoirement identique à primary_protocol et à selection.selected_protocol. Une ancienne valeur d’allocation qui diffère est seulement conservée pour audit ; elle ne peut pas être publiée au client. Le serveur répond alors avec active_transport_reason: "CONTRACT_RECONCILED". Ainsi, un routeur RouterOS 7 avec un profil WireGuard actif reçoit WIREGUARD, sauf demande explicite valide de SSTP.

Le client doit utiliser le bundle retourné par validate-router lorsqu’il existe : cette réponse est la version canonique après verrouillage matériel. Il sélectionne strictement le profil dont protocol correspond à active_transport, après normalisation en majuscules. Si ce profil est absent, il doit signaler l’échec sans journaliser de clé, mot de passe ou script.

Pour un profil SSTP, le script RouterOS doit envoyer connect-to (hôte ou IP) et port comme deux propriétés distinctes. connect-to="hote:port" n’est pas un contrat accepté par RouterOS.

Révision 2026-09-16 — récupération d’un profil incomplet : si resolve constate qu’un créneau WireGuard n’a pas de clé privée ou de configuration livrable, il répond status: "PROVISIONING" et lance la recréation du peer industriel. L’app doit continuer son polling habituel jusqu’à ACTIVE ou PARTIAL. Pendant un reprovisionnement interactif, un profil complet mais sans handshake WireGuard depuis plus du seuil serveur reçoit également une paire industrielle neuve, prête à être flashée. Les jobs de fond restent exclus de cette rotation et un délai anti-boucle empêche de créer plusieurs paires pendant les retries.

5. Ordre des opérations côté serveur — ce que ça implique pour l’app

Une bascule fait toujours : créer le candidat → l’injecter → vérifier son plan de données depuis le serveur → sonder le routeur via son adresse propre → obtenir l’autorisation explicite → retirer l’ancien. Jamais l’inverse. Après resolve, l’app pousse le candidat, mais elle garde l’ancienne interface tant que retire_previous_allowed n’est pas true.

Le retrait exige le même candidate_channel_id, un canal précédent distinct, le plan de données confirmé et la sonde de gestion confirmée. Une seule de ces preuves ne suffit pas.

Le profil de l’ancien transport reste en base, avec ses clés : rebasculer plus tard ne demande ni nouvelles clés ni re-provisionnement complet.

6. Cas particuliers rencontrés en vrai

7. À ne pas faire

8. Confirmation candidate-specific (obligatoire avant retrait)

candidate_channel_id est obligatoire et se lit dans transport_channels[].channel_id du bundle resolve — jamais dans protocols[], qui ne porte que profile_id et ne contient aucun identifiant de canal. Un candidate_channel_id absent est refusé (VPN_CANDIDATE_CHANNEL_REQUIRED) ; y mettre un nom de protocole est refusé (VPN_CANDIDATE_CHANNEL_NOT_FOUND). Il n’existe pas de mode dégradé fondé sur le seul requested_transport : sans canal candidat, le backend ne peut pas prouver que la route de gestion du nouveau plan est distincte de l’ancienne, donc il n’autorise aucun retrait.

Après l’injection, lire transport_channels dans le bundle et appeler :

POST /api/v1/clients/remote-access/management-probe
{
  "operation_id": "uuid-cryptographique-stable-sur-retry",
  "allocation_id": "uuid",
  "candidate_channel_id": "uuid",
  "previous_channel_id": "uuid-ou-null",
  "requested_transport": "WIREGUARD | SSTP"
}

Le backend répond de manière idempotente :

{
  "reachable": true,
  "data_plane_confirmed": true,
  "observed_transport": "SSTP",
  "state": "CONFIRMED",
  "retire_previous_allowed": true,
}

Un reachable:true sans data_plane_confirmed:true, un protocole observé différent, une adresse candidate identique à l’ancien canal, ou toute réponse non CONFIRMED interdit le retrait. Le résultat est relisible via GET /api/v1/clients/remote-access/transport-attempts/:operationId.

9. Où lire les canaux, et lequel ne pas consommer

Le bundle resolve expose transport_channels[]. Chaque entrée porte :

{
  "channel_id": "uuid", // ⬅️ la valeur attendue en candidate_channel_id
  "role": "PRIMARY | FALLBACK | RESCUE",
  "protocol": "WIREGUARD | SSTP",
  "state": "ISSUED | LOCAL_CONFIGURED | DATA_PLANE_CONFIRMED | MANAGEMENT_CONFIRMED | ACTIVE | FAILED | REVOKED",
  "profile_id": "uuid",
  "endpoint": { "host": "…", "port": 51820 },
  "tunnel_address": "10.255.x.y",
  "last_data_plane_confirmed_at": "…",
  "last_management_probe_at": "…",
}

profile_id (un jeu de clés provisionné) et channel_id (un chemin de transport, avec son rôle et son adresse de gestion propre) sont deux objets distincts. protocols[] ne porte que le premier.

Ne pas confondre avec rescue_transports. Ce champ du bundle, aux entrées LEGACY_WG_HOST et WG_EASY_RESCUE, est un pont de migration WireGuard : il est retiré automatiquement dès que la migration du client est terminée. Il ne doit jamais être injecté comme chemin de secours durable.

Le rôle RESCUE est réservé pour un canal de gestion hors-bande sur passerelle indépendante. Aucun canal de ce rôle n’est émis à ce jour : tant que transport_channels[] n’en contient pas, il n’y a pas de secours à injecter, et l’app ne doit pas en fabriquer un.

10. Voie de secours Back To Home — à activer au provisioning

Le secours de la plateforme est Back To Home, le VPN WireGuard natif de MikroTik. Il sort vers les relais MikroTik, donc par un chemin indépendant de nos passerelles : c’est ce qui lui donne sa valeur. Sans lui, un client dont la passerelle est indisponible n’a plus d’accès à son équipement.

⚠️ Trois fonctions distinctes, à ne jamais confondre

Ce que c’est Marqueurs
/ip/cloud Service Cloud MikroTik : DDNS, mise à l’heure, sauvegarde, partage. Antérieur à Back To Home. ddns-enabled, public-address, dns-name, status
Back To Home Le VPN WireGuard natif, via relais. La voie de secours. back-to-home-vpn, vpn-status, vpn-port, vpn-dns-name
Case « VPN » de QuickSet Serveur L2TP/IPsec : identités IPsec, ports 500/1701/4500, NAT, utilisateur PPP. Sans rapport. /interface/l2tp-server, /ip/ipsec

Un /ip/cloud/print qui répond ne prouve rien sur la disponibilité du secours. Et désactiver Back To Home ne désactive pas le VPN de QuickSet, ni l’inverse. Si une interface propose « activer le VPN », elle doit préciser lequel : cocher QuickSet n’apporte aucune voie de secours.

Éligibilité, à vérifier avant de proposer l’activation

Lire /system/resource/print : architecture-name ∈ arm, arm64, tile et version ≥ 7.12. Hors de ces critères le matériel ne peut pas faire Back To Home — annoncer que le secours n’est pas disponible sur ce modèle, sans présenter cela comme un échec.

Séquence d’activation

/ip/cloud/set ddns-enabled=yes          # requis AVANT, sinon l'activation échoue
/ip/cloud/set back-to-home-vpn=enabled

Compter au moins 25 secondes avant de relire. La négociation avec les relais n’est pas instantanée : à 6 secondes le profil est encore incomplet et paraît en échec. Ne pas conclure à l’échec avant d’avoir relu passé ce délai.

Ces commandes ne touchent ni pare-feu, ni adresse, ni route.

Ce que fait le backend

Il lit /ip/cloud/print par le tunnel principal tant que celui-ci fonctionne, et conserve le profil chiffré. Rien à transmettre côté client : c’est une lecture serveur. Le routeur est ensuite marqué réparable.

Deux points de lecture, pour qui interroge /ip/cloud/print :

Identifiants du routeur

POST /api/v1/clients/remote-access/credentials doit être appelé au provisioning : c’est ce dépôt qui permet au backend de joindre le routeur ensuite. Le dépôt est unique et le backend relit quand il en a besoin — il n’existe pas de protocole où le backend réclame un identifiant à l’application, ce qui ferait dépendre une opération serveur d’un téléphone allumé.