# Protocolo de comunicación

> HTML: https://remote.ivjn.us/docs/es/protocol/

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](#descripción-general)
- [Transporte y endpoints](#transporte-y-endpoints)
- [Versión del protocolo](#versión-del-protocolo)
- [Mensajes de control](#mensajes-de-control)
  - [DeviceUp (agent → exchange)](#deviceup-agent--exchange)
  - [DeviceDown (exchange → agent)](#devicedown-exchange--agent)
  - [OperatorUp (cli → exchange)](#operatorup-cli--exchange)
  - [OperatorDown (exchange → cli)](#operatordown-exchange--cli)
  - [Estructuras de datos compartidas](#estructuras-de-datos-compartidas)
- [Tramas de datos binarias](#tramas-de-datos-binarias)
- [Traducción entre dos espacios de identificadores de sesión](#traducción-entre-dos-espacios-de-identificadores-de-sesión)
- [Secuencia del handshake](#secuencia-del-handshake)
- [Tratamiento de errores y clasificación de fallos](#tratamiento-de-errores-y-clasificación-de-fallos)
- [Mantenimiento de conexión y tiempos de espera](#mantenimiento-de-conexión-y-tiempos-de-espera)
- [Contrapresión](#contrapresión)
- [Control de pantalla de Android](#control-de-pantalla-de-android)
- [Sesiones selladas de extremo a extremo](#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

```rust
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`<br>`device_id: string`<br>`token: string`<br>`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`<br>`job_id`<br>`hold`<br>`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`<br>`code: i32 \| null`<br>`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`<br>`jobs: ExecHoldInfo[]` | Lista de trabajos hold |
| `ubus_resp` | `session_id`<br>`code: i32`<br>`data: any` | Resultado ubus. `code` es el código de retorno de ubus; 0 indica éxito |
| `android_resp` | `session_id`<br>`result: AndroidResult` | Operación de pantalla Android completada; termina la sesión |
| `pty_closed` | `session_id`<br>`code: i32 \| null` | PTY terminado; `code` es el código de salida del shell |
| `session_error` | `session_id`<br>`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:

```json
{"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`<br>`error: string \| null`<br>`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[]`<br>`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`<br>`command: string`<br>`timeout_secs: u64`<br>`hold: bool` (por defecto `true`)<br>`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`<br>`job_id` | Vuelve a adjuntar un trabajo hold a la sesión y reproduce la salida almacenada |
| `exec_hold_kill` | `session_id`<br>`job_id` | Termina un trabajo hold por job_id |
| `exec_hold_tail` | `session_id`<br>`job_id` | Sigue solo la salida nueva, sin reproducir el búfer |
| `exec_hold_cat` | `session_id`<br>`job_id` | Vuelca el búfer actual y termina con `exec_hold_cat_done` |
| `ubus_req` | `session_id`<br>`object: string`<br>`method: string`<br>`params: any` | Inicia una llamada ubus. Si `params` es `null`, no pasa el tercer argumento posicional a `ubus` |
| `pty_open` | `session_id`<br>`rows: u16`<br>`cols: u16` | Abre un PTY. Las dimensiones 0 se sustituyen por 24×80 |
| `pty_resize` | `session_id`<br>`rows: u16`<br>`cols: u16` | Cambio de dimensiones del terminal |
| `pty_close` | `session_id` | Cierra una sesión PTY |
| `android_req` | `session_id`<br>`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`<br>`device_id`<br>`command`<br>`timeout_secs`<br>`hold` (por defecto true)<br>`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`<br>`device_id` | Consulta trabajos exec y descargas, cargas y consultas de directorio en curso |
| `list_device_sessions` | `session_id`<br>`device_id` | Consulta la tabla de rutas de Exchange sin ocupar un espacio de sesión del dispositivo |
| `kill_device_session` | `session_id`<br>`device_id`<br>`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`<br>`device_id`<br>`job_id` | Se adjunta a un trabajo hold y sigue recibiendo salida |
| `exec_hold_kill` / `exec_hold_tail` / `exec_hold_cat` | `session_id`<br>`device_id`<br>`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`<br>`device_id: string`<br>`object: string`<br>`method: string`<br>`params: any` | Inicia una llamada ubus |
| `open_pty` | `session_id`<br>`device_id: string`<br>`rows: u16`<br>`cols: u16` | Abre una sesión PTY interactiva |
| `open_android` | `session_id`<br>`device_id: string`<br>`request: AndroidAction` | Inicia una operación de pantalla Android; las cuentas de solo lectura no pueden hacerlo |
| `pty_resize` | `session_id`<br>`rows: u16`<br>`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](#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`<br>`server_version: string` | El servidor lo envía al conectar para confirmar compatibilidad de versión |
| `device_sessions` | `session_id`<br>`sessions: DeviceSessionInfo[]`<br>`limit`<br>`count` | Lista actual de sesiones del dispositivo en Exchange |
| `device_session_killed` | `session_id`<br>`target_session_id`<br>`released: bool` | Ruta liberada localmente; false si no existía |
| `exec_exit` | `session_id`<br>`code: i32 \| null`<br>`timed_out: bool` | Comando terminado, traducido desde el mensaje homónimo del dispositivo |
| `ubus_resp` | `session_id`<br>`code: i32`<br>`data: any` | Resultado de llamada ubus |
| `android_resp` | `session_id`<br>`result: AndroidResult` | Resultado Android con identificador traducido al espacio del operador |
| `pty_closed` | `session_id`<br>`code: i32 \| null` | PTY terminado |
| `session_failed` | `session_id`<br>`reason: SessionFailure`<br>`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

```text
 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.

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

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

```text
  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.

```json
{"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](/docs/es/file-transfer-and-lan.md).

## 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](/docs/es/sealed-sessions.md).
