Contrat API — choix du transport VPN (app mobile / front client)
Date : 2026-07-27
Pour : l’app mobile et le front client
Endpoint concerné : POST /api/v1/clients/remote-access/resolve
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.
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 |
Ce que fait le backend |
|---|---|---|
| Provisionnement normal | ne pas l’envoyer | v7 → WireGuard, v6 → SSTP |
| Le client est déjà installé et ça marche | ne pas l’envoyer | rien ne change — le transport en place est conservé |
| L’app constate sur le terrain que WireGuard ne passera pas | "SSTP" |
crée un candidat SSTP, sans retrait immédiat |
| Le tunnel WireGuard n’a jamais monté chez lui | "SSTP" |
prépare le repli puis attend sa preuve |
Ne jamais envoyer
protocol« par sécurité ». Sans ce champ, le backend conserve l’existant. Avec, il bascule — c’est-à-dire qu’il retire l’autre transport côté serveur. Une bascule involontaire sur un tunnel qui fonctionne, c’est une coupure pour le client.
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",
"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 |
active_transport peut être null sur d’anciennes allocations jamais réalignées : dans ce cas,
se fier à protocols comme avant, et l’alignement se fera à la prochaine demande.
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 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/24, différente de son adresse WireGuard. C’est temporaire — les pools nommés par slot lui rendront son adresse unique. L’app n’a rien à faire de cette adresse.
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é.