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_idest fourni et appartient au client, il est la seule source de vérité pour le bundle (IP, peer, clés et ports). Lerouter_serialsert 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’aucunallocation_idn’est fourni, le serveur répond409 REMOTE_ACCESS_ALLOCATION_SELECTION_REQUIREDavec la liste des IDs à choisir. Un slot occupé par un autre routeur vivant reste protégé par409 ROUTER_SLOT_OCCUPIED.
Révision 2026-09-18 — lecture de bundle par série routeur :
GET /api/v1/clients/remote-accessaccepte aussirouter_serial(routerSerialousn) quandallocation_idest 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 afficherallocation_order.label(VPN 1,VPN 2, …), signalerallocation_order.is_latestpour le dernier achat/dernier arrivé, et ouvrir le détail avec l’allocation_idde 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
protocolniforce_transport« par sécurité ». Sansprotocol, le backend sélectionne le chemin normal. Avecprotocol: "SSTP", il prépare un candidat SSTP. Si WireGuard est encore vivant,force_transport: trueest 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_transportetactive_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 raisonDEFAULT(ouROUTEROS_V6_NO_WIREGUARD/APP_REQUESTED). Cette projection ne modifie pas l’allocation. Une réponsePROVISIONINGreste 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_transportest obligatoirement identique àprimary_protocolet à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 avecactive_transport_reason: "CONTRACT_RECONCILED". Ainsi, un routeur RouterOS 7 avec un profil WireGuard actif reçoitWIREGUARD, 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
resolveconstate qu’un créneau WireGuard n’a pas de clé privée ou de configuration livrable, il répondstatus: "PROVISIONING"et lance la recréation du peer industriel. L’app doit continuer son polling habituel jusqu’àACTIVEouPARTIAL. 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
- CHR / routeur virtuel sans RouterBOARD : pas de numéro de série matériel. L’app en génère
un et le renvoie dans
router_serial— comportement correct, à conserver. Sans lui, le backend répondLe numéro de série du routeur est requis. protocol: "WIREGUARD"sur un routeur v6 : la demande est refusée en silence et le backend renvoieSSTPavec la raisonROUTEROS_V6_NO_WIREGUARD. L’app doit donc lire la réponse plutôt que de supposer que sa demande a été honorée.- SSTP et adressage : un client en SSTP reçoit aujourd’hui une adresse de la plage
10.255.200.0/22, différente de son adresse WireGuard. L’adresse réellement attribuée par accel-ppp est la vérité de routage et est remontée par les événements de session ; l’app ne doit ni la fabriquer ni la mémoriser. Une réservation déterministe par slot existe côté serveur, mais ne remplace pas ce constat tant que le concentrateur peut attribuer une adresse dynamique.
7. À ne pas faire
- Envoyer
protocolà chaque appel « pour être sûr ». - Déduire le transport actif de
protocols: cette liste ne dit pas ce qui sert. - Retirer l’ancienne interface VPN avant
retire_previous_allowed:true. - Considérer qu’un
resolveréussi signifie que le tunnel est monté : il signifie que la configuration est prête. C’est le handshake (WireGuard) ou la session (SSTP) qui fait foi.
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éesLEGACY_WG_HOSTetWG_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 :
- il n’existe aucun champ
vpn-peer-private-key; la clé privée est une lignePrivateKey = …à l’intérieur devpn-wireguard-client-config, un fichier WireGuard complet publié sur plusieurs lignes ; vpn-dns-nameest le nom du service Back To Home,dns-namecelui du DDNS Cloud. Les confondre fait viser le mauvais hôte.
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é.