remote.DocumentaciónInicio

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

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 con register_ack y ok: false, explica ambas versiones en error y cierra la conexión.
  • Operadores: el servidor envía welcome al conectar. Si la versión no coincide, CLI devuelve un error e indica que debe actualizarse a una versión compatible con server_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: u16
device_id: string
token: string
info: 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_id
job_id
hold
timeout_secs
El comando ha comenzado; job_id se usa en la lista hold y para volver a adjuntarse tras reconectar
exec_exit session_id
code: i32 | null
timed_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_id
jobs: ExecHoldInfo[]
Lista de trabajos hold
ubus_resp session_id
code: i32
data: any
Resultado ubus. code es el código de retorno de ubus; 0 indica éxito
android_resp session_id
result: AndroidResult
Operación de pantalla Android completada; termina la sesión
pty_closed session_id
code: i32 | null
PTY terminado; code es el código de salida del shell
session_error session_id
message: 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: bool
error: string | null
heartbeat_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_id
command: string
timeout_secs: u64
hold: 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_id
job_id
Vuelve a adjuntar un trabajo hold a la sesión y reproduce la salida almacenada
exec_hold_kill session_id
job_id
Termina un trabajo hold por job_id
exec_hold_tail session_id
job_id
Sigue solo la salida nueva, sin reproducir el búfer
exec_hold_cat session_id
job_id
Vuelca el búfer actual y termina con exec_hold_cat_done
ubus_req session_id
object: string
method: string
params: any
Inicia una llamada ubus. Si params es null, no pasa el tercer argumento posicional a ubus
pty_open session_id
rows: u16
cols: u16
Abre un PTY. Las dimensiones 0 se sustituyen por 24×80
pty_resize session_id
rows: u16
cols: u16
Cambio de dimensiones del terminal
pty_close session_id Cierra una sesión PTY
android_req session_id
request: 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_id
device_id
command
timeout_secs
hold (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_id
device_id
Consulta trabajos exec y descargas, cargas y consultas de directorio en curso
list_device_sessions session_id
device_id
Consulta la tabla de rutas de Exchange sin ocupar un espacio de sesión del dispositivo
kill_device_session session_id
device_id
target_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_id
device_id
job_id
Se adjunta a un trabajo hold y sigue recibiendo salida
exec_hold_kill / exec_hold_tail / exec_hold_cat session_id
device_id
job_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_id
device_id: string
object: string
method: string
params: any
Inicia una llamada ubus
open_pty session_id
device_id: string
rows: u16
cols: u16
Abre una sesión PTY interactiva
open_android session_id
device_id: string
request: AndroidAction
Inicia una operación de pantalla Android; las cuentas de solo lectura no pueden hacerlo
pty_resize session_id
rows: u16
cols: 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: u16
server_version: string
El servidor lo envía al conectar para confirmar compatibilidad de versión
device_sessions session_id
sessions: DeviceSessionInfo[]
limit
count
Lista actual de sesiones del dispositivo en Exchange
device_session_killed session_id
target_session_id
released: bool
Ruta liberada localmente; false si no existía
exec_exit session_id
code: i32 | null
timed_out: bool
Comando terminado, traducido desde el mensaje homónimo del dispositivo
ubus_resp session_id
code: i32
data: any
Resultado de llamada ubus
android_resp session_id
result: AndroidResult
Resultado Android con identificador traducido al espacio del operador
pty_closed session_id
code: i32 | null
PTY terminado
session_failed session_id
reason: SessionFailure
message: 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 register en 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.