# win-agent 协议与接入约定 v1.1

> 跨窗口共用文档。**读完这一篇就能直接写 Agent，不需要再问。**
> 2026-10-22 更新：补齐 `files` / `screen` / `input` 的全部实现细节，修正单帧上限与分块规则。

---

## 0. 范围划分

| 谁 | 做什么 | 状态 |
| --- | --- | --- |
| **服务器侧（已完成）** | WSS 接收端、节点注册、入网码、任务转发、审计日志、Caddy 反代 + Let's Encrypt | **已上线运行** |
| **Windows 侧（待实现）** | `win-agent.exe`：出站 WSS、ConPTY、GDI 截屏、SendInput、文件分块 | 归你 |
| 后续 | DSH 插件（把控制面包成 DSH 工具） | 服务器侧，P1 用 curl 验收即可 |

**结论：P1 你只做 Windows Agent，服务端已经在那儿等着了，连上就能用。**

---

## 1. 端点

| 用途 | 地址 |
| --- | --- |
| Agent 连接 | `wss://agent.zzd1.com/win-agent` |
| 文档（可直接 curl） | `https://agent.zzd1.com/doc/README.md` |
| 控制面 HTTP（仅服务器本机） | `http://127.0.0.1:8767` |
| 服务 | `systemctl status win-agent` |

证书：Let's Encrypt，`CN=agent.zzd1.com`，**用系统信任链正常校验，不要 pinning、不要跳过校验**。

---

## 2. 帧格式

**全部是 JSON 文本帧**（不是二进制帧）。服务端**单帧上限 4 MB**（`maxPayload`），超过会被直接断开。

> 4 MB 是硬上限，`files` / `screen` 必须按下面第 5 节分块。

---

## 3. 连接与注册

### 3.1 注册（连上后 **10 秒内**第一帧）

```json
{"type":"register","code":"5504933C","hostname":"WIN-DEMO","os":"Windows 11 23H2","user":"WIN-DEMO\\tester","agent":"0.1.0"}
```

- `code`：一次性入网码，**用后即焚**；从命令行参数或环境变量读，**不要硬编码进 exe**
- `hostname` / `os` / `user` / `agent`：仅用于控制台辨认，可为空字符串

### 3.2 服务端确认（一定回）

```json
{"type":"registered","node":"n_e109eb76aa82","server_time":1792641889424}
```

- **`node` 是本次会话的节点 ID**，P1 不做持久身份，重连会拿到新的
- 收到这一帧才算注册成功；**没收到就继续等，不要发 `task` 相关的东西**

### 3.3 心跳

```json
{"type":"heartbeat"}
```

- **单向**：Agent → Server。**服务端不回**。
- 建议 **20 秒**一次。
- **服务端不会因为漏心跳断你**（P1 没有 idle timeout），只在 `/nodes` 里让 `last_seen_ms_ago` 变大。
- **Agent 侧建议**：连续 **60 秒**收不到任何下行帧，主动重连（属于自保，非强制）。

---

## 4. 任务与结果

### 4.1 下行任务

```json
{"type":"task","id":"t_1","op":"exec","args":{"cmd":"whoami","timeout_ms":15000}}
```

### 4.2 上行结果

```json
{"type":"result","id":"t_1","ok":true,"out":"...","err":""}
```

**硬约定：**

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | string | **原样带回**，服务端靠它对回包 |
| `ok` | bool | 操作是否成功 |
| `out` | **string（永远是字符串）** | 内容格式由 `op` 决定，见第 5 节 |
| `err` | **string（永远是字符串）** | 失败原因；成功时给 `""` |

> **`out` / `err` 永远是字符串。**服务端不做任何解析，原样透传给调用方。
> 需要结构的 op（`files.list`）由 Agent 自己 `JSON.stringify` 塞进 `out`，调用方自己 parse。
> **不要**把 `out` 发成对象或数组 —— 服务端会 `String()` 化，你就丢结构了。

### 4.3 超时

服务端默认等 **30 秒**（可用 `/task` 的 `timeout_ms` 覆盖）。超时会回 HTTP 500 `task timeout`，Agent 之后再回包会变成孤儿结果（只记审计，丢弃）。

**Agent 侧必须自己也有超时**：`args.timeout_ms` 到了就 kill 掉并回 `ok:false`。

---

## 5. 每个 op 的精确契约

### 5.1 `exec`

```json
{"op":"exec","args":{"cmd":"whoami","cwd":"C:\\","timeout_ms":15000}}
```

