# Giao thức truyền thông

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

Tài liệu này chỉ định định dạng dây hoàn chỉnh giữa agent, exchange và cli. Definitions live in `crates/proto`, and all three share the same code: fields are defined in one place, so inconsistencies surface at compile time rather than during a production session.

Nó phục vụ các nhà khai thác kiểm tra lưu lượng mạng để chẩn đoán sự cố và các nhà phát triển thay đổi giao thức hoặc viết ứng dụng khách của bên thứ ba.

---

## Nội dung

- [Tổng quan](#tổng-quan)
- [Giao thông vận tải và điểm cuối](#giao-thông-vận-tải-và-điểm-cuối)
- [Phiên bản giao thức](#phiên-bản-giao-thức)
- [Thông báo điều khiển](#kiểm-soát-tin-nhắn)
  - [DeviceUp (agent → exchange)](#deviceup-agent--exchange)
  - [DeviceDown (exchange → agent)](#devicedown-exchange--agent)
  - [OperatorUp (cli → exchange)](#toán-tửup-cli--exchange)
  - [OperatorDown (exchange → cli)](#operatordown-exchange--cli)
  - [Cấu trúc dữ liệu được chia sẻ](#cấu-trúc-dữ-liệu-được-chia-sẻ)
- [Khung dữ liệu nhị phân](#khung-dữ-liệu-nhị-phân)
- [Dịch ID phiên giữa hai không gian tên](#session-id-translation-between-two-namespaces)
- [Trình tự bắt tay](#trình-tự-bắt-tay)
- [Xử lý lỗi và các loại lỗi](#xử-lý-lỗi-và-các-loại-lỗi)
- [Keepalive và Timeouts](#keepalive-và-thời-gian-chờ)
- [Áp lực ngược](#áp-lực-ngược)
- Điều khiển màn hình [Android](#điều-khiển-màn-hình-android)
- [Phiên kín từ đầu đến cuối](#phiên-kín-từ-đầu-đến-cuối)

---

## Tổng quan

Giao thức có hai lớp chia sẻ một kết nối WebSocket:

| Lớp | Hãng vận chuyển | Mã hóa | Nội dung |
|---|---|---|---|
| Kiểm soát tin nhắn | Khung WebSocket **text** khung | JSON | Đăng ký, nhịp tim, phiên khai mạc/kết thúc, thông báo kết quả |
| Khung dữ liệu | Khung WebSocket **binary** khung | Tiêu đề cố định 9 byte + byte thô | Luồng byte PTY, lệnh stdout/stderr |

Việc nhúng dữ liệu phát trực tuyến vào JSON dưới dạng base64 sẽ tăng kích thước của nó lên khoảng 33%. Lưu lượng PTY bao gồm các gói nhỏ thường xuyên lặp lại mỗi lần nhấn phím, do đó chi phí này ảnh hưởng trực tiếp đến độ trễ tương tác. Thông báo điều khiển không thường xuyên, làm cho khả năng đọc JSON trở nên có giá trị hơn: việc chụp gói có thể hiểu được ngay lập tức.

Theo quy ước, mỗi khung nhị phân WebSocket ** mang chính xác một khung dữ liệu**. Extra or missing bytes are errors.

---

## Giao thông vận tải và điểm cuối

| Điểm cuối | khách hàng | Xác thực |
|---|---|---|
| `GET /ws/device` | agent | The **first frame** after connection establishment must be a `register` control message containing `device_id` and `token` |
| `GET /ws/operator` | remote-cli | Tiêu đề yêu cầu HTTP `Authorization: Bearer <运维令牌>` trong bắt tay WebSocket |

The two paths use separate credential types. Mã thông báo thiết bị không thể truy cập điểm cuối của nhà điều hành và ngược lại.

Operator credentials use a request header rather than a URL query string because URLs appear in reverse proxy logs, browser history, and Referer headers. Device tokens use the first frame rather than a request header because some embedded clients cannot easily customize WebSocket handshake headers. Cách tiếp cận khung đầu tiên cũng cho phép phiên bản giao thức không khớp và mã thông báo không hợp lệ sử dụng cùng một thông báo phản hồi.

Việc triển khai sản xuất phải sử dụng `wss://`: cả hai loại thông tin xác thực đều xuất hiện ở dạng văn bản gốc trong quá trình bắt tay.

---

## Phiên bản giao thức

```rust
pub const PROTOCOL_VERSION: u16 = 1;
```

Các phiên bản được so sánh theo cả hai hướng trong quá trình bắt tay. Sự không phù hợp sẽ gây ra sự từ chối ngay lập tức kèm theo lời giải thích rõ ràng. Điều này ngăn không cho các phiên bản không khớp xuất hiện dưới dạng lỗi phân tích cú pháp không thể giải thích được trong thông báo sau, điều này sẽ khó chẩn đoán.

- Phía thiết bị: agent bao gồm phiên bản của nó trong `register`. Khi không khớp, máy chủ trả về `register_ack` có `ok: false`, mô tả cả hai phiên bản trong `error` và đóng kết nối.
- Phía nhà điều hành: máy chủ chủ động gửi `welcome` sau khi thiết lập kết nối. Nếu CLI phát hiện sự không khớp, nó sẽ báo lỗi và hướng dẫn người dùng nâng cấp lên phiên bản phù hợp với `server_version`.

**Thông báo không xác định Giá trị `type` luôn bị từ chối**, không bao giờ được hiểu âm thầm là loại đã biết.

---

## Kiểm soát tin nhắn

Tất cả các thông báo điều khiển là các đối tượng JSON được gắn thẻ bởi trường `type` (các enum được gắn thẻ nội bộ serde). Giá trị thẻ và tên trường sử dụng `snake_case`. `session_id` được tuần tự hóa một cách rõ ràng dưới dạng số đơn giản.

### DeviceUp (agent → exchange)

| `type` | Trường | Mục đích |
|---|---|---|
| `register` | `protocol_version: u16`<br>`device_id: string`<br>`token: string`<br>`info: DeviceInfo` | Phải là thông báo **first** sau khi kết nối. Máy chủ không chấp nhận tin nhắn nào khác trước nó, với thời hạn 15 giây |
| `heartbeat` | `metrics: Metrics` | Các chỉ số hoạt động định kỳ Connection liveness is handled separately by native WebSocket ping/pong: a missing heartbeat does not imply a broken connection, or vice versa |
| `exec_started` | `session_id`<br>`job_id`<br>`hold`<br>`timeout_secs` | Lệnh bắt đầu; `job_id` được sử dụng trong danh sách giữ và để gắn lại sau khi kết nối lại |
| `exec_exit` | `session_id`<br>`code: i32 \| null`<br>`timed_out: bool` | Lệnh đã xong. `code` là `null` khi có tín hiệu kết thúc quá trình; `timed_out` biểu thị việc buộc phải chấm dứt do hết thời gian chờ |
| `exec_list_resp` | `session_id`<br>`jobs: ExecHoldInfo[]` | Danh sách việc làm đang giữ |
| `ubus_resp` | `session_id`<br>`code: i32`<br>`data: any` | kết quả cuộc gọi ubus. `code` is the ubus return code; 0 nghĩa là thành công |
| `android_resp` | `session_id`<br>`result: AndroidResult` | Android screen operation completed; this message ends the session |
| `pty_closed` | `session_id`<br>`code: i32 \| null` | Phiên PTY kết thúc; `code` is the shell exit code |
| `session_error` | `session_id`<br>`message: string` | Lỗi cấp phiên. Thiết bị sẽ gửi thông báo này khi không thể hoàn thành yêu cầu thay vì âm thầm hủy yêu cầu |

`register` thứ hai trên kết nối đã đăng ký được coi là sử dụng sai giao thức và được ghi lại dưới dạng cảnh báo.

Ví dụ:

```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` | Trường | Mục đích |
|---|---|---|
| `register_ack` | `ok: bool`<br>`error: string \| null`<br>`heartbeat_interval_secs: u64` | Kết quả đăng ký. Với `ok: false`, `error` đưa ra lý do, sau đó máy chủ sẽ đóng kết nối. Máy chủ chỉ định khoảng nhịp tim để có thể giảm tần số tập trung khi nhóm phát triển mà không cần cấu hình lại từng thiết bị. agent kẹp 0 đến ít nhất 1 giây để ngăn bộ hẹn giờ bận làm bão hòa CPU |
| `device_access` | `managers: DeviceManager[]`<br>`updated_at: u64` (mili giây) | Who has permission on this device. `DeviceManager` = `operator_id`, `username`, `email?`, `role`, `level` (`manage`/`view`), `source` (`role_default`/`grant`). Chỉ được gửi sau khi đăng ký và thay đổi quyền cho các đại lý quảng cáo khả năng `device_access`; see repository `docs/permissions.md` |
| `exec_req` | `session_id`<br>`command: string`<br>`timeout_secs: u64`<br>`hold: bool` (mặc định `true`)<br>`hold_timeout_secs?: u64` | Thực hiện một lệnh. Với `hold=true` (mặc định), quá trình có thể tiếp tục sau khi người vận hành ngắt kết nối. Thời gian chờ sử dụng `hold_timeout_secs` (vắng mặt/0 → 3600, cứng tối đa 86400). `hold=false` hủy lệnh khi người vận hành ngắt kết nối |
| `exec_cancel` | `session_id` | Chấm dứt (kill) việc thực hiện lệnh sớm |
| `exec_detach` | `session_id` | Dừng truyền phát đầu ra đến toán tử hiện tại trong khi vẫn giữ quá trình giữ |
| `exec_list_req` | `session_id` | Truy vấn công việc giữ hiện tại |
| `exec_attach_req` | `session_id`<br>`job_id` | Gắn lại công việc lưu giữ vào phiên này và phát lại đầu ra được lưu vào bộ đệm |
| `exec_hold_kill` | `session_id`<br>`job_id` | Terminate a hold job by job_id |
| `exec_hold_tail` | `session_id`<br>`job_id` | Chỉ theo dõi đầu ra mới mà không phát lại bộ đệm |
| `exec_hold_cat` | `session_id`<br>`job_id` | Kết xuất bộ đệm hiện tại và kết thúc bằng `exec_hold_cat_done` |
| `ubus_req` | `session_id`<br>`object: string`<br>`method: string`<br>`params: any` | Thực hiện cuộc gọi ubus. Khi `params` là `null`, không có đối số vị trí thứ ba nào được chuyển tới `ubus` |
| `pty_open` | `session_id`<br>`rows: u16`<br>`cols: u16` | Mở phiên PTY. Kích thước 0 giảm trở lại 24 × 80 |
| `pty_resize` | `session_id`<br>`rows: u16`<br>`cols: u16` | Terminal window dimensions changed |
| `pty_close` | `session_id` | Đóng phiên PTY |
| `android_req` | `session_id`<br>`request: AndroidAction` | One Android screen operation |
| `android_cancel` | `session_id` | Hủy yêu cầu Android chưa hoàn thành |

### Toán tửUp (cli → exchange)

Quá trình xác thực diễn ra trong quá trình bắt tay WebSocket, do đó không có thông báo đăng nhập.

| `type` | Trường | Mục đích |
|---|---|---|
| `open_exec` | `session_id`<br>`device_id`<br>`command`<br>`timeout_secs`<br>`hold` (mặc định đúng)<br>`hold_timeout_secs?` | Execute a command on the target device. giữ được bật theo mặc định; `hold=true` có thời gian chờ 0 → 3600 giây. `hold=false` có thời gian chờ 0 → Exchange mặc định là 60 giây |
| `list_exec_holds` | `session_id`<br>`device_id` | Truy vấn công việc thực thi và tải xuống, tải lên và truy vấn thư mục đang hoạt động |
| `list_device_sessions` | `session_id`<br>`device_id` | Truy vấn trực tiếp bảng định tuyến Exchange mà không tốn khe phiên thiết bị |
| `kill_device_session` | `session_id`<br>`device_id`<br>`target_session_id` | Hủy theo `target_session_id` phía thiết bị và giải phóng tuyến cục bộ; bao gồm lệnh thực thi giữ và không tiêu tốn thêm khe cắm |
| `attach_exec` | `session_id`<br>`device_id`<br>`job_id` | Đính kèm công việc giữ và tiếp tục nhận đầu ra |
| `exec_hold_kill` / `exec_hold_tail` / `exec_hold_cat` | `session_id`<br>`device_id`<br>`job_id` | giết/đuôi/mèo để giữ việc |
| `detach_session` | `session_id` | Dừng chờ đợi ở phía trước và tách ra mà không giết quá trình giữ |
| `open_ubus` | `session_id`<br>`device_id: string`<br>`object: string`<br>`method: string`<br>`params: any` | Thực hiện cuộc gọi ubus |
| `open_pty` | `session_id`<br>`device_id: string`<br>`rows: u16`<br>`cols: u16` | Mở phiên PTY tương tác |
| `open_android` | `session_id`<br>`device_id: string`<br>`request: AndroidAction` | Initiate an Android screen operation; read-only accounts are prohibited |
| `pty_resize` | `session_id`<br>`rows: u16`<br>`cols: u16` | Terminal window dimensions changed. Không có `device_id`: phiên đã tồn tại nên máy chủ có thể tra cứu thiết bị đích |
| `close_session` | `session_id` | Chủ động kết thúc phiên: thoát PTY bình thường hoặc người dùng gián đoạn thực thi lệnh |

The `session_id` in all four `open_*` messages is **allocated by the CLI itself** (see [Session ID Translation Between Two Namespaces](#session-id-translation-between-two-namespaces)), allowing subsequent frames immediately after the request without waiting for an ID acknowledgment.

### OperatorDown (exchange → cli)

| `type` | Trường | Mục đích |
|---|---|---|
| `welcome` | `protocol_version: u16`<br>`server_version: string` | Được máy chủ chủ động gửi sau khi thiết lập kết nối để xác minh các phiên bản giao thức phù hợp |
| `device_sessions` | `session_id`<br>`sessions: DeviceSessionInfo[]`<br>`limit`<br>`count` | Current device-side sessions in Exchange |
| `device_session_killed` | `session_id`<br>`target_session_id`<br>`released: bool` | Phát hành tại địa phương; false if it did not exist |
| `exec_exit` | `session_id`<br>`code: i32 \| null`<br>`timed_out: bool` | Lệnh đã kết thúc, được dịch từ thông báo cùng tên phía thiết bị |
| `ubus_resp` | `session_id`<br>`code: i32`<br>`data: any` | kết quả cuộc gọi ubus |
| `android_resp` | `session_id`<br>`result: AndroidResult` | Kết quả Android với ID phiên được dịch sang không gian tên toán tử |
| `pty_closed` | `session_id`<br>`code: i32 \| null` | Phiên PTY đã kết thúc |
| `session_failed` | `session_id`<br>`reason: SessionFailure`<br>`message: string` | Phiên thất bại. `reason` phân biệt các nguyên nhân để CLI có thể quyết định xem việc thử lại có đáng hay không |

### Cấu trúc dữ liệu được chia sẻ
`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`. For compatibility, missing `kind` defaults to `exec`, and missing `age_secs` defaults to 0. List responses calculate age from the current clock. Tóm tắt đường dẫn lệnh/tệp được giới hạn ở khoảng 120 ký tự. Các công việc không thực thi chỉ hỗ trợ tiêu diệt, không hỗ trợ tail/cat/attach.

`DeviceSessionInfo`: `session_id: u32` (ID phía thiết bị), `operator_session_id: u32`, `kind: string`, `hold: bool`, `age_secs: u64`, `deadline_unix_ms: u64`. Exchange trả lời trực tiếp các yêu cầu danh sách phiên và buộc hủy mà không cần mở phiên thiết bị khác; read-only accounts receive `forbidden`.

#### `DeviceInfo`

Thông tin thiết bị tĩnh, được báo cáo một lần trong `register` và được lưu trữ trong `devices.info`. Mọi thứ ngoại trừ `agent_version` đều là tùy chọn: hầu hết các nguồn không khả dụng khi agent chạy bên ngoài OpenWrt, chẳng hạn như trên máy phát triển hoặc trong vùng chứa. Đăng ký vẫn thành công chứ không phải khởi động thất bại. Missing fields are omitted during serialization.

| trường | Loại | Nguồn |
|---|---|---|
| `hostname` | `string?` | `/proc/sys/kernel/hostname` |
| `model` | `string?` | `DISTRIB_TARGET` trong `/etc/openwrt_release`, quay trở lại `model` từ `ubus call system board` nếu không có |
| `openwrt_version` | `string?` | `DISTRIB_DESCRIPTION`, or ubus `release.description` |
| `kernel` | `string?` | `/proc/sys/kernel/osrelease` |
| `arch` | `string?` | Hằng số thời gian biên dịch, đáng tin cậy hơn phát hiện thời gian chạy |
| `agent_version` | `string` | Phiên bản agent, dùng để chẩn đoán phiên bản không khớp |

#### `Metrics`

Được thu thập lại cho mỗi nhịp tim, chỉ sử dụng các giá trị có sẵn trực tiếp từ `/proc` mà không phân tách các quy trình con. Việc không đọc được một mục không làm hỏng toàn bộ bộ sưu tập: thà mất một số liệu còn hơn là không đọc được toàn bộ nhịp tim và báo cáo thiết bị ngoại tuyến không chính xác.

| trường | Loại | Mô tả |
|---|---|---|
| `uptime_secs` | `u64` | `/proc/uptime` |
| `load` | `[f32; 3]` | 1/5/15-minute load averages from `/proc/loadavg` |
| `mem_total_kb` | `u64` | `/proc/meminfo` |
| `mem_available_kb` | `u64` | Sử dụng `MemAvailable` thay vì `MemFree`: cái sau loại trừ bộ đệm có thể lấy lại được và đánh giá thấp đáng kể bộ nhớ khả dụng trên bộ định tuyến, tạo ra cảnh báo sai |
| `cpu_usage_pct` | `f32?` | Tính toán từ sự chênh lệch giữa hai mẫu `/proc/stat`; bị bỏ qua ở nhịp tim đầu tiên vì không có mẫu tham chiếu |
| `interfaces` | `InterfaceStats[]` | `/proc/net/dev`. Báo cáo **cumulative** nhận/truyền byte thay vì tốc độ, giúp máy chủ tự do tính toán tốc độ trong bất kỳ khoảng thời gian nào. Bỏ qua khi trống |

`InterfaceStats`: `{ name: string, rx_bytes: u64, tx_bytes: u64 }`

---

## Khung dữ liệu nhị phân

```text
 0        1                    5                    9
 +--------+--------------------+--------------------+
 |  kind  |     session_id     |    payload_len     |  payload (payload_len 字节)
 |   u8   |    u32 (大端)      |    u32 (大端)      |
 +--------+--------------------+--------------------+
 └──────────── 9 字节定长头 ────────────┘
```

| Bù đắp | Chiều dài | trường | Mô tả |
|---|---|---|---|
| 0 | 1 | `kind` | Loại luồng; xem bên dưới |
| 1 | 4 | `session_id` | `u32` lớn nhất. 0 is reserved as a sentinel meaning no session |
| 5 | 4 | `payload_len` | `u32` lớn, số byte tải trọng, tối đa 1 MiB |
| 9 | `payload_len` | `payload` | Raw bytes without encoding or escaping |

### Các loại luồng

| Giá trị | Tên | Hướng | Mô tả |
|---|---|---|---|
| 1 | `ExecStdout` | Thiết bị → nhà điều hành | Remote command standard output |
| 2 | `ExecStderr` | Thiết bị → nhà điều hành | Lỗi tiêu chuẩn, được truyền riêng biệt với thiết bị xuất chuẩn nên CLI có thể hiển thị nó theo cách khác, chẳng hạn như màu đỏ |
| 3 | `PtyData` | **Bidirectional** | Luồng byte PTY. Ngược dòng mang đầu vào bàn phím, xuôi dòng mang đầu ra màn hình; cùng loại phục vụ cả hai hướng |

Nếu agent nhận được `ExecStdout` / `ExecStderr` xuôi dòng, nó sẽ ghi lại cảnh báo và loại bỏ khung: điều này cho thấy lỗi triển khai ở thiết bị ngang hàng.

### Ghi chú thiết kế

**`payload_len` thực sự dư thừa với trường độ dài WebSocket. ** Vẫn còn vì hai lý do: giải mã có thể phát hiện việc cắt bớt (phải từ chối khai báo 11 byte với chỉ 5 byte được cung cấp thay vì chấp nhận âm thầm dưới dạng gói ngắn hơn) và định dạng khung có thể không thay đổi nếu cần TCP thô sau này.

Giới hạn tải trọng **1 MiB** không dành cho lưu lượng thông thường: PTY và đầu ra lệnh là các luồng gia tăng nhỏ hơn nhiều so với mức đó. Nó ngăn không cho một máy ngang hàng làm cạn kiệt bộ nhớ máy chủ bằng tiêu đề yêu cầu tải trọng 2 GiB.

Tải trọng **Empty là hợp lệ**: việc đọc 0 byte đôi khi xảy ra trong các phiên PTY. Khung như vậy chỉ bao gồm tiêu đề 9 byte.

**Payload chứa byte tùy ý**, bao gồm các chuỗi không phải UTF-8 và phải được bảo toàn chính xác.

### Lỗi giải mã

| Lỗi | Trình kích hoạt |
|---|---|
| `FrameTooShort { len }` | Total length below 9 bytes |
| `UnknownStreamKind(v)` | Byte đầu tiên không phải là 1/2/3 |
| `PayloadTooLarge { len }` | `payload_len` đã khai báo vượt quá 1 MiB |
| `LengthMismatch { declared, actual }` | Độ dài khai báo khác với số byte còn lại thực tế |

Lỗi giải mã khung dữ liệu chỉ ảnh hưởng đến khung đó: exchange ghi lại cảnh báo và loại bỏ nó mà không ngắt kết nối.

---

## Session ID Translation Between Two Namespaces

**Đây là phần của giao thức dễ bị hiểu lầm nhất.**

### Trong Một Câu

**initiator** phân bổ `session_id`, **initiator chỉ trong kết nối của chính nó**. Mỗi kết nối của nhà điều hành và thiết bị đều duy trì các không gian tên ID độc lập và exchange chuyển đổi giữa chúng. Một phiên logic có hai ID khác nhau.

```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…）
```

### Tại sao thiết kế này

**Avoid a round trip.** If the server allocated all IDs, the CLI would have to wait for an acknowledgment saying its session ID is 31 before sending keyboard input after opening a PTY. Phân bổ CLI cho phép các khung dữ liệu ngay sau `open_pty`.

**ATránh xung đột ID giữa các nhà khai thác.** Hai nhà khai thác có thể sử dụng đồng thời ID cục bộ 1 trên cùng một thiết bị. Thiết bị phải phân biệt chúng để tránh gửi kết quả đầu ra của người này cho người khác. exchange allocates device-side IDs separately for each device connection, mapping the two `session 1` instances to device-side `31` and `32`.

**Giữ hành vi của người vận hành khỏi ảnh hưởng đến ID phía thiết bị.** exchange kiểm soát hoàn toàn ID phía thiết bị; no operator can specify or guess them.

### Hai bàn

exchange duy trì một bảng cho mỗi kết nối, với cả hai bảng luôn được cập nhật cùng nhau:

| Vị trí | Bảng | Khóa → giá trị |
|---|---|---|
| `DeviceHandle.sessions` | Device-side forward table | ID phiên thiết bị → (điều khiển gửi của nhà điều hành, ID phiên của nhà điều hành, ID tài khoản của nhà điều hành, loại phiên, ID hàng kiểm tra) |
| `OperatorHandle.sessions` | Operator-side reverse index | ID phiên vận hành → (ID thiết bị, ID phiên thiết bị) |

The reverse index enables cleanup: when an operator disconnects, the server must find and terminate its sessions on **every device**, otherwise processes and PTYs with no output recipient would remain.

### Where Translation Happens

| Hướng | Tin nhắn | Dịch thuật |
|---|---|---|
| Operator → device (`open_*`) | Thông báo điều khiển | **Allocate** a new ID in the device namespace, register it in both tables, and use the new ID in the message sent to the device |
| Người vận hành → thiết bị (`pty_resize` / `close_session`) | Thông báo điều khiển | Tra cứu ID phiên thiết bị thông qua chỉ mục ngược, thay thế và chuyển tiếp |
| Người vận hành → thiết bị | Khung dữ liệu | Tra cứu ID phiên thiết bị thông qua chỉ mục ngược và ghi đè 4 byte tiêu đề tại chỗ, giữ nguyên tải trọng |
| Thiết bị → nhà điều hành | Thông báo điều khiển | Tra cứu ID phiên của nhà điều hành và kết nối mục tiêu trong bảng chuyển tiếp, thay thế ID và phân phối |
| Thiết bị → nhà điều hành | Khung dữ liệu | Tra cứu ID phiên của nhà điều hành trong bảng chuyển tiếp và ghi đè tiêu đề tại chỗ, giữ nguyên tải trọng |

Bản dịch khung dữ liệu là bản viết lại **only**: exchange không phân tích cú pháp, đệm hoặc thay đổi bất kỳ byte tải trọng nào. `DataFrame::with_session_id` thay thế ID tại chỗ mà không phân bổ lại tải trọng.

### Quy tắc phân bổ

Cả hai bộ cấp phát đều tăng từ 1 và **skip 0**, chẳng hạn như trọng điểm không có phiên được sử dụng trong các thông báo lỗi cấp kết nối.

- CLI (`SessionIdGenerator`): gói khi tràn và bỏ qua 0. Sử dụng bốn tỷ phiên trong một kết nối là không thực tế, nhưng gói phù hợp với quy trình chạy dài hơn là hoảng loạn.
- Thiết bị (`DeviceHandle::open_session`): sau khi gói, nếu ID thuộc phiên trực tiếp, sẽ tiếp tục đến vị trí trống tiếp theo.

exchange từ chối ID nhà điều hành 0 và sử dụng lại ID phiên hoạt động. Tính hợp lệ của kết nối thiết bị, giới hạn đồng thời và đăng ký trong cả hai chỉ mục đều được kiểm tra trong cùng một phần quan trọng, ngăn chặn phiên mở đồng thời từ nhiều nhà khai thác vượt quá giới hạn.

### Khi Không Có Tuyến Đường Tồn Tại

**Loại bỏ âm thầm mà không gặp lỗi.** Một thiết bị ngang hàng vẫn gửi dữ liệu theo sau ngay sau khi đóng phiên là một cuộc chạy đua bình thường. Điều này áp dụng theo cả hai hướng:

- Thiết bị trả lời phiên không còn tồn tại → nhật ký gỡ lỗi; nhà điều hành có thể đã ngắt kết nối.
- Nhà điều hành gửi dữ liệu cho phiên không xác định → nhật ký theo dõi và loại bỏ.

### Đường dẫn dọn dẹp phiên

| Trình kích hoạt | Dọn dẹp |
|---|---|
| Thiết bị kết thúc phiên bình thường (`exec_exit` / `ubus_resp` / `android_resp` / `pty_closed` / `session_error`) | Cùng nhau loại bỏ tuyến chuyển tiếp và chỉ mục ngược của kết nối sở hữu, cập nhật bản ghi kiểm tra và gửi thông báo đã dịch cho nhà điều hành |
| Toán tử `close_session` | Xóa cả hai chỉ mục, yêu cầu kết nối thiết bị ban đầu chấm dứt, ngay lập tức xác nhận việc đóng đối với nhà điều hành và cập nhật bản ghi kiểm tra việc hủy. Đóng liên tục không làm gì cả |
| Ngắt kết nối thiết bị | Thực hiện tất cả các phiên cho kết nối đó, gửi `session_failed` / `device_disconnected` cho mỗi nhà điều hành và cập nhật bản ghi kiểm tra với `{"outcome":"device_disconnected"}` |
| Nhà điều hành ngắt kết nối | Duyệt qua chỉ mục ngược của nó để tìm các phiên của nó trên mỗi thiết bị. Gửi hướng dẫn chấm dứt cho các phiên không giữ; đối với lệnh giữ, chỉ gửi `exec_detach`, để quá trình chạy. **Chỉ dọn sạch các phiên của chính nó**; các nhà khai thác khác trên cùng một thiết bị không bị ảnh hưởng |
| Thiết bị kết nối lại và thay thế kết nối cũ | Vô hiệu hóa tất cả các phiên trên kết nối cũ và thông báo cho mỗi nhà điều hành bằng `device_disconnected` |

Chỉ mục ngược liên kết với một kết nối thiết bị cụ thể; nó không chuyển hướng theo ID thiết bị sang kết nối mới sau khi kết nối lại. Nhiều kết nối của nhà điều hành cho một tài khoản, mỗi tài khoản sở hữu một chỉ mục, do đó việc ngắt kết nối một tài khoản sẽ không hủy phiên của kết nối khác.

Xác nhận cho việc hủy rõ ràng: exec trả về `exec_exit` (`code: null`, `timed_out: false`), PTY trả về `pty_closed` (`code: null`) và ubus trả về `session_failed` (`reason: device_error`). Điều này xác nhận exchange đã đóng phiên chứ không phải quá trình từ xa đã thoát; dữ liệu theo dõi tiếp theo và các thông báo thoát khỏi thiết bị sẽ bị loại bỏ.

Ở giới hạn đồng thời, Exchange trước tiên lấy lại các tuyến mà người vận hành đã không còn hoặc thời hạn đã trôi qua trong cùng một khóa phiên, sau đó kiểm tra lại giới hạn. Thời hạn thực hiện là thời hạn gửi thực tế cộng với thời gian gia hạn 15 giây; tập tin sử dụng 3600 giây cộng thêm 15 giây; các loại khác sử dụng 24 giờ. Giữ người thực thi trong thời hạn của nó sẽ không bị hủy khi người điều hành rời đi. Reclaim/rõ ràng `kill_device_session` gửi lệnh hủy tương ứng tới thiết bị và ngay lập tức xóa cả hai chỉ mục mà không cần đợi phản hồi của thiết bị. Các nhà khai thác có sẵn nhận `exec_exit` (`timed_out:true`) cho người thực thi hoặc `session_failed` (`device_error`, `session reclaimed`) cho các loại khác. Cập nhật kiểm tra là không đồng bộ và nỗ lực tốt nhất.

### Kết nối lại thiết bị và kỷ nguyên

Mỗi kết nối thiết bị sẽ nhận được `epoch` (UUID) ngẫu nhiên trong quá trình đăng ký. Khi cùng một thiết bị kết nối lại, **kết nối mới sẽ thay thế kết nối cũ** thay vì bị từ chối. Không thể phân biệt một cách đáng tin cậy giữa kết nối trùng lặp với kết nối đã chết mà máy chủ chưa phát hiện được (kết nối nửa mở); từ chối cái mới sẽ ngăn cản việc kết nối lại cho đến khi cái cũ hết thời gian.

Việc dọn dẹp kết nối cũ có thể thực hiện đăng ký **after** của kết nối mới. Do đó, việc hủy đăng ký phải kiểm tra kỷ nguyên: chỉ xóa mục nhập bảng kết nối nếu kỷ nguyên hiện tại của nó khớp với kết nối đang được dọn sạch. Nếu không có bước kiểm tra này, việc dọn dẹp kết nối cũ sẽ ngay lập tức đánh dấu thiết bị mới được kết nối ngoại tuyến.

---

## Trình tự bắt tay

### Phía thiết bị

```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）       │
      │────────────────────────────────────────────────▶│
```

**Đường dẫn lỗi:**

- Khung đầu tiên không phải là văn bản, JSON không hợp lệ hoặc không phải là `register` → ghi lại cảnh báo và đóng ngay lập tức mà không cần xác nhận.
- Không có `register` trong vòng 15 giây → đóng.
- Phiên bản không khớp → trả về `register_ack{ok:false, error:"协议版本不匹配：设备为 X，服务端为 Y"}` và đóng.
- Mã thông báo không hợp lệ/thiết bị không tồn tại/thiết bị bị vô hiệu hóa → luôn trả về cùng một `"设备 ID 或令牌无效"` và đóng. **DKhông phân biệt nguyên nhân**: làm như vậy sẽ khiến kho thiết bị có thể đếm được.

agent xử lý riêng hai loại lỗi này:

| Danh mục agent | Trình kích hoạt | Chiến lược kết nối lại |
|---|---|---|
| `Rejected` | Nhận được ack với `ok: false` | Đã sửa lỗi chờ **300 giây**. Việc thử lại không thể sửa được mã thông báo không hợp lệ; thử lại thường xuyên gây lãng phí tài nguyên ở cả hai bên và nhật ký máy chủ tràn ngập. Thời gian chờ đợi lâu hơn sẽ giúp ai đó có thời gian cập nhật cấu hình |
| `Transport` | Lỗi kết nối, hết thời gian bắt tay (45 giây), phản hồi không đúng định dạng, kết nối bị gián đoạn | Độ trễ theo cấp số nhân: bắt đầu từ 1 giây, tăng gấp đôi mỗi lần lên tới 300 giây, nhân với jitter ngẫu nhiên trong [0.5, 1.5) |

**Backoff chỉ đặt lại sau khi đăng ký thành công**, không chỉ thiết lập TCP. Nếu không, việc từ chối ngay lập tức sau khi kết nối sẽ chuyển thành số lần thử lại dày đặc trong khoảng thời gian mili giây.

**Jitter là điều cần thiết.** Khi máy chủ khởi động lại, hàng trăm hoặc hàng nghìn thiết bị sẽ phát hiện tình trạng ngắt kết nối cùng một lúc. Với đường cong thử lại xác định giống hệt nhau, chúng vẫn được đồng bộ hóa và tấn công máy chủ mới khởi động theo từng đợt, biến việc khởi động lại thành một lỗi xếp tầng kéo dài.

### Bên điều hành

```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 có thể gửi khung dữ liệu tiếp theo ** mà không cần chờ bất kỳ xác nhận nào**. Đây chính xác là lý do tại sao người khởi tạo phân bổ ID phiên.

---

## Xử lý lỗi và các loại lỗi

### `SessionFailure`

Được sử dụng trong trường `reason` của `session_failed`; Các giá trị JSON sử dụng snake_case.

| Giá trị | Ý nghĩa | Nhà sản xuất | Có thể thử lại |
|---|---|---|---|
| `device_offline` | Thiết bị không tồn tại hoặc hiện đang ngoại tuyến | exchange không tìm thấy thiết bị nào trong bảng kết nối trong `open_*` | ✅ |
| `device_disconnected` | Thiết bị bị ngắt kết nối trong phiên | Thiết bị bị ngắt kết nối/được thay thế bằng kết nối mới/hàng đợi gửi đã bị đóng trong quá trình gửi đi | ✅ |
| `device_error` | Thiết bị từ chối yêu cầu hoặc thực thi không thành công | agent trả về `session_error` (tạo PTY, khởi động lệnh hoặc lỗi cuộc gọi ubus) hoặc thiết bị ngang hàng tiêu thụ quá chậm trong quá trình chuyển tiếp | ❌ |
| `rate_limited` | Đã vượt quá giới hạn phiên đồng thời | exchange nhận thấy các phiên hoạt động của thiết bị đã đạt đến `--max-sessions-per-device` (mặc định là 32) trong `open_*` | ✅ |
| `forbidden` | Thông tin xác thực của người vận hành không thể truy cập vào thiết bị | Tài khoản chỉ đọc (`role = readonly`) cố gắng bắt đầu bất kỳ phiên nào | ❌ |

`is_retryable()` cố tình loại trừ `forbidden` và `device_error`: việc thử lại vấn đề về quyền hoặc lỗi thiết bị nội bộ là vô nghĩa và chỉ gây ra hoạt động CLI không cần thiết.

CLI thêm một gợi ý có thể thực hiện được sau `message` ban đầu. Ví dụ: `device_offline` đề xuất kiểm tra trạng thái thiết bị bằng `remote-cli device list`. `device_error` là ngoại lệ: lỗi của chính thiết bị là lời giải thích chính xác nhất và không nên pha loãng bằng bản soạn sẵn.

### Ngữ nghĩa lỗi theo lớp

| Lớp | Hành vi | Phạm vi |
|---|---|---|
| Lỗi giải mã khung | Đăng nhập và loại bỏ khung | Một khung hình |
| Kiểm soát lỗi phân tích tin nhắn | Cảnh báo và bỏ qua tin nhắn | Một tin nhắn; kết nối tiếp tục |
| Lỗi cấp phiên | `session_failed` / `session_error` | Một phiên; kết nối tiếp tục |
| Đăng ký thất bại | Đóng sau `register_ack{ok:false}` | Toàn bộ kết nối |
| Phiên bản không khớp | Đóng sau một thông báo lỗi rõ ràng | Toàn bộ kết nối |
| Độ trễ hàng đợi | Chủ động đóng kết nối | Toàn bộ kết nối |

The principle: **keep failures local whenever possible**, since a device connection may carry simultaneous sessions for multiple operators.

---

## Keepalive và thời gian chờ

| Cơ chế | Giá trị | Mô tả |
|---|---|---|
| exchange WebSocket Ping | Mỗi `--heartbeat-secs` (mặc định 30 giây) | exchange gửi tới cả thiết bị và nhà điều hành. Thư viện client tự động xếp hàng Pong; agent phải xả sạch bồn rửa sau khi nhận được Ping để gửi Pong kịp thời |
| agent WebSocket Ping | Mọi `heartbeat_interval_secs` (được cung cấp trong xác nhận máy chủ) | agent sends native Ping in addition to each JSON `heartbeat`, avoiding Cloudflare's approximately 100-second idle-disconnect boundary if exchange Ping is delayed |
| Hết thời gian im lặng kết nối | `heartbeat × --missed-pings` (mặc định 30 × 3 = 90 giây) | Kết nối của thiết bị hoặc nhà điều hành không có tin nhắn gửi đến trong khoảng thời gian này được coi là bị mất và bị ngắt kết nối. Pong được tính là hoạt động; `--missed-pings` được đặt thành 0 quay trở lại ít nhất một nhịp tim |
| Hết thời gian ghi exchange | 10 giây | Bất kỳ dữ liệu WebSocket nào hoặc thời gian chờ ghi Ping đều kết thúc kết nối, ngăn chặn các kết nối chậm chặn quá trình dọn dẹp |
| Nhịp tim kinh doanh | Mọi `heartbeat_interval_secs` (được cung cấp trong xác nhận máy chủ) | agent gửi JSON `heartbeat` với `Metrics` và Ping WebSocket riêng biệt. Báo cáo số liệu và bóng bàn/bóng bàn gốc phục vụ các mục đích riêng biệt; riêng nhịp tim doanh nghiệp không thể xác định được trạng thái kết nối |
| Sự kiện trực tuyến/ngoại tuyến | `tick_count` dành cho trình kích hoạt thông báo `online` / `offline` (mặc định 3, phạm vi 1–100); dự phòng phát sóng/kiểm tra `REMOTE_MISSED_THRESHOLD` / `REMOTE_ONLINE_THRESHOLD` | Người vận hành định cấu hình hai trình kích hoạt, thay vì hai bộ đếm trên một trình kích hoạt. `offline` thông báo sau khi `tick_count` liên tiếp bỏ lỡ; `online` thông báo sau khi `tick_count` thành công liên tiếp. `device_ids` của mỗi trình kích hoạt có thể là một ID thiết bị (chuỗi), một bộ ID (mảng) hoặc bị bỏ qua /`null` cho tất cả các thiết bị không gian làm việc. Trình kích hoạt không lặp lại thông báo khi đã ở trạng thái được báo cáo; đầu tiên nó phải trang bị lại `tick_count` liên tiếp để có kết quả ngược lại. Kiểm tra và phát sóng hiện diện trong quá trình không đọc các trình kích hoạt này và chuyển về `REMOTE_MISSED_THRESHOLD` / `REMOTE_ONLINE_THRESHOLD` (cũng được mặc định là 3). Việc ổ cắm có còn trong bảng kết nối hay không được xác định riêng bởi `--missed-pings` |
| Hết thời gian đăng ký thiết bị | 15 giây | `register` phải được gửi trong khoảng thời gian này sau khi thiết lập kết nối |
| Hết thời gian bắt tay agent | 45 giây mỗi pha (`HANDSHAKE_TIMEOUT`) | `connect_device_ws` (TCP, bắt tay HTTP CONNECT, TLS, WebSocket tùy chọn) và chờ `register_ack` sau khi gửi `register`, mỗi người đều nhận được giới hạn này; 45 giây không phải là giới hạn tổng hợp của họ |
| agent ghi/Hết thời gian xả Pong | 30 giây (`WRITE_TIMEOUT`) | Mỗi lần gửi (đăng ký, nhịp tim JSON, Ping, đầu ra phiên) và mỗi lần gửi Pong sau đăng ký đều có thời hạn riêng. Lỗi ghi hoặc hết thời gian chờ sẽ kết thúc kết nối và kích hoạt kết nối lại. Pong tuôn ra trong khi chờ đăng ký được điều chỉnh bởi thời gian chờ 45 giây ở trên |
| agent TCP được giữ nguyên | Thăm dò sau 30 giây không hoạt động | Cho phép duy trì `TCP_NODELAY` và TCP sau khi kết nối trực tiếp hoặc KẾT NỐI HTTP; Cài đặt TCP không thay thế WebSocket Ping/Pong |
| CLI hết thời gian chờ chào mừng | 15 giây | Thời gian CLI đợi `welcome` sau khi kết nối |
| Hết thời gian thực hiện lệnh | Được chỉ định bởi `timeout_secs` trong `open_exec` | agent kẹp ít nhất 1 giây. Khi hết thời gian chờ, SIGTERM toàn bộ nhóm quy trình, sau đó SIGKILL sau khoảng thời gian gia hạn 3 giây |
| hết thời gian gọi ubus | 15 giây (cố định trong agent) | ubus là RPC cục bộ; vượt quá khoảng thời gian này thường cho thấy dịch vụ tương ứng bị kẹt |

Một client shell nhàn rỗi vẫn trả lời bằng Pong. agent xóa rõ ràng phản hồi được thư viện tự động xếp hàng mà không cần chờ nhịp công việc tiếp theo, do đó việc thiếu đầu vào bàn phím không gây ra ngắt kết nối sai (#42). `--heartbeat-secs` phải lớn hơn 0; các giá trị không hợp lệ bị từ chối trong quá trình phân tích cú pháp đối số khởi động.

---

## Áp lực ngược

exchange duy trì hàng đợi gửi giới hạn các tin nhắn **512** cho mỗi kết nối.

Hàng đợi đầy có nghĩa là thiết bị ngang hàng không thể theo kịp, thường là do kết nối PTY bị đình trệ hoặc máy khách bị treo. Câu trả lời là **ngắt kết nối đó một cách chủ động** thay vì tiếp tục lưu vào bộ đệm: việc lưu vào bộ đệm không giới hạn sẽ biến một máy khách bị đình trệ thành rò rỉ bộ nhớ ảnh hưởng đến toàn bộ dịch vụ. Thông báo 512 cung cấp khoảng trống rộng rãi cho lưu lượng PTY tương tác (thông thường độ dài hàng đợi ở dạng một chữ số); đạt đến giới hạn thường có nghĩa là người ngang hàng đã ngừng tiêu dùng.

Phân phối luôn sử dụng `try_send` không chặn. Đường dẫn định tuyến giữ khóa bảng kết nối; bất kỳ `await` nào cũng sẽ gắn thời lượng khóa với tốc độ tiêu thụ ngang hàng, cho phép một máy khách chậm làm đình trệ toàn bộ bảng.

Hai lỗi giao hàng được xử lý khác nhau:

| kết quả | Ý nghĩa | Xử lý |
|---|---|---|
| `Lagging` | Hàng đợi đầy | Ngắt kết nối bằng tín hiệu đóng riêng biệt, dọn sạch các phiên của nó và cố gắng hết sức để thông báo lỗi cho điểm cuối khác |
| `Closed` | Người nhận phát hành | Chấm dứt kết nối bình thường; chỉ nhật ký gỡ lỗi |

Tín hiệu đóng kết nối bỏ qua hàng đợi gửi, cho phép dọn dẹp ngay cả khi hàng đợi đầy hoặc quá trình ghi bị chặn. Việc thay thế thiết bị sau khi kết nối lại và xoay vòng mã thông báo sử dụng cơ chế tương tự.

agent cũng có hàng đợi đi có dung lượng 256. Hàng đợi đầy có nghĩa là mạng không thể gửi dữ liệu nào cả, do đó việc tích lũy thêm là vô nghĩa; ngắt kết nối và kết nối lại. Khi hàng đợi đầu vào PTY (64) đầy, các lần nhấn phím sẽ bị loại bỏ: việc tích lũy các đầu vào tương tác nhỏ không có mục đích gì.
## Điều khiển màn hình Android

Được xác định trong `crates/proto/src/android.rs`, sử dụng định tuyến, xác thực, giới hạn đồng thời và kiểm tra phiên hai không gian tên hiện có. `request` trong `open_android` / `android_req` là một đối tượng JSON được phân biệt đối xử bởi `action`:

| `action` | Các lĩnh vực khác | Hành vi |
|---|---|---|
| `status` | không có | Truy vấn phiên bản SDK và ủy quyền cục bộ; không cần bật công tắc điều khiển màn hình nhưng dịch vụ từ xa phải đang chạy |
| `tree` | không có | Cây nút trợ năng của ứng dụng hiện đang hoạt động |
| `screenshot` | không có | Một ảnh chụp màn hình JPEG |
| `tap` | `x, y: u32` | Nhấn vào tọa độ pixel màn hình gốc |
| `swipe` | `x1, y1, x2, y2, duration_ms: u32` | Vuốt, thời lượng 50–5000 mili giây |
| `swipe_path` | `points: [{x, y}]` (2–64 điểm), `duration_ms: u32` | Vuốt nhiều đoạn: một lần nhấn liên tục đi qua tất cả các điểm, nội suy thống nhất theo độ dài, thời lượng 50–5000 ms |
| `global` | `key: string` | `back` / `home` / `recents` / `notifications` / `quick_settings` |
| `input_text` | `text: string` | Thay thế trường có thể chỉnh sửa tập trung, tối đa 8192 byte UTF-8, không có NUL |

`AndroidResult` bị `kind` phân biệt đối xử; tất cả các trường được yêu cầu:

| `kind` | Trường |
|---|---|
| `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` | Không có; cho biết việc hoàn thành thao tác nhập, cử chỉ hoặc hành động của hệ thống |

Các nút chứa các chuỗi `class_name, package, view_id, text, description`, `bounds: [left, top, right, bottom]` (tọa độ pixel màn hình có dấu) và booleans `clickable, editable, focused, password`. Tối đa 200 nút, với mỗi chuỗi giới hạn ở 256 đơn vị UTF-16. `truncated` chỉ cho biết giới hạn nút. Các giá trị và mô tả mật khẩu trống và cây con cấu hình riêng của agent bị bỏ qua.

Cạnh dài nhất của ảnh chụp màn hình được giới hạn ở 1600 pixel. `width/height` là kích thước hình ảnh; `screen_width/screen_height` là kích thước trước khi chia tỷ lệ. Tọa độ cử chỉ sử dụng kích thước ban đầu. JSON được cầu Java trả về bị giới hạn ở 2 MiB, với 1024 byte bổ sung được phép cho đường bao WebSocket. Không có loại khung nhị phân mới nào được thêm vào.

```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` cho biết ủy quyền ảnh chụp màn hình hiện có sẵn (khả năng truy cập MediaProjection hoặc Android 11+); mọi thao tác trên màn hình thực tế vẫn yêu cầu `controls_enabled`. Không đủ quyền, cửa sổ không thể truy cập và thao tác không thành công sẽ trả về agent `session_error`, được Exchange dịch sang `session_failed`. Các tác nhân không phải Android trả về một lỗi không được hỗ trợ rõ ràng.

Agent xử lý đồng thời tối đa 4 yêu cầu Android và chỉ mã hóa một ảnh chụp màn hình mỗi lần. Các thao tác Java chờ tối đa 10 giây (tối đa 5 giây đối với khung màn hình); Thời gian chờ yêu cầu agent là 15 giây, thời gian chờ CLI là 20 giây. Khi nhà điều hành hủy hoặc ngắt kết nối, Exchange gửi `android_cancel`; agent đóng ổ cắm cục bộ và APK hủy công việc được xếp hàng đợi chưa chạy. Các cử chỉ hoặc thay đổi văn bản đã hoàn thành sẽ không được khôi phục.

Hành động kiểm tra là `android`; chi tiết lưu trữ hành động và tọa độ. `input_text` chỉ lưu trữ `characters` (số ký tự Unicode) và kết quả thành công chỉ ghi lại `outcome: "completed"`. Nội dung văn bản, cây giao diện người dùng và hình ảnh không được ghi vào hồ sơ kiểm tra. Tài khoản `readonly` không thể khởi tạo ngay cả `status`, `tree` hoặc ảnh chụp màn hình.

## Phần mở rộng phiên tệp

Các tác nhân hỗ trợ hoạt động tệp quảng cáo khả năng `files`. Exchange dịch `open_file {session_id, device_id, request}` thành `file_req {session_id, request}`. `request.action` là `list`, `download` hoặc `upload`; định nghĩa trường có trong `crates/proto/src/files.rs`.

Thông báo điều khiển hai chiều sử dụng JSON: bộ điều khiển gửi `file_input {session_id, input}` và thiết bị trả về `file_event {session_id, event}`. `input.kind` là `chunk {offset, data}`, `ack {offset}` hoặc `finish`; `event.kind` là `ready {size}`, `chunk {offset, data}`, `progress {received}`, `complete {size}` hoặc `list {path, entries, truncated}`. Nội dung chunk sử dụng tiêu chuẩn Base64, mỗi chunk tối đa là 64 KiB và người gửi chờ xác nhận trước khi tiếp tục.

`ready` ban đầu của quá trình tải xuống yêu cầu `ack {offset: 0}`; các đoạn tiếp theo xác nhận số byte tích lũy. Quá trình tải lên bắt đầu bằng `ready` và mỗi `progress` sẽ điều khiển đoạn tiếp theo; sau khi gửi mọi thứ, `finish` cam kết điều đó. `complete` và `list` là các tin nhắn đầu cuối; lỗi sử dụng `session_error`/`session_failed` hiện có. `close_session` gửi `file_cancel`; hủy bỏ và ngắt kết nối đều loại bỏ các tuyến phiên. Tin nhắn tệp chỉ được chuyển tiếp trên các phiên tệp, duy trì sự tách biệt giữa ID phiên của nhà điều hành và thiết bị.

Phiên bản giao thức vẫn là 1; các tin nhắn mới yêu cầu nâng cấp Exchange và Agent phù hợp. Để biết giới hạn hoạt động và giao diện LAN, hãy xem [Truyền tệp và Kết nối mạng LAN trực tiếp](/docs/vi/file-transfer-and-lan.md).

## Tiện ích mở rộng đa nền tảng

Phiên bản giao thức vẫn giữ nguyên 1. Đăng ký `info` bổ sung thêm `platform` tùy chọn (`openwrt` / `linux` / `freebsd` / `netbsd` / `windows` / `android` / `macos`), `os_version` và `capabilities` (`exec` / `pty` / `ubus` / `android_control` / `files` / `sensors` / `net`). OpenWrt báo cáo `openwrt_version`.

Heartbeat `metrics.unavailable` là một mảng chuỗi tùy chọn xác định các số liệu `uptime`, `load`, `memory`, `cpu` và `interfaces` hiện không khả dụng. Nếu vắng mặt, hãy phân tích cú pháp bằng định dạng cũ. Các giá trị tương ứng với số liệu được liệt kê chỉ là phần giữ chỗ tương thích; khách hàng sẽ hiển thị chúng là không có sẵn. Windows không cung cấp mức tải trung bình của Unix và Android có thể hạn chế một số nguồn `/proc` thông qua quyền hệ thống.

Các lệnh được diễn giải bởi shell của thiết bị mục tiêu: `/bin/sh` trên Linux/OpenWrt/macOS, PowerShell trên Windows và `/system/bin/sh` trên Android. Định dạng khung PTY và các thông báo điều khiển giống hệt nhau trên cả ba hệ thống; Windows sử dụng ConPTY. Các thiết bị không phải OpenWrt trả về `session_error` cho các yêu cầu ubus.

## Phiên kín từ đầu đến cuối

Phiên bản giao thức vẫn giữ nguyên 1. Tin nhắn mới: OperatorUp `open_sealed {session_id, device_id, hello}`, `sealed_input {session_id, packet}`; Thiết bịDown `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}`. Loại khung nhị phân mới `4 = Sealed` (tải trọng `counter ‖ AEAD(inner_kind ‖ bytes)`), `DeviceInfo.seal_key` và khả năng `sealed`.

Đối với các phiên kín, Exchange chỉ thực hiện dịch ID, giới hạn đồng thời và định tuyến. Nó không phân tích hay kiểm tra tải trọng (hồ sơ kiểm tra chỉ chứa khóa công khai, mức ủy quyền và thời hạn cũng như kết quả cuối cùng) và không quyết định các quyền ngoài việc kiểm tra xem tài khoản có thể nhìn thấy thiết bị hay không. Các tuyến đường được phát hành trên `last:true`. Các tuyến kín chỉ chấp nhận khung `Sealed`; các tuyến thông thường từ chối các khung `Sealed`. agent loại bỏ mọi bản rõ được gửi đến ID phiên được niêm phong. Để biết về ủy quyền, bắt tay, dẫn xuất khóa, mô hình mối đe dọa, hãy xem [EPhiên kín từ đầu đến cuối](/docs/vi/sealed-sessions.md).
