Protocole réseau
Ce document spécifie intégralement le format échangé entre agent, exchange et cli. Les définitions résident dans crates/proto et sont partagées par les trois composants : tout ajout ou retrait de champ se définit à un seul endroit, et les incohérences apparaissent à la compilation plutôt que pendant une session en production.
Il s'adresse aux administrateurs qui analysent des captures réseau et aux développeurs qui modifient le protocole ou écrivent un client tiers.
Sommaire
- Vue d'ensemble
- Transport et points d'accès
- Version du protocole
- Messages de contrôle
- Trames de données binaires
- Traduction entre deux espaces d'identifiants de session
- Séquence de négociation
- Traitement et catégories d'erreurs
- Maintien de connexion et délais
- Contre-pression
- Contrôle de l'écran Android
- Sessions scellées de bout en bout
Vue d'ensemble
Le protocole comprend deux couches sur une même connexion WebSocket :
| Couche | Support | Encodage | Contenu |
|---|---|---|---|
| Messages de contrôle | Trames WebSocket texte | JSON | Enregistrement, pulsations, ouverture/fermeture de sessions, résultats |
| Trames de données | Trames WebSocket binaires | En-tête fixe de 9 octets + octets bruts | Flux PTY, stdout/stderr des commandes |
Le découpage évite le surcoût d'environ 33 % du base64 dans JSON. Les petits paquets PTY, fréquents et renvoyés à chaque frappe, transformeraient directement ce surcoût en latence. Les messages de contrôle sont peu fréquents : la lisibilité du JSON (captures lisibles) présente davantage d'intérêt.
Par convention, une trame WebSocket binaire transporte exactement une trame de données ; tout octet manquant ou excédentaire constitue une erreur.
Transport et points d'accès
| Point d'accès | Client | Authentification |
|---|---|---|
GET /ws/device |
agent | La première trame après connexion doit être un message register avec device_id et token |
GET /ws/operator |
remote-cli | En-tête HTTP de négociation WebSocket Authorization: Bearer <运维令牌> |
Ces chemins utilisent deux catégories d'identifiants distinctes et non interchangeables. Un jeton d'appareil ne fonctionne pas sur le point d'accès opérateur, et réciproquement.
Les identifiants opérateur passent dans l'en-tête plutôt que dans l'URL, car celle-ci apparaît dans les journaux des proxys inverses, l'historique et Referer. Le jeton d'appareil passe dans la première trame : certains clients embarqués personnalisent difficilement les en-têtes WebSocket, et cette méthode permet une réponse commune aux versions incompatibles et aux jetons incorrects.
En production, wss:// est obligatoire : les deux identifiants apparaissent en clair pendant la négociation.
Version du protocole
pub const PROTOCOL_VERSION: u16 = 1;
Les versions sont comparées dans les deux sens pendant la négociation ; toute incompatibilité entraîne un refus immédiat et explicite. Cela évite qu'une incompatibilité se manifeste plus tard par une erreur de décodage incompréhensible, très difficile à diagnostiquer.
- Appareil : agent indique sa version dans
register. En cas d'incompatibilité, le serveur répond parregister_ackavecok: false, précise les deux versions danserror, puis ferme la connexion. - Opérateur : le serveur envoie spontanément
welcomeaprès connexion. Si la version diffère, CLI signale l'erreur et demande une mise à niveau compatible avecserver_version.
Toute valeur de message type inconnue est refusée, jamais interprétée silencieusement comme un type connu.
Messages de contrôle
Tous les messages de contrôle sont des objets JSON distingués par type (énumération serde à balisage interne). Les valeurs et noms de champs suivent snake_case. session_id se sérialise directement comme un nombre.
DeviceUp (agent → exchange)
type |
Champs | Rôle |
|---|---|---|
register |
protocol_version: u16device_id: stringtoken: stringinfo: DeviceInfo |
Doit être le premier message. Le serveur n'accepte aucun autre message avant lui et impose un délai de 15 secondes |
heartbeat |
metrics: Metrics |
Rapport périodique des métriques métier. Les ping/pong WebSocket natifs vérifient séparément la connexion : une pulsation manquante ne prouve pas une rupture, ni l'inverse |
exec_started |
session_idjob_idholdtimeout_secs |
Commande démarrée ; job_id sert à la liste hold et au rattachement après reconnexion |
exec_exit |
session_idcode: i32 | nulltimed_out: bool |
Commande terminée. code vaut null après arrêt par signal ; timed_out indique un arrêt forcé pour dépassement du délai |
exec_list_resp |
session_idjobs: ExecHoldInfo[] |
Liste des tâches hold |
ubus_resp |
session_idcode: i32data: any |
Résultat ubus. code est le code de retour ubus ; 0 signifie succès |
android_resp |
session_idresult: AndroidResult |
Opération d'écran Android terminée ; termine la session |
pty_closed |
session_idcode: i32 | null |
Session PTY terminée ; code est le code de sortie du shell |
session_error |
session_idmessage: string |
Erreur de session. L'appareil répond ainsi lorsqu'il ne peut satisfaire une requête, au lieu de l'ignorer |
Un second register sur une connexion enregistrée est considéré comme un usage incorrect du protocole et produit un avertissement.
Exemple :
{"type":"register","protocol_version":1,"device_id":"lab-router","token":"a1b2….c2Vj…","info":{"hostname":"OpenWrt","model":"ath79/generic","openwrt_version":"OpenWrt 23.05.5","kernel":"5.15.137","arch":"mips","agent_version":"0.1.0"}}
{"type":"exec_exit","session_id":31,"code":0,"timed_out":false}
DeviceDown (exchange → agent)
type |
Champs | Rôle |
|---|---|---|
register_ack |
ok: boolerror: string | nullheartbeat_interval_secs: u64 |
Résultat d'enregistrement. Si ok: false, error indique la raison puis le serveur ferme la connexion. Le serveur fixe l'intervalle des pulsations pour pouvoir l'allonger sur toute la flotte sans reconfigurer chaque appareil. agent impose au moins 1 seconde si la valeur vaut 0 pour éviter une boucle saturant le CPU |
device_access |
managers: DeviceManager[]updated_at: u64 (millisecondes) |
Autorisations sur cet appareil. DeviceManager = operator_id, username, email?, role, level (manage/view), source (role_default/grant). Envoyé après enregistrement et modification des droits, uniquement aux agents déclarant la capacité device_access ; voir docs/permissions.md dans le dépôt |
exec_req |
session_idcommand: stringtimeout_secs: u64hold: bool (défaut true)hold_timeout_secs?: u64 |
Exécute une commande. Avec hold=true (défaut), le processus continue après déconnexion de l'opérateur ; le délai utilise hold_timeout_secs (absent/0 → 3600, plafond 86400). hold=false annule la commande à la déconnexion de l’opérateur |
exec_cancel |
session_id |
Termine prématurément (kill) une commande |
exec_detach |
session_id |
Arrête la diffusion vers l'opérateur courant en conservant le processus hold |
exec_list_req |
session_id |
Demande la liste courante des tâches hold |
exec_attach_req |
session_idjob_id |
Rattache une tâche hold à cette session et rejoue la sortie mémorisée |
exec_hold_kill |
session_idjob_id |
Termine une tâche hold par job_id |
exec_hold_tail |
session_idjob_id |
Suit uniquement la nouvelle sortie, sans rejouer le tampon |
exec_hold_cat |
session_idjob_id |
Vide le tampon courant puis termine par exec_hold_cat_done |
ubus_req |
session_idobject: stringmethod: stringparams: any |
Lance un appel ubus. Si params vaut null, aucun troisième argument positionnel n'est passé à ubus |
pty_open |
session_idrows: u16cols: u16 |
Ouvre une session PTY. Des dimensions nulles sont remplacées par 24×80 |
pty_resize |
session_idrows: u16cols: u16 |
Modification des dimensions du terminal |
pty_close |
session_id |
Ferme la session PTY |
android_req |
session_idrequest: AndroidAction |
Une opération d'écran Android |
android_cancel |
session_id |
Annule une requête Android inachevée |
OperatorUp (cli → exchange)
L'authentification a lieu pendant la négociation WebSocket ; aucun message de connexion n'est nécessaire.
type |
Champs | Rôle |
|---|---|---|
open_exec |
session_iddevice_idcommandtimeout_secshold (défaut true)hold_timeout_secs? |
Exécute une commande sur l'appareil cible. hold est actif par défaut ; hold=true avec délai 0 → 3600s. hold=false avec délai 0 → défaut Exchange de 60s |
list_exec_holds |
session_iddevice_id |
Liste les tâches exec et les téléchargements, envois et requêtes de répertoire en cours |
list_device_sessions |
session_iddevice_id |
Consulte directement la table de routage Exchange sans utiliser de place de session sur l'appareil |
kill_device_session |
session_iddevice_idtarget_session_id |
Annule par target_session_id côté appareil et libère localement la route, y compris pour hold exec, sans place supplémentaire |
attach_exec |
session_iddevice_idjob_id |
Rattache une tâche hold et reprend la réception de sortie |
exec_hold_kill / exec_hold_tail / exec_hold_cat |
session_iddevice_idjob_id |
kill / tail / cat d'une tâche hold |
detach_session |
session_id |
Termine l'attente au premier plan et détache sans tuer le processus hold |
open_ubus |
session_iddevice_id: stringobject: stringmethod: stringparams: any |
Lance un appel ubus |
open_pty |
session_iddevice_id: stringrows: u16cols: u16 |
Ouvre une session PTY interactive |
open_android |
session_iddevice_id: stringrequest: AndroidAction |
Lance une opération d'écran Android ; interdit aux comptes en lecture seule |
pty_resize |
session_idrows: u16cols: u16 |
Redimensionne le terminal. Aucun device_id : la session existe et le serveur connaît la cible |
close_session |
session_id |
Termine volontairement une session : sortie normale du PTY ou interruption d'une commande |
Dans les quatre messages open_*, CLI attribue lui-même session_id (voir Traduction entre deux espaces d'identifiants de session). Il peut donc envoyer immédiatement les trames suivantes sans attendre de confirmation d'identifiant.
OperatorDown (exchange → cli)
type |
Champs | Rôle |
|---|---|---|
welcome |
protocol_version: u16server_version: string |
Envoi spontané du serveur après connexion pour vérifier la version |
device_sessions |
session_idsessions: DeviceSessionInfo[]limitcount |
Sessions courantes côté appareil dans Exchange |
device_session_killed |
session_idtarget_session_idreleased: bool |
Libération locale effectuée ; false si la session n'existait pas |
exec_exit |
session_idcode: i32 | nulltimed_out: bool |
Fin de commande, traduite depuis le message homonyme de l'appareil |
ubus_resp |
session_idcode: i32data: any |
Résultat de l'appel ubus |
android_resp |
session_idresult: AndroidResult |
Résultat Android avec identifiant traduit côté opérateur |
pty_closed |
session_idcode: i32 | null |
Session PTY terminée |
session_failed |
session_idreason: SessionFailuremessage: string |
Échec de session. reason permet au CLI de décider si une nouvelle tentative est pertinente |
Structures communes
ExecHoldInfo : job_id, kind (exec / download / upload / file), age_secs, session_id?, command_preview, status, timeout_secs, started_at_unix_ms, exit_code?, timed_out. Pour compatibilité, kind vaut exec par défaut et age_secs vaut 0 ; la liste calcule l'âge selon l'heure courante. Le résumé de commande/chemin est limité à environ 120 caractères. Les tâches autres qu'exec acceptent uniquement kill, pas tail/cat/attach.
DeviceSessionInfo : session_id: u32 (identifiant côté appareil), operator_session_id: u32, kind: string, hold: bool, age_secs: u64, deadline_unix_ms: u64. Exchange répond directement aux listes et annulations forcées sans ouvrir de nouvelle session sur l'appareil ; les comptes en lecture seule reçoivent forbidden.
DeviceInfo
Informations statiques transmises une fois dans register et stockées dans devices.info. Tout est facultatif sauf agent_version : hors OpenWrt (machine de développement, conteneur), la plupart des sources n'existent pas, mais agent doit pouvoir s'enregistrer normalement. Les champs absents sont omis à la sérialisation.
| Champ | Type | Source |
|---|---|---|
hostname |
string? |
/proc/sys/kernel/hostname |
model |
string? |
DISTRIB_TARGET dans /etc/openwrt_release, sinon model de ubus call system board |
openwrt_version |
string? |
DISTRIB_DESCRIPTION, ou release.description d'ubus |
kernel |
string? |
/proc/sys/kernel/osrelease |
arch |
string? |
Constante de compilation, plus fiable qu'une détection à l'exécution |
agent_version |
string |
Version d'agent, utile pour diagnostiquer les incompatibilités |
Metrics
Relevées à chaque pulsation, uniquement depuis /proc sans créer de sous-processus. L'échec d'une mesure n'annule pas l'ensemble : perdre une métrique est préférable à perdre la pulsation et faire considérer l'appareil hors ligne.
| Champ | Type | Description |
|---|---|---|
uptime_secs |
u64 |
/proc/uptime |
load |
[f32; 3] |
Charges moyennes sur 1/5/15 minutes de /proc/loadavg |
mem_total_kb |
u64 |
/proc/meminfo |
mem_available_kb |
u64 |
Utilise MemAvailable plutôt que MemFree, qui exclut les caches récupérables et sous-estime fortement la mémoire disponible d'un routeur, provoquant de fausses alertes |
cpu_usage_pct |
f32? |
Différence entre deux relevés /proc/stat ; omis à la première pulsation faute de référence |
interfaces |
InterfaceStats[] |
/proc/net/dev. Envoie les octets cumulés, pas les débits, pour laisser le serveur calculer ces derniers sur toute fenêtre. Omis si le tableau est vide |
InterfaceStats : { name: string, rx_bytes: u64, tx_bytes: u64 }
Trames de données binaires
0 1 5 9
+--------+--------------------+--------------------+
| kind | session_id | payload_len | payload (payload_len 字节)
| u8 | u32 (大端) | u32 (大端) |
+--------+--------------------+--------------------+
└──────────── 9 字节定长头 ────────────┘
| Décalage | Taille | Champ | Description |
|---|---|---|---|
| 0 | 1 | kind |
Type de flux, voir ci-dessous |
| 1 | 4 | session_id |
u32 big-endian. 0 est réservé à la sentinelle « aucune session » |
| 5 | 4 | payload_len |
u32 big-endian, taille utile en octets, maximum 1 MiB |
| 9 | payload_len |
payload |
Octets bruts, sans encodage ni échappement |
Types de flux
| Valeur | Nom | Sens | Description |
|---|---|---|---|
| 1 | ExecStdout |
Appareil → opérateur | Sortie standard de la commande distante |
| 2 | ExecStderr |
Appareil → opérateur | Erreur standard séparée de stdout afin que CLI la distingue, par exemple en rouge |
| 3 | PtyData |
Bidirectionnel | Flux PTY : saisie clavier à l'aller, affichage au retour, même type dans les deux sens |
Si agent reçoit ExecStdout / ExecStderr en sens descendant, il avertit et ignore la trame : cela révèle une erreur d'implémentation du pair.
Choix de conception
payload_len est effectivement redondant avec la longueur WebSocket. Il permet de détecter une troncature (11 octets annoncés mais 5 reçus ne doivent pas devenir silencieusement un petit paquet) et de conserver le format en cas de migration ultérieure vers TCP brut.
La limite de 1 MiB ne vise pas le trafic normal, où PTY et sorties de commandes arrivent en petits incréments, mais empêche un en-tête annonçant 2 GiB d'épuiser la mémoire du serveur.
Une charge utile vide est valide : un PTY peut occasionnellement produire une lecture de zéro octet. La trame se réduit alors à l'en-tête de 9 octets.
La charge utile accepte tout octet, même des séquences non UTF-8, à conserver intégralement.
Erreurs de décodage
| Erreur | Déclencheur |
|---|---|
FrameTooShort { len } |
Longueur totale inférieure à 9 octets |
UnknownStreamKind(v) |
Premier octet différent de 1/2/3 |
PayloadTooLarge { len } |
payload_len déclaré supérieur à 1 MiB |
LengthMismatch { declared, actual } |
Longueur déclarée différente du nombre d'octets restants |
Une erreur de décodage n'affecte que la trame : exchange avertit et l'ignore sans déconnecter.
Traduction entre deux espaces d'identifiants de session
C'est la partie du protocole la plus facile à mal comprendre.
En une phrase
L'initiateur attribue session_id, unique uniquement sur sa propre connexion. Les côtés opérateur et appareil disposent d'espaces indépendants, traduits par exchange. Une session logique possède deux identifiants différents.
remote-cli exchange remote-agent
(设备 lab-router)
┌────────────┐ ┌────────────────────┐ ┌────────────┐
│ 运维编号 │ │ 路由表 │ │ 设备编号 │
│ 空间 │ │ │ │ 空间 │
│ │ │ lab-router: │ │ │
│ session 7 │───────▶│ 31 → (op-A, 7) │───────────▶│ session 31 │
│ │◀───────│ │◀───────────│ │
└────────────┘ │ op-A: │ └────────────┘
│ 7 → (lab-router,│
由 CLI 自己的 │ 31) │ 由 exchange
SessionIdGenerator └────────────────────┘ 的 DeviceHandle
分配(1,2,3…) 分配(1,2,3…)
Pourquoi ce choix
Éviter un aller-retour. Si le serveur attribuait tout, CLI devrait attendre une confirmation « votre session est 31 » avant de transmettre le clavier au PTY. En attribuant lui-même l'identifiant, il peut envoyer les données immédiatement après open_pty.
Éviter les collisions entre opérateurs. Deux opérateurs peuvent utiliser simultanément l'identifiant local 1 sur un même appareil. L'appareil doit les distinguer pour ne pas livrer la sortie à la mauvaise personne. exchange attribue des identifiants propres à chaque connexion appareil : les deux session 1 deviennent 31 et 32.
Isoler la numérotation appareil du comportement opérateur. exchange maîtrise entièrement les identifiants côté appareil ; aucun opérateur ne peut les choisir ou les deviner.
Deux tables
exchange maintient une table par connexion et met toujours les deux à jour ensemble :
| Emplacement | Table | Clé → valeur |
|---|---|---|
DeviceHandle.sessions |
Table directe côté appareil | Identifiant appareil → (canal d'envoi opérateur, identifiant opérateur, ID du compte opérateur, type de session, ID de ligne d'audit) |
OperatorHandle.sessions |
Index inverse côté opérateur | Identifiant opérateur → (ID appareil, identifiant appareil) |
L'index inverse permet le nettoyage : lorsqu'un opérateur se déconnecte, il faut retrouver et terminer ses sessions sur tous les appareils, sinon des processus et PTY continueraient sans destinataire.
Où intervient la traduction
| Sens | Message | Traduction |
|---|---|---|
Opérateur → appareil (open_*) |
Contrôle | Attribue un nouvel identifiant appareil, inscrit les deux tables et transmet ce nouvel identifiant |
Opérateur → appareil (pty_resize / close_session) |
Contrôle | Retrouve l'identifiant appareil dans l'index inverse, le remplace et transmet |
| Opérateur → appareil | Données | Retrouve l'identifiant appareil, réécrit les 4 octets d'en-tête sur place, conserve la charge utile |
| Appareil → opérateur | Contrôle | La table directe fournit l'identifiant opérateur et la connexion cible ; remplacement puis livraison |
| Appareil → opérateur | Données | La table directe fournit l'identifiant opérateur ; réécrit l'en-tête sur place, conserve la charge utile |
Cette traduction est la seule modification d'une trame : exchange n'analyse, ne mémorise et ne modifie aucun octet utile. DataFrame::with_session_id remplace l'identifiant sur place sans réallouer la charge utile.
Règles d'attribution
Les deux allocateurs commencent à 1, incrémentent et sautent 0, sentinelle « aucune session », par exemple pour les erreurs de connexion.
- CLI (
SessionIdGenerator) : revient au début après débordement et saute 0. Quatre milliards de sessions sur une connexion sont improbables, mais ce comportement convient mieux à un processus permanent qu'un panic. - Appareil (
DeviceHandle::open_session) : après retour au début, cherche la prochaine place libre si un ancien identifiant reste actif.
exchange refuse 0 et les identifiants opérateur déjà actifs. La validité de connexion appareil, la limite de concurrence et l'inscription des deux index sont vérifiées dans une même section critique pour empêcher plusieurs ouvertures simultanées de dépasser la limite.
Route introuvable
Ignorer silencieusement, sans erreur. Des données résiduelles après fermeture relèvent d'une course normale, dans les deux sens :
- Réponse appareil pour une session disparue → journal debug ; l'opérateur a pu se déconnecter avant.
- Données opérateur pour une session sans propriétaire → journal trace et abandon.
Nettoyage des sessions
| Déclencheur | Nettoyage |
|---|---|
Fin normale côté appareil (exec_exit / ubus_resp / android_resp / pty_closed / session_error) |
Supprime route directe et index inverse, complète l'audit et livre le message traduit à l'opérateur |
close_session opérateur |
Supprime les deux index, demande l'arrêt à la connexion appareil d'origine, confirme immédiatement la fin à l'opérateur et complète l'audit d'annulation. Fermer deux fois ne fait rien |
| Déconnexion appareil | Retire toutes ses sessions, envoie session_failed / device_disconnected aux opérateurs concernés et complète l'audit avec {"outcome":"device_disconnected"} |
| Déconnexion opérateur | Parcourt l'index inverse ; termine les sessions non hold, envoie seulement exec_detach aux hold exec sans tuer le processus. Nettoie uniquement ses propres sessions ; celles des autres opérateurs restent actives |
| Reconnexion appareil remplaçant l'ancienne | Invalide toutes les anciennes sessions et notifie chaque opérateur par device_disconnected |
L'index inverse est lié à une connexion appareil précise : une reconnexion ne le redirige pas vers la nouvelle connexion du même appareil. Plusieurs connexions d'un même compte ont leurs propres index ; en fermer une n'annule pas les sessions d'une autre.
Confirmation d'annulation : exec reçoit exec_exit (code: null, timed_out: false), PTY reçoit pty_closed (code: null), ubus reçoit session_failed (reason: device_error). Cela confirme la fermeture dans exchange, pas la sortie effective du processus distant ; les données et notifications ultérieures sont ignorées.
À la limite de concurrence, Exchange récupère d'abord, sous le même verrou, les routes sans opérateur ou expirées, puis revérifie la limite. Échéance exec : délai effectif + 15 secondes ; fichiers : 3600 + 15 secondes ; autres types : 24 heures. Un hold exec non expiré survit au départ de l'opérateur. La récupération ou kill_device_session envoie l'annulation appropriée et supprime immédiatement les deux index sans attendre l'appareil. Les opérateurs disponibles reçoivent exec_exit (timed_out:true) pour exec ou session_failed (device_error, session reclaimed) pour les autres types. L'audit est complété de manière asynchrone au mieux.
Reconnexion appareil et epoch
Chaque connexion reçoit un epoch aléatoire (UUID) lors de l'enregistrement. Une nouvelle connexion du même appareil remplace l'ancienne : il serait impossible de distinguer sûrement un doublon d'une ancienne connexion morte mais encore considérée active (semi-ouverte). Refuser bloquerait l'appareil jusqu'à expiration de l'ancienne connexion.
Le nettoyage de l'ancienne peut intervenir après le nouvel enregistrement. Il faut donc vérifier l'epoch avant suppression : ne retirer la connexion courante que si son epoch correspond. Sinon, le nettoyage ancien marquerait immédiatement hors ligne l'appareil qui vient de revenir.
Séquence de négociation
Côté appareil
remote-agent exchange
│ │
│ ① WebSocket 握手(wss://host/ws/device) │
│────────────────────────────────────────────────▶│
│◀────────────────────────────────────────────────│
│ 101 Switching Protocols │
│ │
│ ┌──────┴──────┐
│ │ 启动 15 秒 │
│ │ 注册计时器 │
│ └──────┬──────┘
│ ② {"type":"register","protocol_version":1, │
│ "device_id":"…","token":"…","info":{…}} │
│────────────────────────────────────────────────▶│
│ │
│ ┌──────────────────┴──────────────────┐
│ │ a. 比对 protocol_version │
│ │ b. split_token → 前缀查 devices 表 │
│ │ c. 校验 enabled │
│ │ d. argon2 verify(前缀不符也照做, │
│ │ 避免用耗时区分前缀是否存在) │
│ └──────────────────┬──────────────────┘
│ │
│ ③ {"type":"register_ack","ok":true, │
│ "error":null,"heartbeat_interval_secs":30} │
│◀────────────────────────────────────────────────│
│ │
│ ┌──────┴──────┐
│ │ 登记连接表 │
│ │ 顶替旧连接 │
│ │ 回写 info 与│
│ │ last_seen_at│
│ └──────┬──────┘
│ │
│ ④ 退避曲线复位,写状态文件 state=connected │
│ │
│══════════════ 转发循环 ═══════════════════════════│
│ │
│ heartbeat(每 30 秒,携带 Metrics) │
│────────────────────────────────────────────────▶│
│ WebSocket Ping(随每次 heartbeat 发出) │
│────────────────────────────────────────────────▶│
│ WebSocket Pong
│◀────────────────────────────────────────────────│
│ WebSocket Ping(每 30 秒)
│◀────────────────────────────────────────────────│
│ Pong(tungstenite 排队,agent 显式 flush) │
│────────────────────────────────────────────────▶│
Échecs :
- Première trame non textuelle, JSON invalide ou message autre que
register→ avertissement et fermeture immédiate sans ack. - Aucun
registeren 15 secondes → fermeture. - Version incompatible → réponse
register_ack{ok:false, error:"协议版本不匹配:设备为 X,服务端为 Y"}, puis fermeture. - Jeton invalide / appareil inexistant / désactivé → même réponse
"设备 ID 或令牌无效", puis fermeture. La raison reste indistinguable, afin d'empêcher l'énumération des appareils.
agent traite séparément ces deux catégories :
| Catégorie agent | Déclencheur | Reconnexion |
|---|---|---|
Rejected |
ack avec ok: false |
Attente fixe de 300 secondes. Réessayer ne corrige pas un jeton ; des tentatives fréquentes gaspillent les ressources et saturent les journaux. L'attente laisse le temps de corriger la configuration |
Transport |
Connexion impossible, négociation expirée (45 secondes), réponse mal formée, rupture | Repli exponentiel : 1 seconde initialement, double à chaque fois, maximum 300 secondes, multiplié par une variation aléatoire dans [0.5, 1.5) |
Le repli ne se réinitialise qu'après enregistrement réussi, pas dès la connexion TCP. Sinon une connexion immédiatement rejetée provoquerait des tentatives espacées de quelques millisecondes.
La variation aléatoire est indispensable. Après redémarrage du serveur, des centaines ou milliers d'appareils détectent simultanément la rupture. Une courbe déterministe les maintiendrait synchronisés, provoquant des vagues de connexions susceptibles de transformer un redémarrage en avalanche continue.
Côté opérateur
remote-cli exchange
│ │
│ ① WebSocket 握手(wss://host/ws/operator) │
│ Authorization: Bearer <prefix>.<secret> │
│────────────────────────────────────────────────▶│
│ │
│ ┌──────────────────┴──────────────────┐
│ │ 在 upgrade 之前完成鉴权: │
│ │ split_token → 前缀查 operators 表 │
│ │ → argon2 verify → 校验 enabled │
│ └──────────────────┬──────────────────┘
│ │
│ 鉴权失败 → HTTP 401「凭证无效」(不升级) │
│◀────────────────────────────────────────────────│
│ │
│◀────────────────────────────────────────────────│
│ 101 Switching Protocols │
│ │
│ ② {"type":"welcome","protocol_version":1, │
│ "server_version":"0.1.0"} │
│◀────────────────────────────────────────────────│
│ │
│ ③ 比对 protocol_version(15 秒超时) │
│ 不一致 → 报错退出,提示升级 CLI │
│ │
│══════════════ 可以发起会话 ═══════════════════════│
│ │
│ {"type":"open_pty","session_id":1, │
│ "device_id":"lab-router","rows":40,"cols":120}│
│────────────────────────────────────────────────▶│
│ │──▶ 查设备在线
│ │──▶ 查并发上限
│ │──▶ 查 readonly 角色
│ │──▶ 写审计(先于下发)
│ │──▶ 分配设备侧编号
│ 二进制帧(PtyData, session 1, 键盘输入) │
│────────────────────────────────────────────────▶│──▶ 改写为设备侧编号
│ │
│ 二进制帧(PtyData, session 1, 屏幕输出) │
│◀────────────────────────────────────────────────│◀── 改写为运维侧编号
CLI peut envoyer les trames de données suivantes sans attendre aucune confirmation : c'est précisément l'intérêt d'attribuer l'identifiant côté initiateur.
Traitement et catégories d'erreurs
SessionFailure
Valeur du champ reason de session_failed, sérialisée en snake_case.
| Valeur | Signification | Origine | Nouvelle tentative |
|---|---|---|---|
device_offline |
Appareil inexistant ou hors ligne | exchange ne trouve pas l'appareil dans la table lors de open_* |
✅ |
device_disconnected |
Déconnexion appareil en cours de session | Déconnexion, remplacement par une nouvelle connexion ou file d'envoi déjà fermée à la transmission | ✅ |
device_error |
Refus ou échec côté appareil | session_error d'agent (création PTY, lancement de commande, appel ubus), ou pair trop lent lors du transfert |
❌ |
rate_limited |
Limite de sessions simultanées dépassée | exchange constate lors de open_* que --max-sessions-per-device est atteint (défaut 32) |
✅ |
forbidden |
Identifiants opérateur sans accès à l'appareil | Un compte en lecture seule (role = readonly) tente d'ouvrir une session |
❌ |
is_retryable() exclut volontairement forbidden et device_error : réessayer une erreur de droits ou interne à l'appareil ne ferait que faire tourner CLI inutilement.
CLI ajoute une suggestion exploitable après le message original ; pour device_offline, il propose remote-cli device list. Exception : device_error, dont le message d'appareil constitue déjà l'explication la plus précise et ne doit pas être dilué.
Portée des échecs par couche
| Couche | Comportement | Portée |
|---|---|---|
| Décodage de trame | Journaliser, ignorer la trame | Une trame |
| Analyse d'un message de contrôle | Avertir, ignorer le message | Un message, connexion conservée |
| Échec de session | session_failed / session_error |
Une session, connexion conservée |
| Échec d'enregistrement | register_ack{ok:false} puis fermeture |
Connexion entière |
| Version incompatible | Erreur explicite puis fermeture | Connexion entière |
| File saturée | Fermeture volontaire | Connexion entière |
Principe : un échec localisable ne doit jamais devenir un échec de connexion, car une connexion appareil peut transporter les sessions de plusieurs opérateurs.
Maintien de connexion et délais
| Mécanisme | Valeur | Description |
|---|---|---|
| Ping WebSocket exchange | Tous les --heartbeat-secs (défaut 30 secondes) |
exchange envoie aux appareils et opérateurs. La bibliothèque cliente met automatiquement Pong en file ; agent doit explicitement effectuer flush du sink après Ping pour envoyer Pong rapidement |
| Ping WebSocket agent | Tous les heartbeat_interval_secs (fourni dans ack) |
agent envoie un Ping natif en plus du JSON heartbeat, afin qu'un retard du Ping exchange n'atteigne pas la limite d'inactivité Cloudflare d'environ 100 secondes |
| Silence de connexion | heartbeat × --missed-pings (défaut 30 × 3 = 90 secondes) |
Sans aucun message entrant pendant ce délai, connexion appareil ou opérateur considérée perdue et fermée. Pong compte comme activité ; 0 pour --missed-pings impose au moins une période de pulsation |
| Écriture exchange | 10 secondes | Une écriture WebSocket de données ou Ping expirée termine la connexion pour ne pas bloquer le nettoyage |
| Pulsation métier | Tous les heartbeat_interval_secs (fourni dans ack) |
agent envoie JSON heartbeat avec Metrics et séparément un Ping WebSocket. Le rapport de métriques et ping/pong ont des rôles distincts : la pulsation métier seule ne détermine pas l'état du lien |
| Événements en ligne/hors ligne | tick_count des déclencheurs online / offline (défaut 3, plage 1–100) ; repli diffusion/audit REMOTE_MISSED_THRESHOLD / REMOTE_ONLINE_THRESHOLD |
L'opérateur configure deux déclencheurs distincts, pas deux compteurs sur un seul. offline notifie après tick_count échecs consécutifs ; online après tick_count succès consécutifs. device_ids accepte un ID chaîne, un tableau d'ID, ou omission / null pour tout l'espace de travail. Aucun rappel tant que l'état annoncé persiste ; il faut tick_count résultats opposés pour réarmer. La diffusion presence en mémoire et l'audit ignorent ces déclencheurs et utilisent REMOTE_MISSED_THRESHOLD / REMOTE_ONLINE_THRESHOLD (défaut 3 aussi). La présence du socket dans la table dépend séparément de --missed-pings |
| Enregistrement appareil | 15 secondes | Délai pour envoyer register après connexion |
| Négociation agent | 45 secondes par étape (HANDSHAKE_TIMEOUT) |
connect_device_ws (TCP, HTTP CONNECT facultatif, TLS, WebSocket) puis attente de register_ack après register ont chacun ce délai ; ce n'est pas un total de 45 secondes |
| Écriture agent / flush Pong | 30 secondes (WRITE_TIMEOUT) |
Chaque send (enregistrement, pulsation JSON, Ping, sortie de session) et flush Pong après enregistrement a son délai. Échec ou expiration ferme et reconnecte. Pendant l'attente d'enregistrement, flush Pong reste soumis au délai ack de 45 secondes |
| Keepalive TCP agent | Sondage après 30 secondes d'inactivité | Après connexion directe ou HTTP CONNECT, active TCP_NODELAY et TCP keepalive. Ce réglage TCP ne remplace pas Ping/Pong WebSocket |
| Accueil CLI | 15 secondes | Attente de welcome après connexion |
| Exécution de commande | timeout_secs de open_exec |
agent impose au moins 1 seconde. À expiration, SIGTERM au groupe de processus entier, puis SIGKILL après 3 secondes de grâce |
| Appel ubus | 15 secondes, fixé dans agent | Pour ce RPC local, un tel dépassement indique presque sûrement un service bloqué |
Un shell inactif répond toujours à Pong ; agent envoie explicitement par flush les réponses mises en file par la bibliothèque, sans attendre la pulsation suivante. L'absence de frappe ne fait donc pas considérer le lien perdu (#42). --heartbeat-secs doit être supérieur à 0 ; les valeurs invalides sont rejetées à l'analyse des arguments.
Contre-pression
exchange maintient pour chaque connexion une file d'envoi bornée à 512 messages.
Une file pleine indique que le pair ne consomme plus assez vite, généralement un réseau bloqué ou un client suspendu dans une session PTY. La réponse est de fermer volontairement la connexion : un tampon illimité transformerait un seul client bloqué en fuite mémoire du service entier. 512 messages constituent une marge importante pour un PTY interactif (normalement quelques messages seulement) ; atteindre ce plafond indique pratiquement l'arrêt de la consommation.
Toute livraison utilise try_send non bloquant. Le routage détient le verrou de la table de connexions : un await lierait sa durée de possession à la vitesse du pair et un client lent bloquerait toute la table.
Deux échecs de livraison sont distingués :
| Résultat | Signification | Traitement |
|---|---|---|
Lagging |
File pleine | Signal de fermeture indépendant, nettoyage des sessions de la connexion ; notification d'échec à l'autre côté au mieux |
Closed |
Récepteur libéré | Fin normale ; journal debug uniquement |
Le signal de fermeture ne traverse pas la file d'envoi : le nettoyage reste possible si elle est pleine ou l'écriture bloquée. Le remplacement après reconnexion et la rotation des jetons utilisent le même mécanisme.
agent possède aussi une file sortante de 256 messages. Sa saturation indique que le réseau n'envoie plus rien ; accumuler davantage est inutile, il faut reconnecter. Si la file d'entrée PTY (64) est pleine, les frappes sont abandonnées : accumuler ces petites données interactives n'a pas de sens.
Contrôle de l'écran Android
Défini dans crates/proto/src/android.rs, il réutilise routage à deux espaces, authentification, limites et audit. request dans open_android / android_req est un objet JSON distingué par action :
action |
Autres champs | Comportement |
|---|---|---|
status |
Aucun | Autorisations locales et version SDK ; l'interrupteur écran peut rester désactivé, mais le service distant doit fonctionner |
tree |
Aucun | Arbre des nœuds accessibles de l'application active |
screenshot |
Aucun | Capture JPEG unique |
tap |
x, y: u32 |
Appui aux coordonnées physiques de l'écran |
swipe |
x1, y1, x2, y2, duration_ms: u32 |
Balayage de 50–5000 ms |
swipe_path |
points: [{x, y}] (2–64 points), duration_ms: u32 |
Balayage polygonal : un seul appui traverse tous les points, interpolation uniforme selon la longueur, durée 50–5000 ms |
global |
key: string |
back / home / recents / notifications / quick_settings |
input_text |
text: string |
Remplace le champ éditable focalisé, maximum 8192 octets UTF-8, sans NUL |
AndroidResult est distingué par kind, avec tous les champs obligatoires :
kind |
Champs |
|---|---|
status |
controls_enabled, accessibility_enabled, screen_capture_enabled: bool ; sdk: u32 |
tree |
nodes: AndroidNode[] ; screen_width, screen_height: u32 ; truncated: bool |
screenshot |
mime_type: "image/jpeg" ; data_base64: string ; width, height, screen_width, screen_height: u32 |
ok |
Aucun ; saisie, geste ou action système terminé |
Les nœuds contiennent les chaînes class_name, package, view_id, text, description, bounds: [left, top, right, bottom] (coordonnées écran signées), et les booléens clickable, editable, focused, password. Maximum 200 nœuds et 256 unités UTF-16 par chaîne ; truncated indique uniquement la limite de nœuds. Les valeurs et descriptions de mots de passe restent vides ; le sous-arbre de configuration d'agent est omis.
Le grand côté d'une capture est limité à 1600 pixels ; width/height indique l'image, screen_width/screen_height la taille avant redimensionnement. Les gestes utilisent cette dernière. Le JSON du pont Java est limité à 2 MiB, avec 1024 octets supplémentaires autorisés pour l'enveloppe WebSocket. Aucun nouveau type de trame binaire.
{"type":"open_android","session_id":1,"device_id":"phone","request":{"action":"input_text","text":"Hello\nWorld"}}
{"type":"android_req","session_id":31,"request":{"action":"input_text","text":"Hello\nWorld"}}
{"type":"android_resp","session_id":31,"result":{"kind":"ok"}}
{"type":"android_resp","session_id":1,"result":{"kind":"ok"}}
screen_capture_enabled indique une autorisation de capture disponible (MediaProjection ou accessibilité Android 11+). Toute opération réelle exige toujours controls_enabled. Autorisations insuffisantes, fenêtre inaccessible ou échec produisent session_error côté agent, traduit en session_failed par Exchange. Un agent non Android renvoie une erreur explicite de non-prise en charge.
Agent traite au plus 4 requêtes Android simultanées et encode une seule capture à la fois. Attente Java : 10 secondes maximum (5 pour une image écran) ; délai agent : 15 secondes ; CLI : 20 secondes. À l'annulation ou déconnexion de l'opérateur, Exchange envoie android_cancel ; agent ferme le socket local et l'APK annule les travaux en attente. Les gestes ou modifications de texte déjà exécutés ne sont pas annulés.
L'action d'audit est android ; detail conserve action et coordonnées. input_text conserve seulement characters (nombre de caractères Unicode), et un succès seulement outcome: "completed". Le texte, l'arbre d'interface et les images ne sont pas audités. Les comptes readonly ne peuvent même pas demander status, tree ou une capture.
Extension des sessions de fichiers
Un Agent compatible fichiers déclare files. Exchange traduit open_file {session_id, device_id, request} en file_req {session_id, request} ; request.action vaut list, download ou upload. Les champs sont définis dans crates/proto/src/files.rs.
Les contrôles bidirectionnels utilisent JSON : le contrôleur envoie file_input {session_id, input}, l'appareil répond file_event {session_id, event}. input.kind vaut chunk {offset, data}, ack {offset} ou finish ; event.kind vaut ready {size}, chunk {offset, data}, progress {received}, complete {size} ou list {path, entries, truncated}. Le contenu utilise Base64 standard, avec des blocs de 64 KiB maximum ; l'émetteur attend confirmation avant de poursuivre.
Le premier ready d'un téléchargement exige ack {offset: 0}, puis chaque bloc est confirmé par le total cumulé d'octets. Un envoi commence par ready, chaque progress déclenche le bloc suivant, puis finish valide l'ensemble. complete et list terminent la session ; les erreurs utilisent session_error / session_failed. close_session transmet file_cancel ; annulation et déconnexion retirent la route. Les messages fichiers ne passent que sur des sessions fichiers, préservant la séparation des identifiants opérateur et appareil.
La version reste 1 ; les nouveaux messages exigent une mise à niveau conjointe d'Exchange et Agent. Limites et interface LAN : Transfert de fichiers et connexion LAN directe.
Extensions multiplateformes
La version reste 1. info d'enregistrement ajoute les champs facultatifs platform (openwrt / linux / freebsd / netbsd / windows / android / macos), os_version, capabilities (exec / pty / ubus / android_control / files / sensors / net). OpenWrt transmet openwrt_version.
metrics.unavailable est un tableau facultatif de chaînes indiquant les mesures indisponibles parmi uptime, load, memory, cpu, interfaces. Son absence suit l'ancien format ; les valeurs associées aux éléments listés servent uniquement de substituts compatibles, et les clients doivent afficher leur absence. Windows ne fournit pas de charge moyenne Unix ; Android peut limiter l'accès à certaines sources /proc.
Les commandes sont interprétées par le shell cible : /bin/sh sur Linux/OpenWrt/macOS, PowerShell sur Windows, /system/bin/sh sur Android. Les trames PTY et contrôles ont le même format sur les trois systèmes ; Windows utilise ConPTY. Un appareil non OpenWrt répond à ubus par session_error.
Sessions scellées de bout en bout
La version reste 1. Nouveaux messages : OperatorUp open_sealed {session_id, device_id, hello}, sealed_input {session_id, packet} ; DeviceDown sealed_open {session_id, hello}, sealed_input, sealed_close {session_id, detach} ; DeviceUp / OperatorDown sealed_accept {session_id, accept}, sealed_event {session_id, packet, last}. Nouveau type binaire 4 = Sealed (charge utile counter ‖ AEAD(inner_kind ‖ bytes)), DeviceInfo.seal_key et capacité sealed.
Pour les sessions scellées, Exchange assure uniquement traduction des identifiants, limites de concurrence et routage. Il n'analyse ni n'audite la charge utile (l'audit conserve seulement clés publiques, niveau et échéance d'autorisation, résultat final), et ne décide pas des droits (vérifie seulement que le compte voit l'appareil). last:true libère la route. Une route scellée n'accepte que Sealed ; une route ordinaire refuse Sealed. agent ignore tout texte en clair envoyé à un identifiant de session scellée. Autorisation, négociation, dérivation de clés, modèle de menace : Sessions scellées de bout en bout.