| 项 | 约定 |
| --- | --- |
| Shell | **PowerShell**：`powershell.exe -NoProfile -NonInteractive -Command "<cmd>"` |
| 工作目录 | `args.cwd`，缺省 `C:\` |
| 超时 | `args.timeout_ms`，默认 **15000**，上限 **120000**；到点 **kill 进程树** |
| 编码 | **UTF-8**：执行前置 `[Console]::OutputEncoding=[Text.Encoding]::UTF8`，或 `chcp 65001` |
| 合并方式 | `out` = stdout **+** stderr 合并 |
| 成功判定 | `ok = (exit_code == 0)` |
| 失败时 | `ok=false`，`err = "exit=N"`（N 为退出码），`out` 仍带已捕获的输出 |
| 输出截断 | `out` 超过 **256 KB** 时截断，并在末尾加 `\n...[truncated]` |

### 5.2 `files.stat`（**v1.1 新增**）

分块读写前必须先知道文件多大。

```json
{"op":"files.stat","args":{"path":"C:\\Windows\\notepad.exe"}}
```

`out` = **JSON 字符串**：

```json
{"path":"C:\\Windows\\notepad.exe","exists":true,"size":201216,"mtime_ms":1698000000000,"is_dir":false}
```

### 5.3 `files.list`

```json
{"op":"files.list","args":{"path":"C:\\Users\\tester\\Desktop"}}
```

`out` = **JSON 字符串**（数组）：

```json
[
  {"name":"a.txt","path":"C:\\...\\a.txt","size":123,"mtime_ms":1698000000000,"is_dir":false},
  {"name":"sub","path":"C:\\...\\sub","size":0,"mtime_ms":0,"is_dir":true}
]
```

### 5.4 `files.get`（**分块**）

```json
{"op":"files.get","args":{"path":"C:\\big.iso","offset":0,"len":1048576}}
```

| 项 | 约定 |
| --- | --- |
| 编码 | `out` = **纯 base64**（不带 `data:` 前缀） |
| `offset` | 字节偏移，默认 0 |
| `len` | 本次读取字节数，**默认并建议 1048576（1 MB）**，上限 **3145728（3 MB）** |
| 分块依据 | 1 MB 原文 → base64 约 1.33 MB，留足 4 MB 单帧余量 |
| 末尾 | 不足 `len` 表示读到 EOF |
| 大文件流程 | `files.stat` 拿 `size` → 循环 `files.get(offset += len)` 直到读完 |

### 5.5 `files.put`（**分块**）

```json
{"op":"files.put","args":{"path":"C:\\tmp\\a.bin","offset":0,"data_b64":"...","truncate":true,"final":false}}
```

| 项 | 约定 |
| --- | --- |
| 编码 | `data_b64` = 纯 base64 |
| `offset` | 写入偏移；**首块 `offset=0` 且 `truncate=true`** 时先清空文件 |
| `final` | 最后一块置 `true`，Agent 做 flush/close |
| `out` | 本次写入的字节数（十进制字符串，如 `"1048576"`） |
| 单块大小 | **≤ 1 MB 原文**（base64 后 ~1.33 MB） |
| 无状态 | Agent 不维护会话状态，每块独立打开/写入/关闭即可 |

### 5.6 `screen`

```json
{"op":"screen","args":{"format":"png","max_width":1920}}
```

| 项 | 约定 |
| --- | --- |
| 格式 | **PNG**（默认）；支持 `"jpeg"` + `args.quality`（1–100，默认 80） |
| 返回 | `out` = **纯 base64**，不带 `data:` 前缀 |
| 分块 | **P1 不分块**。单帧上限 4 MB 硬约束 |
| 尺寸控制 | `max_width` 等比缩放，**默认 1920**；仍超 3 MB 时自动降质量/再缩小直到达标 |
| 多显示器 | P1 只截**主屏** |
| 失败 | `ok=false`,`err="screen failed: ..."` |

> 1080p PNG 一般 0.5–2 MB，安全；4K 全屏 PNG 可能爆 4 MB，所以 `max_width` 默认限到 1920。

### 5.7 `input`

坐标一律 **绝对屏幕坐标**（主屏左上角为原点，单位像素），**不是**窗口相对坐标。

```json
// 点击
{"op":"input","args":{"kind":"click","x":100,"y":200,"button":"left","count":1}}
// 双击
{"op":"input","args":{"kind":"click","x":100,"y":200,"count":2}}
// 移动
{"op":"input","args":{"kind":"move","x":100,"y":200}}
// 按键（名称或虚拟键码）
{"op":"input","args":{"kind":"key","key":"ENTER","mods":["ctrl"]}}
{"op":"input","args":{"kind":"key","vk":13}}
// 文本（支持中文，走 SendInput + WM_CHAR）
{"op":"input","args":{"kind":"text","text":"hello 中文"}}
// 滚轮（delta 正=上滚，负=下滚，一格 120）
{"op":"input","args":{"kind":"wheel","x":100,"y":200,"delta":-120}}
```

| 字段 | 取值 |
| --- | --- |
| `kind` | `click` / `move` / `key` / `text` / `wheel` |
| `button` | `left`（默认）/ `right` / `middle` |
| `count` | 连击次数，默认 1 |
| `mods` | `ctrl` / `alt` / `shift` / `win` 数组 |
| `key` | 名称：`ENTER` `ESC` `TAB` `SPACE` `BACKSPACE` `UP` `DOWN` `LEFT` `RIGHT` `F1`–`F12` `A`–`Z` `0`–`9` |
| `vk` | Windows 虚拟键码（与 `key` 二选一，`vk` 优先） |
| `delta` | 滚轮量，一格 ±120 |

`out` = `""`（成功时）。

---

## 6. 关闭码与重连策略

| code | 含义 | Agent 应该怎么做 |
| --- | --- | --- |
| 4001 | 10 秒内未注册 | **重连**（协议层问题，可能网络抖动） |
| 4002 | 首帧不是合法 JSON | **重连** |
| 4003 | 首帧不是 `register` | **重连**（Agent 自身 bug，重连仍会失败，需排查） |
| 4004 | 入网码无效或已过期 | **停止重连**，等新入网码 |
| 4005 | 被控制面踢下线 | **停止重连**，等待人工处理 |
| 1006 / 1001 / 网络错误 | 正常关闭 / 服务端重启 | **重连**，退避 3s → 5s → 10s（之后固定 10s） |

---

## 7. 控制面 API（服务器本机 `127.0.0.1:8767`）

| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/health` | `{"ok":true,"nodes":N,"pending":N,"codes":N,"uptime_s":N}` |
| GET | `/nodes` | 节点列表（`node` / `info` / `remote` / `last_seen_ms_ago`） |
| POST | `/codes` | `{"ttl_s":3600}` → `{"ok":true,"code":"XXXX","ttl_s":3600}` |
| POST | `/task` | `{"node","op","args","timeout_ms"}` → `{"ok":true,"out":"...","err":""}` |
| POST | `/kick` | `{"node":"n_xxx"}` |

