Protocolo de comunicación
Este documento especifica por completo el formato de comunicación entre agent, exchange y cli. Las definiciones están en crates/proto y los tres componentes comparten el mismo código: cada campo se añade o elimina en un único lugar y las discrepancias aparecen durante la compilación, antes de una sesión en producción.
Está dirigido a operadores que inspeccionan paquetes para diagnosticar problemas y a desarrolladores que modifican el protocolo o escriben clientes de terceros.
Índice
- Descripción general
- Transporte y endpoints
- Versión del protocolo
- Mensajes de control
- Tramas de datos binarias
- Traducción entre dos espacios de identificadores de sesión
- Secuencia del handshake
- Tratamiento de errores y clasificación de fallos
- Mantenimiento de conexión y tiempos de espera
- Contrapresión
- Control de pantalla de Android
- Sesiones selladas de extremo a extremo
Descripción general
El protocolo tiene dos capas que comparten una conexión WebSocket:
| Capa | Transporte | Codificación | Contenido |
|---|---|---|---|
| Mensajes de control | Tramas WebSocket de texto | JSON | Registro, latidos, apertura/cierre de sesiones y notificación de resultados |
| Tramas de datos | Tramas WebSocket binarias | Cabecera fija de 9 bytes + bytes sin procesar | Flujo de bytes PTY y stdout/stderr de comandos |
La separación evita el crecimiento aproximado del 33 % que supone introducir datos de flujo en JSON mediante base64. Los PTY generan pequeños paquetes frecuentes al reflejar cada pulsación, por lo que ese coste aumenta directamente la latencia interactiva. Los mensajes de control son poco frecuentes y se benefician más de la legibilidad de JSON: permite entender capturas de paquetes.
Por convención, una trama WebSocket binaria contiene exactamente una trama de datos: tanto los bytes sobrantes como los ausentes se consideran errores.
Transporte y endpoints
| Endpoint | Cliente | Autenticación |
|---|---|---|
GET /ws/device |
agent | La primera trama tras conectar debe ser register, con device_id y token |
GET /ws/operator |
remote-cli | Cabecera HTTP Authorization: Bearer <运维令牌> en el handshake WebSocket |
Las dos rutas usan credenciales distintas y no intercambiables. Un token de dispositivo no sirve para el endpoint de operadores, ni viceversa.
Las credenciales de operador van en una cabecera y no en la consulta de la URL, porque esta puede aparecer en registros del proxy inverso, historial del navegador y Referer. El token del dispositivo va en la primera trama porque algunos clientes integrados dificultan personalizar las cabeceras del handshake WebSocket. Además, este mecanismo permite comunicar tanto una incompatibilidad de versión como un token incorrecto mediante la misma respuesta.
En producción se debe usar wss://: ambas credenciales aparecen en texto sin cifrar durante el handshake.
Versión del protocolo
pub const PROTOCOL_VERSION: u16 = 1;
Las versiones se comparan en ambos sentidos durante el handshake. Una discrepancia provoca un rechazo inmediato con un mensaje claro, evitando que una incompatibilidad se manifieste después como un fallo inexplicable al analizar algún mensaje, difícil de diagnosticar.
- Dispositivos: agent incluye su versión en
register; si no coincide, el servidor responde conregister_ackyok: false, explica ambas versiones enerrory cierra la conexión. - Operadores: el servidor envía
welcomeal conectar. Si la versión no coincide, CLI devuelve un error e indica que debe actualizarse a una versión compatible conserver_version.
Todo type de mensaje desconocido se rechaza; nunca se interpreta silenciosamente como un tipo conocido.
Mensajes de control
Todos los mensajes de control son objetos JSON etiquetados con type (enumeraciones con etiquetado interno de serde). Las etiquetas y los nombres de campo usan snake_case. session_id se serializa de forma transparente como un número sin envoltura.
DeviceUp (agent → exchange)
type |
Campos | Finalidad |
|---|---|---|
register |
protocol_version: u16device_id: stringtoken: stringinfo: DeviceInfo |
Debe ser el primer mensaje tras conectar. El servidor no acepta ningún otro antes de recibirlo y concede 15 segundos |
heartbeat |
metrics: Metrics |
Informe periódico de métricas de aplicación. La disponibilidad del enlace corresponde al ping/pong nativo de WebSocket y es independiente: perder un latido no implica que el enlace haya caído, ni viceversa |
exec_started |
session_idjob_idholdtimeout_secs |
El comando ha comenzado; job_id se usa en la lista hold y para volver a adjuntarse tras reconectar |
exec_exit |
session_idcode: i32 | nulltimed_out: bool |
Comando terminado. code es null si el proceso terminó por una señal; timed_out indica terminación forzada por tiempo de espera |
exec_list_resp |
session_idjobs: ExecHoldInfo[] |
Lista de trabajos hold |
ubus_resp |
session_idcode: i32data: any |
Resultado ubus. code es el código de retorno de ubus; 0 indica éxito |
android_resp |
session_idresult: AndroidResult |
Operación de pantalla Android completada; termina la sesión |
pty_closed |
session_idcode: i32 | null |
PTY terminado; code es el código de salida del shell |
session_error |
session_idmessage: string |
Error de sesión. El dispositivo responde así cuando no puede atender una solicitud, en lugar de descartarla silenciosamente |
Un segundo register en una conexión ya registrada se considera uso incorrecto del protocolo y genera una advertencia en el servidor.
Ejemplo:
{"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 |
Campos | Finalidad |
|---|---|---|
register_ack |
ok: boolerror: string | nullheartbeat_interval_secs: u64 |
Resultado del registro. Con ok: false, error explica la causa y el servidor cierra la conexión. El servidor fija el intervalo de latidos para ampliarlo centralizadamente al crecer la flota, sin reconfigurar cada dispositivo. agent limita 0 a un mínimo de 1 segundo para evitar que el temporizador sature la CPU |
device_access |
managers: DeviceManager[]updated_at: u64 (milisegundos) |
Indica quién tiene acceso al dispositivo. DeviceManager = operator_id, username, email?, role, level (manage/view), source (role_default/grant). Se envía tras el registro y al cambiar permisos, solo a agent con capacidad device_access; véase docs/permissions.md del repositorio |
exec_req |
session_idcommand: stringtimeout_secs: u64hold: bool (por defecto true)hold_timeout_secs?: u64 |
Ejecuta un comando. Con hold=true (predeterminado), el proceso puede continuar sin el operador; el tiempo límite es hold_timeout_secs (ausente/0 → 3600, máximo absoluto 86400). hold=false cancela el comando al desconectarse el operador |
exec_cancel |
session_id |
Termina anticipadamente (kill) un comando |
exec_detach |
session_id |
Deja de transmitir salida al operador actual, manteniendo el proceso hold |
exec_list_req |
session_id |
Consulta la lista actual de trabajos hold |
exec_attach_req |
session_idjob_id |
Vuelve a adjuntar un trabajo hold a la sesión y reproduce la salida almacenada |
exec_hold_kill |
session_idjob_id |
Termina un trabajo hold por job_id |
exec_hold_tail |
session_idjob_id |
Sigue solo la salida nueva, sin reproducir el búfer |
exec_hold_cat |
session_idjob_id |
Vuelca el búfer actual y termina con exec_hold_cat_done |
ubus_req |
session_idobject: stringmethod: stringparams: any |
Inicia una llamada ubus. Si params es null, no pasa el tercer argumento posicional a ubus |
pty_open |
session_idrows: u16cols: u16 |
Abre un PTY. Las dimensiones 0 se sustituyen por 24×80 |
pty_resize |
session_idrows: u16cols: u16 |
Cambio de dimensiones del terminal |
pty_close |
session_id |
Cierra una sesión PTY |
android_req |
session_idrequest: AndroidAction |
Operación individual de pantalla Android |
android_cancel |
session_id |
Cancela una solicitud Android pendiente |
OperatorUp (cli → exchange)
La autenticación se completa durante el handshake WebSocket, por lo que no hay mensajes de inicio de sesión.
type |
Campos | Finalidad |
|---|---|---|
open_exec |
session_iddevice_idcommandtimeout_secshold (por defecto true)hold_timeout_secs? |
Ejecuta un comando en el dispositivo. hold está activado por defecto; hold=true y tiempo límite 0 → 3600s. hold=false y tiempo límite 0 → valor predeterminado de Exchange de 60s |
list_exec_holds |
session_iddevice_id |
Consulta trabajos exec y descargas, cargas y consultas de directorio en curso |
list_device_sessions |
session_iddevice_id |
Consulta la tabla de rutas de Exchange sin ocupar un espacio de sesión del dispositivo |
kill_device_session |
session_iddevice_idtarget_session_id |
Cancela por el target_session_id del dispositivo y libera localmente la ruta; incluye exec hold y no ocupa espacios adicionales |
attach_exec |
session_iddevice_idjob_id |
Se adjunta a un trabajo hold y sigue recibiendo salida |
exec_hold_kill / exec_hold_tail / exec_hold_cat |
session_iddevice_idjob_id |
kill / tail / cat de trabajos hold |
detach_session |
session_id |
Termina la espera en primer plano y se desadjunta sin matar el proceso hold |
open_ubus |
session_iddevice_id: stringobject: stringmethod: stringparams: any |
Inicia una llamada ubus |
open_pty |
session_iddevice_id: stringrows: u16cols: u16 |
Abre una sesión PTY interactiva |
open_android |
session_iddevice_id: stringrequest: AndroidAction |
Inicia una operación de pantalla Android; las cuentas de solo lectura no pueden hacerlo |
pty_resize |
session_idrows: u16cols: u16 |
Cambio de dimensiones del terminal. No incluye device_id: la sesión ya existe y el servidor puede localizar el dispositivo |
close_session |
session_id |
Termina explícitamente la sesión: salida normal del PTY o interrupción de un comando por el usuario |
El session_id de los cuatro mensajes open_* lo asigna el propio CLI (véase Traducción entre dos espacios de identificadores de sesión), de modo que puede enviar tramas posteriores inmediatamente, sin esperar confirmación del identificador.
OperatorDown (exchange → cli)
type |
Campos | Finalidad |
|---|---|---|
welcome |
protocol_version: u16server_version: string |
El servidor lo envía al conectar para confirmar compatibilidad de versión |
device_sessions |
session_idsessions: DeviceSessionInfo[]limitcount |
Lista actual de sesiones del dispositivo en Exchange |
device_session_killed |
session_idtarget_session_idreleased: bool |
Ruta liberada localmente; false si no existía |
exec_exit |
session_idcode: i32 | nulltimed_out: bool |
Comando terminado, traducido desde el mensaje homónimo del dispositivo |
ubus_resp |
session_idcode: i32data: any |
Resultado de llamada ubus |
android_resp |
session_idresult: AndroidResult |
Resultado Android con identificador traducido al espacio del operador |
pty_closed |
session_idcode: i32 | null |
PTY terminado |
session_failed |
session_idreason: SessionFailuremessage: string |
Fallo de sesión. reason distingue causas para que CLI decida si reintentar |
Estructuras de datos compartidas
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. Por compatibilidad, kind ausente equivale a exec y age_secs a 0; la lista calcula la antigüedad con el reloj actual. El resumen del comando o ruta tiene un máximo aproximado de 120 caracteres. Los trabajos que no son exec solo admiten kill, no tail/cat/attach.
DeviceSessionInfo: session_id: u32 (identificador del dispositivo), operator_session_id: u32, kind: string, hold: bool, age_secs: u64, deadline_unix_ms: u64. Exchange responde directamente a la lista de sesiones y cancelación forzada sin abrir sesiones nuevas en el dispositivo; las cuentas de solo lectura reciben forbidden.
DeviceInfo
Información estática enviada una vez en register y almacenada en devices.info. Todos los campos salvo agent_version son opcionales: fuera de OpenWrt (equipos de desarrollo o contenedores), la mayoría de fuentes no existen y agent debe registrarse normalmente en lugar de fallar al arrancar. Los campos ausentes se omiten al serializar.
| Campo | Tipo | Fuente |
|---|---|---|
hostname |
string? |
/proc/sys/kernel/hostname |
model |
string? |
DISTRIB_TARGET de /etc/openwrt_release; si no se puede leer, usa model de ubus call system board |
openwrt_version |
string? |
DISTRIB_DESCRIPTION o release.description de ubus |
kernel |
string? |
/proc/sys/kernel/osrelease |
arch |
string? |
Constante de compilación, más fiable que detectarla en ejecución |
agent_version |
string |
Versión de agent para diagnosticar incompatibilidades |
Metrics
Se recopilan en cada ciclo de latidos, solo de fuentes legibles directamente en /proc, sin crear procesos secundarios. Un fallo de lectura individual no invalida el informe: perder una métrica es preferible a perder todo el latido y considerar desconectado al dispositivo.
| Campo | Tipo | Descripción |
|---|---|---|
uptime_secs |
u64 |
/proc/uptime |
load |
[f32; 3] |
Carga media de 1/5/15 minutos de /proc/loadavg |
mem_total_kb |
u64 |
/proc/meminfo |
mem_available_kb |
u64 |
Usa MemAvailable en lugar de MemFree: este excluye la caché recuperable, subestima mucho la memoria disponible en routers y genera falsas alertas |
cpu_usage_pct |
f32? |
Diferencia entre dos muestras de /proc/stat; se omite en el primer latido, sin muestra de referencia |
interfaces |
InterfaceStats[] |
/proc/net/dev. Informa bytes enviados y recibidos acumulados, no tasas, para que el servidor calcule tasas sobre cualquier ventana temporal. Se omite si el array está vacío |
InterfaceStats: { name: string, rx_bytes: u64, tx_bytes: u64 }
Tramas de datos binarias
0 1 5 9
+--------+--------------------+--------------------+
| kind | session_id | payload_len | payload (payload_len 字节)
| u8 | u32 (大端) | u32 (大端) |
+--------+--------------------+--------------------+
└──────────── 9 字节定长头 ────────────┘
| Desplazamiento | Longitud | Campo | Descripción |
|---|---|---|---|
| 0 | 1 | kind |
Tipo de flujo; véase la tabla siguiente |
| 1 | 4 | session_id |
u32 big-endian. 0 se reserva como centinela sin sesión |
| 5 | 4 | payload_len |
u32 big-endian; longitud de carga en bytes, máximo 1 MiB |
| 9 | payload_len |
payload |
Bytes sin procesar, sin codificación ni escape |
Tipos de flujo
| Valor | Nombre | Dirección | Descripción |
|---|---|---|---|
| 1 | ExecStdout |
Dispositivo → operador | Salida estándar del comando remoto |
| 2 | ExecStderr |
Dispositivo → operador | Error estándar, separado de stdout para presentarlo de forma distinta en CLI, por ejemplo en rojo |
| 3 | PtyData |
Bidireccional | Flujo PTY: entrada de teclado hacia el dispositivo y salida de pantalla hacia el operador; el mismo tipo en ambos sentidos |
Si agent recibe ExecStdout / ExecStderr hacia el dispositivo, registra una advertencia y lo descarta: indica un error de implementación del otro extremo.
Notas de diseño
payload_len es redundante con la longitud de WebSocket. Se mantiene por dos motivos: detectar truncamientos al decodificar (declarar 11 bytes y enviar solo 5 debe rechazarse, no aceptarse silenciosamente como paquete corto), y permitir una futura migración a TCP sin modificar el formato.
El límite de 1 MiB no está pensado para el tráfico normal: PTY y salida de comandos se transmiten incrementalmente y no alcanzan ese tamaño. Impide que una cabecera que declare 2 GiB agote la memoria del servidor.
Una carga vacía es válida: los PTY pueden producir lecturas de cero bytes. La trama contiene entonces solo la cabecera de 9 bytes.
La carga puede contener cualquier byte, incluidas secuencias no UTF-8, y debe conservarse sin cambios.
Errores de decodificación
| Error | Condición |
|---|---|
FrameTooShort { len } |
Longitud total inferior a 9 bytes |
UnknownStreamKind(v) |
El primer byte no es 1/2/3 |
PayloadTooLarge { len } |
El payload_len declarado supera 1 MiB |
LengthMismatch { declared, actual } |
La longitud declarada no coincide con los bytes restantes reales |
Un fallo de decodificación solo afecta a esa trama: exchange registra una advertencia y la descarta sin cerrar la conexión.
Traducción entre dos espacios de identificadores de sesión
Esta es la parte del protocolo que más fácilmente se malinterpreta.
En una frase
session_id lo asigna quien inicia la sesión y solo es único dentro de su propia conexión. Operadores y dispositivos tienen espacios independientes y exchange los traduce. Una misma sesión lógica tiene dos identificadores distintos.
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…)
Motivos del diseño
Evitar un viaje de ida y vuelta. Si el servidor asignara todos los identificadores, CLI tendría que esperar la respuesta «tu sesión es 31» tras abrir PTY antes de enviar entrada de teclado. Al asignarlo CLI, puede enviar tramas inmediatamente después de open_pty.
Evitar colisiones entre operadores. Dos operadores pueden usar simultáneamente el identificador local 1 sobre el mismo dispositivo. Este debe distinguirlos para que la salida de uno no llegue al otro. exchange asigna identificadores por conexión de dispositivo y traduce ambos session 1 a 31 y 32 en el dispositivo.
Independizar los identificadores del dispositivo del comportamiento del operador. exchange los controla por completo; ningún operador puede elegirlos ni adivinarlos.
Dos tablas
exchange mantiene una tabla por conexión y actualiza ambas siempre en pareja:
| Ubicación | Tabla | Clave → valor |
|---|---|---|
DeviceHandle.sessions |
Tabla directa del dispositivo | Identificador del dispositivo → (canal de envío del operador, identificador del operador, ID de cuenta del operador, tipo de sesión, ID de fila de auditoría) |
OperatorHandle.sessions |
Índice inverso del operador | Identificador del operador → (ID del dispositivo, identificador de sesión del dispositivo) |
El índice inverso permite limpiar: cuando un operador se desconecta, hay que localizar y terminar las sesiones que dejó en todos los dispositivos, evitando procesos y PTY cuya salida nadie recibe.
Dónde se traduce
| Dirección | Mensaje | Traducción |
|---|---|---|
Operador → dispositivo (open_*) |
Mensaje de control | Asigna un identificador nuevo en el espacio del dispositivo, lo registra en ambas tablas y lo usa en el mensaje al dispositivo |
Operador → dispositivo (pty_resize / close_session) |
Mensaje de control | Busca el identificador del dispositivo en el índice inverso, lo sustituye y reenvía |
| Operador → dispositivo | Trama de datos | Busca el identificador del dispositivo en el índice inverso y sobrescribe los 4 bytes de cabecera sin tocar la carga |
| Dispositivo → operador | Mensaje de control | Busca identificador y conexión de destino del operador en la tabla directa, lo sustituye y entrega el mensaje |
| Dispositivo → operador | Trama de datos | Busca el identificador del operador en la tabla directa y sobrescribe la cabecera sin tocar la carga |
La traducción es la única modificación de una trama: exchange no analiza, almacena ni modifica ningún byte de carga. DataFrame::with_session_id sustituye el identificador in situ sin reasignar la carga.
Reglas de asignación
Ambos asignadores comienzan en 1 e incrementan saltándose el 0, reservado como centinela sin sesión, por ejemplo en errores de conexión.
- CLI (
SessionIdGenerator): al desbordarse vuelve al inicio y salta el 0. Agotar cuatro mil millones de sesiones en una conexión es poco realista, pero volver al inicio encaja mejor con procesos persistentes que provocar panic. - Dispositivo (
DeviceHandle::open_session): si al volver al inicio encuentra una sesión antigua activa, sigue buscando un identificador libre.
exchange rechaza el 0 y la reutilización de identificadores activos por operadores. La validez de conexión del dispositivo, el límite de concurrencia y el registro en ambos índices se comprueban en la misma sección crítica para impedir superar el límite al abrir varias conexiones de operador a la vez.
Comportamiento si no existe una ruta
Descarte silencioso, sin error. Es normal que haya una carrera cuando una sesión acaba de cerrarse y el otro extremo aún envía datos finales. La regla vale en ambos sentidos:
- Dispositivo responde a una sesión inexistente → registro debug; quizá el operador se desconectó antes.
- Operador envía datos de una sesión sin propietario → registro trace y descarte.
Rutas de limpieza de sesiones
| Desencadenante | Limpieza |
|---|---|
Dispositivo termina normalmente (exec_exit / ubus_resp / android_resp / pty_closed / session_error) |
Elimina la ruta directa y el índice inverso de la conexión, completa la auditoría y entrega al operador el mensaje traducido |
close_session del operador |
Elimina ambos índices, pide a la conexión original del dispositivo que termine, devuelve inmediatamente confirmación al operador y audita la cancelación. Cerrar de nuevo no hace nada |
| Desconexión del dispositivo | Retira todas sus sesiones, envía session_failed / device_disconnected a cada operador y completa auditoría con {"outcome":"device_disconnected"} |
| Desconexión del operador | Recorre el índice inverso para localizar sus sesiones en cada dispositivo y ordena terminar las no hold; para exec hold solo envía exec_detach, sin matar el proceso. Solo limpia sus propias sesiones; no afecta a otros operadores del mismo dispositivo |
| Reconexión del dispositivo sustituye la antigua | Invalida todas las sesiones antiguas y notifica device_disconnected a cada operador |
El índice inverso se liga a una conexión concreta del dispositivo y no se redirige por ID a la nueva conexión tras reconectar. Cada conexión de operador, incluso de la misma cuenta, tiene su propio índice: desconectar una no cancela sesiones de otra.
Confirmaciones de cancelación explícita: exec devuelve exec_exit (code: null, timed_out: false); PTY devuelve pty_closed (code: null); ubus devuelve session_failed (reason: device_error). Confirman que exchange cerró la sesión, sin demostrar que el proceso remoto terminara; se descartan los datos y mensajes de salida posteriores del dispositivo.
Al alcanzar el límite de concurrencia, Exchange recupera primero, bajo el mismo bloqueo, rutas sin operador o fuera de plazo y vuelve a comprobar el límite. El plazo exec es el límite real enviado más 15 segundos de margen; el de archivos, 3600 segundos más 15; los demás, 24 horas. Un exec hold dentro de plazo sobrevive a la marcha del operador. La recuperación y kill_device_session envían cancelación al dispositivo y eliminan ambos índices inmediatamente sin esperar respuesta; los operadores disponibles reciben exec_exit con timed_out:true para exec o session_failed con device_error y session reclaimed para otros tipos. La auditoría se completa asíncronamente en la medida de lo posible.
Reconexión de dispositivos y epoch
Cada conexión de dispositivo recibe un epoch (UUID) aleatorio al registrarse. Al reconectar el mismo dispositivo, la nueva conexión sustituye a la anterior: no se rechaza, porque no se distingue con fiabilidad una conexión duplicada de una antigua muerta que el servidor aún no detectó (conexión semiabierta). Rechazarla impediría reconectar hasta que expirara la antigua.
La limpieza antigua puede ejecutarse después del registro nuevo. Al desregistrar debe comprobarse epoch y eliminar solo si el epoch actual de la tabla coincide con el propio. Sin esta comprobación, la limpieza antigua marcaría inmediatamente como desconectado al dispositivo recién conectado.
Secuencia del handshake
Dispositivo
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) │
│────────────────────────────────────────────────▶│
Rutas de fallo:
- Primera trama no textual, JSON inválido o mensaje distinto de
register→ registra advertencia y cierra directamente, sin ack. - No se envía
registeren 15 segundos → cierra la conexión. - Versiones incompatibles → responde
register_ack{ok:false, error:"协议版本不匹配:设备为 X,服务端为 Y"}y cierra. - Token inválido / dispositivo inexistente / dispositivo desactivado → responde siempre
"设备 ID 或令牌无效"y cierra. No distingue causas, para no convertir el inventario en información enumerable.
agent trata estas dos categorías de fallo por separado:
| Categoría en agent | Desencadenante | Reconexión |
|---|---|---|
Rejected |
Recibe ack con ok: false |
Espera fija de 300 segundos. Reintentar no corrige tokens: consume recursos de ambos extremos y llena los registros del servidor. La espera deja tiempo para corregir configuración |
Transport |
Fallo de conexión, timeout de handshake (45 segundos), respuesta mal formada o interrupción | Espera exponencial: empieza en 1 segundo, se duplica hasta 300 y se multiplica por una variación aleatoria en [0.5, 1.5) |
La espera solo se reinicia tras un registro correcto, no al establecer TCP. De lo contrario, conectar y ser rechazado inmediatamente causaría reintentos intensivos cada pocos milisegundos.
La variación aleatoria es necesaria. Tras reiniciar el servidor, cientos o miles de dispositivos detectan la caída a la vez. Si reconectaran con la misma curva determinista, seguirían sincronizados y golpearían por oleadas al servidor recién iniciado, convirtiendo un reinicio en una avalancha persistente.
Operador
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 puede enviar las tramas de datos del segundo paso sin esperar ninguna confirmación: esa es la finalidad de que el iniciador asigne el identificador.
Tratamiento de errores y clasificación de fallos
SessionFailure
Aparece en reason de session_failed; sus valores JSON usan snake_case.
| Valor | Significado | Origen | Reintentable |
|---|---|---|---|
device_offline |
Dispositivo inexistente o desconectado | exchange no encuentra el dispositivo en la tabla de conexiones durante open_* |
✅ |
device_disconnected |
El dispositivo cayó durante la sesión | Desconexión, sustitución por otra conexión o cola de envío cerrada al entregar la solicitud | ✅ |
device_error |
Rechazo o fallo de ejecución del dispositivo | agent responde session_error (fallo al crear PTY, iniciar comando o llamar ubus), o el otro extremo consume demasiado despacio al reenviar |
❌ |
rate_limited |
Se supera el límite de sesiones concurrentes | exchange detecta durante open_* que las sesiones activas alcanzan --max-sessions-per-device (32 por defecto) |
✅ |
forbidden |
Credenciales sin permiso para el dispositivo | Una cuenta de solo lectura (role = readonly) intenta iniciar cualquier sesión |
❌ |
is_retryable() excluye deliberadamente forbidden y device_error: reintentar permisos o fallos internos del dispositivo no tiene sentido y solo mantiene CLI ocupado.
CLI añade al message original una indicación práctica. Por ejemplo, device_offline sugiere comprobar el estado con remote-cli device list. device_error es la excepción: el mensaje del dispositivo ya es la explicación más precisa y no conviene diluirlo con texto genérico.
Semántica de fallos por capas
| Capa | Comportamiento | Alcance |
|---|---|---|
| Fallo al decodificar trama | Registra y descarta la trama | Una trama |
| Fallo al analizar control | Registra advertencia e ignora el mensaje | Un mensaje; la conexión continúa |
| Fallo de sesión | session_failed / session_error |
Una sesión; la conexión continúa |
| Fallo de registro | Cierra después de register_ack{ok:false} |
Toda la conexión |
| Versión incompatible | Cierra tras error explícito | Toda la conexión |
| Retraso de cola | Cierra activamente | Toda la conexión |
El principio es mantener locales todos los fallos que puedan aislarse, ya que una conexión de dispositivo puede transportar sesiones de varios operadores simultáneamente.
Mantenimiento de conexión y tiempos de espera
| Mecanismo | Valor | Descripción |
|---|---|---|
| Ping WebSocket de exchange | Cada --heartbeat-secs (30 segundos por defecto) |
exchange lo envía a dispositivos y operadores. La biblioteca del cliente encola Pong automáticamente; agent debe hacer flush explícito del sink al recibir Ping para enviarlo a tiempo |
| Ping WebSocket de agent | Cada heartbeat_interval_secs (del ack del servidor) |
agent envía Ping nativo además de cada JSON heartbeat, evitando alcanzar el límite operativo de desconexión tras unos 100 segundos de inactividad de Cloudflare si se retrasa el Ping de exchange |
| Timeout de silencio | heartbeat × --missed-pings (por defecto 30 × 3 = 90 segundos) |
Si no llega ningún mensaje del dispositivo u operador durante ese plazo, se considera perdida la conexión y se cierra. Pong cuenta como actividad; --missed-pings igual a 0 se limita a esperar al menos un ciclo de latido |
| Timeout de escritura de exchange | 10 segundos | Una escritura WebSocket de datos o Ping fuera de plazo termina la conexión para impedir que una conexión lenta bloquee la limpieza |
| Latido de aplicación | Cada heartbeat_interval_secs (del ack del servidor) |
agent envía JSON heartbeat con Metrics y también Ping WebSocket. Métricas y ping/pong nativo tienen funciones distintas; los latidos de aplicación por sí solos no determinan el estado del enlace |
| Eventos de conexión/desconexión | tick_count de disparadores de notificación online / offline (3 por defecto, intervalo 1–100); difusión y auditoría usan alternativamente REMOTE_MISSED_THRESHOLD / REMOTE_ONLINE_THRESHOLD |
Se configuran dos tipos de disparador, en lugar de dos contadores en uno. offline notifica tras tick_count fallos consecutivos; online, tras tick_count éxitos consecutivos. device_ids de cada disparador admite un ID de dispositivo (cadena), varios (array), o ausencia / null para todos los dispositivos del espacio de trabajo. No repite notificaciones mientras siga en el estado anunciado; para notificar de nuevo debe rearmarse con tick_count resultados opuestos consecutivos. La difusión de presencia y auditoría internas del proceso no leen estos disparadores, sino REMOTE_MISSED_THRESHOLD / REMOTE_ONLINE_THRESHOLD (también 3 por defecto). La permanencia del socket en la tabla depende de --missed-pings, independientemente de este contador |
| Timeout de registro de dispositivo | 15 segundos | Debe enviar register dentro de este plazo tras conectar |
| Timeout del handshake de agent | 45 segundos por fase (HANDSHAKE_TIMEOUT) |
connect_device_ws (TCP, HTTP CONNECT opcional, TLS y handshake WebSocket) y la espera de register_ack tras enviar register tienen cada uno este plazo; no son 45 segundos para las dos fases juntas |
| Timeout de escritura / flush de Pong de agent | 30 segundos (WRITE_TIMEOUT) |
Cada send (registro, latido JSON, Ping y salida de sesión incluidos) y cada flush de Pong tras registrarse tiene su propio plazo. Un fallo o timeout cierra y reconecta. Durante el registro, el flush de Pong está sujeto al plazo de 45 segundos de espera de ack |
| Keepalive TCP de agent | Sondas tras 30 segundos inactivo | Tras conectar directamente o por HTTP CONNECT, activa TCP_NODELAY y TCP keepalive. Son ajustes TCP y no sustituyen Ping/Pong de WebSocket |
| Timeout de bienvenida de CLI | 15 segundos | Plazo para recibir welcome tras conectar |
| Timeout de comando | Determinado por timeout_secs de open_exec |
agent lo limita a un mínimo de 1 segundo. Al expirar envía SIGTERM al grupo de procesos completo y SIGKILL tras 3 segundos de gracia |
| Timeout ubus | 15 segundos, fijo en agent | ubus es RPC local; superar este plazo prácticamente indica que el servicio correspondiente está bloqueado |
El cliente de un shell inactivo sigue respondiendo Pong. agent hace flush explícito de respuestas encoladas automáticamente sin esperar al siguiente latido; la ausencia de teclado no provoca falsas desconexiones (#42). --heartbeat-secs debe ser mayor que 0; los valores inválidos se rechazan al analizar argumentos de inicio.
Contrapresión
exchange mantiene para cada conexión una cola de envío acotada de 512 mensajes.
Una cola llena significa que el otro extremo no consume al ritmo necesario; en PTY suele indicar una red bloqueada o cliente suspendido. Se cierra activamente la conexión: acumular sin límite convertiría un cliente bloqueado en una fuga de memoria de todo el servicio. 512 mensajes dejan margen amplio para PTY interactivo, cuya cola normalmente tiene una longitud de un solo dígito; alcanzar el límite indica casi con certeza que el receptor ya no consume.
Todos los envíos usan try_send no bloqueante. El enrutamiento mantiene bloqueada la tabla de conexiones; cualquier await vincularía la duración del bloqueo al consumo del otro extremo y un cliente lento bloquearía toda la tabla.
Se distinguen dos fallos de entrega:
| Resultado | Significado | Tratamiento |
|---|---|---|
Lagging |
Cola llena | Interrumpe la conexión con una señal de cierre independiente, limpia sus sesiones e intenta notificar el fallo al otro extremo |
Closed |
Receptor liberado | Fin normal de conexión; solo registra debug |
La señal de cierre no pasa por la cola y puede iniciar la limpieza incluso con la cola llena o escritura bloqueada. La sustitución por reconexión y la rotación de tokens usan el mismo mecanismo.
agent también tiene una cola de salida de capacidad 256. Llenarla significa que la red no transmite y seguir acumulando no tiene sentido: debe desconectar y reconectar. Si se llena la cola de entrada PTY (64), descarta pulsaciones directamente; acumular pequeños datos interactivos no aporta nada.
Control de pantalla de Android
Las definiciones están en crates/proto/src/android.rs y reutilizan rutas de sesión de dos espacios, autenticación, límites de concurrencia y auditoría. request de open_android / android_req es un objeto JSON discriminado por action:
action |
Otros campos | Comportamiento |
|---|---|---|
status |
Ninguno | Consulta permisos locales y versión SDK; no requiere activar el interruptor de pantalla, pero el servicio remoto debe ejecutarse |
tree |
Ninguno | Árbol de nodos de accesibilidad de la aplicación activa |
screenshot |
Ninguno | Captura JPEG individual |
tap |
x, y: u32 |
Toca coordenadas en píxeles de la pantalla original |
swipe |
x1, y1, x2, y2, duration_ms: u32 |
Deslizamiento de 50–5000 ms |
swipe_path |
points: [{x, y}] (2–64 puntos), duration_ms: u32 |
Deslizamiento por polilínea: una pulsación continua recorre todos los puntos con interpolación uniforme por longitud durante 50–5000 ms |
global |
key: string |
back / home / recents / notifications / quick_settings |
input_text |
text: string |
Sustituye el campo editable con foco; máximo 8192 bytes UTF-8, sin NUL |
AndroidResult se discrimina por kind; todos los campos son obligatorios:
kind |
Campos |
|---|---|
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 |
Ninguno; indica que terminó la entrada, gesto o acción del sistema |
Los nodos contienen las cadenas class_name, package, view_id, text, description, bounds: [left, top, right, bottom] (coordenadas de pantalla con signo) y los booleanos clickable, editable, focused, password. Hay como máximo 200 nodos y 256 unidades UTF-16 por cadena; truncated solo indica el límite de nodos. Valores y descripciones de contraseñas quedan vacíos y se omite el subárbol de configuración del propio agent.
El lado largo de las capturas mide como máximo 1600 píxeles. width/height son dimensiones de imagen; screen_width/screen_height, las anteriores al escalado. Los gestos usan coordenadas originales. El JSON del puente Java admite 2 MiB, más 1024 bytes para la envoltura WebSocket. No se añade ningún tipo de trama binaria.
{"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 indica una autorización de captura disponible (MediaProjection o accesibilidad en Android 11+); todas las operaciones reales de pantalla siguen requiriendo controls_enabled. Ante permisos insuficientes, ventanas inaccesibles o fallos, agent devuelve session_error, que Exchange convierte en session_failed. Los agent no Android devuelven un error explícito de operación no compatible.
Agent procesa como máximo 4 solicitudes Android simultáneas y codifica solo una captura a la vez. Las operaciones Java esperan hasta 10 segundos (hasta 5 por un fotograma); agent tiene timeout de 15 segundos y CLI de 20. Al cancelar o desconectarse el operador, Exchange envía android_cancel; agent cierra el socket local y APK cancela trabajos en cola aún no ejecutados. Los gestos y modificaciones de texto ya ejecutados no se revierten.
La acción de auditoría es android; detail guarda acción y coordenadas. input_text solo guarda characters (número de caracteres Unicode); los resultados correctos solo registran outcome: "completed". Texto, árbol de interfaz e imágenes no se auditan. Las cuentas readonly tampoco pueden iniciar status, tree ni capturas.
Extensión de sesiones de archivos
Los Agent compatibles declaran la capacidad files. Exchange traduce open_file {session_id, device_id, request} a file_req {session_id, request}; request.action es list, download o upload; los campos se definen en crates/proto/src/files.rs.
El control bidireccional usa JSON: el controlador envía file_input {session_id, input} y el dispositivo responde file_event {session_id, event}. input.kind es chunk {offset, data}, ack {offset} o finish; event.kind es ready {size}, chunk {offset, data}, progress {received}, complete {size} o list {path, entries, truncated}. Los bloques usan Base64 estándar, con máximo de 64 KiB; el emisor espera confirmación antes de continuar.
El primer ready de una descarga requiere ack {offset: 0}; luego cada bloque confirma los bytes acumulados. Una carga comienza con ready; cada progress impulsa el bloque siguiente y finish confirma la operación tras enviar todos. complete y list son mensajes finales; los errores usan session_error / session_failed. close_session envía file_cancel; cancelación y desconexión eliminan la ruta. Los mensajes de archivo solo se reenvían en sesiones de archivo, manteniendo separados los identificadores de operador y dispositivo.
La versión sigue siendo 1; los mensajes nuevos requieren actualizar conjuntamente Exchange y Agent. Las restricciones e interfaces LAN se describen en Transferencia de archivos y conexión directa por LAN.
Extensiones multiplataforma
La versión sigue siendo 1. El info de registro añade los opcionales platform (openwrt / linux / freebsd / netbsd / windows / android / macos), os_version, capabilities (exec / pty / ubus / android_control / files / sensors / net). OpenWrt comunica openwrt_version.
metrics.unavailable del latido es un array opcional de cadenas que enumera métricas uptime, load, memory, cpu, interfaces no recopilables. Su ausencia se interpreta como el formato antiguo; los valores de métricas enumeradas son marcadores de compatibilidad y el cliente debe mostrarlas como ausentes. Windows no ofrece carga media Unix y Android puede restringir fuentes /proc por permisos del sistema.
Los comandos se interpretan con el shell de destino: /bin/sh en Linux/OpenWrt/macOS, PowerShell en Windows y /system/bin/sh en Android. Las tramas y controles PTY son iguales en los tres sistemas; Windows usa ConPTY. Los dispositivos no OpenWrt responden session_error a solicitudes ubus.
Sesiones selladas de extremo a extremo
La versión sigue siendo 1. Mensajes nuevos: 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}. Se añade el tipo binario 4 = Sealed (carga counter ‖ AEAD(inner_kind ‖ bytes)), DeviceInfo.seal_key y capacidad sealed.
Para sesiones selladas, Exchange solo traduce identificadores, limita concurrencia y enruta: no analiza ni audita la carga (la auditoría solo guarda clave pública, nivel y vencimiento de autorización y resultado final), ni determina permisos (solo comprueba que la cuenta pueda ver el dispositivo). Libera la ruta con last:true. Las rutas selladas solo aceptan tramas Sealed; las normales no aceptan tramas Sealed. agent descarta cualquier texto sin cifrar dirigido a un identificador sellado. Autorización, handshake, derivación de claves, modelo de amenazas se describen en Sesiones selladas de extremo a extremo.