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 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

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-namearm, arm64, tile et version7.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é.