```bash
curl -s localhost:8767/health
curl -s -X POST localhost:8767/codes -H 'content-type: application/json' -d '{"ttl_s":3600}'
curl -s localhost:8767/nodes
curl -s -X POST localhost:8767/task -H 'content-type: application/json' \
  -d '{"node":"n_e109eb76aa82","op":"exec","args":{"cmd":"whoami"}}'
```

---

## 8. 路径与权限

- **允许绝对路径**，不限制根目录（这是运维工具，不是沙箱）
- Agent 以**当前登录用户**权限运行（网吧机器通常是管理员）
- `files.put` 不做路径白名单，调用方自己负责别写坏系统文件

---

## 9. 运维

```bash
systemctl status win-agent
systemctl restart win-agent
journalctl -u win-agent -f
tail -f /data/projects/MyWork/APKS/YouXin/audit/win-agent/logs/agent-audit.jsonl
```

---

## 10. 踩过的坑（别再踩）

1. **`flush_interval -1` 必须写进 Caddy 反代块** —— 少了它 WebSocket 被缓冲，指令延迟数秒。
2. **`/var/log/caddy/*.log` 属主必须是 `caddy:caddy`** —— root 建过同名文件会让 Caddy 启动失败，**整站全挂**。
3. **`admin off` 的 Caddy 不能 `reload`** —— 会报 `127.0.0.1:2019 connection refused`，用 `restart`。
4. **DNS 改完有 TTL 延迟** —— Windows 侧 `nslookup agent.zzd1.com 223.5.5.5` 核对，必要时临时加 hosts。
5. **入网码一次性** —— 每次联调重新签，别硬编码。

---

## 11. 当前实测状态（2026-10-22）

```
systemctl is-active win-agent        -> active
openssl s_client agent.zzd1.com:443  -> issuer=Let's Encrypt, subject=CN=agent.zzd1.com
wss://agent.zzd1.com/win-agent       -> open OK
register                             -> {"type":"registered","node":"n_96dd42bb4e13",...}
POST /task exec whoami               -> {"ok":true,"out":"win-demo\\tester","err":""}
```
