# EFKA 与 IOT 交互协议

本文档描述 `efka` 与 `iot` 之间的 TLS 长连接协议。当前协议由 Erlang term 直接序列化，发送端使用 `term_to_binary/1`，接收端使用 `binary_to_term(PacketBin, [safe])`。

协议帧只使用 safe external term：顶层 label、业务 label、map key 使用 binary；`Ref` 使用 `crypto:strong_rand_bytes(16)` 生成，是 16 字节 binary。网络协议里不发送 Erlang `reference()`，也不依赖动态创建 atom。

## 传输层

- `efka` 作为 TLS client 连接 `iot`。
- `iot` 作为 TLS server 接收多个 `efka` 连接，一个连接对应一个 `ssl_channel` 进程。
- socket 使用 `{packet, 4}`，每个 Erlang term binary 作为一个完整包发送。
- `Ref` 使用 `crypto:strong_rand_bytes(16)` 生成，只在当前连接的 inflight 表内匹配。

## 顶层帧

协议顶层 tuple 用来表达交互语义：

```erlang
{<<"request">>, Ref, Body}
{<<"response">>, Ref, Reply}
{<<"command">>, Ref, {Domain, Payload}}
{<<"command_response">>, Ref, {Domain, Reply}}
{<<"message">>, Body}
```

语义说明：

| 帧 | 方向 | 语义 |
| --- | --- | --- |
| `{<<"request">>, Ref, Body}` | efka -> iot | efka 发起请求，需要 iot 回复 |
| `{<<"response">>, Ref, Reply}` | iot -> efka | iot 对 efka request 的回复 |
| `{<<"command">>, Ref, {Domain, Payload}}` | iot -> efka | iot 下发命令，需要 efka 回复 |
| `{<<"command_response">>, Ref, {Domain, Reply}}` | efka -> iot | efka 对 iot command 的回复 |
| `{<<"message">>, Body}` | 双向 | 异步消息，不要求回复 |

`command` 和 `command_response` 的 `Domain` 表示业务域，目前支持：

- `<<"container">>`

## 鉴权请求

初始连接由 `efka` 发起鉴权 request。每条 TLS 连接只允许一次鉴权；`iot` 侧鉴权成功后会在 `ssl_channel` 标记该连接已鉴权，如果同一连接再次发送 `auth_request`，`iot` 会直接关闭连接。

```erlang
{<<"request">>, Ref, {<<"auth_request">>, #{
    <<"uuid">> => UUID,
    <<"token">> => Token,
    <<"timestamp">> => Timestamp
}}}
```

`iot` 回复：

```erlang
{<<"response">>, Ref, {<<"auth_response">>, <<"ok">>}}
{<<"response">>, Ref, {<<"auth_response">>, {<<"error">>, {<<"failed">>, Reason}}}}
```

处理语义：

- `<<"ok">>`：`efka` 进入 `activated` 状态。
- `{<<"error">>, {<<"failed">>, Reason}}`：鉴权失败，`iot` 返回失败响应后关闭连接；`efka` 进入重连流程。

## 授权控制

`/host/activate` 只修改 `iot` 本地和持久化的 host 授权状态，不再向 `efka` 下发 auth command。`efka` 可以继续保持连接并发送数据，是否处理这些数据由 `iot_host` 当前状态决定。

因此当前协议没有 `{<<"command">>, Ref, {<<"auth">>, ...}}` 和 `{<<"command_response">>, Ref, {<<"auth">>, ...}}`。授权关闭时，`iot_host` 保持 channel 在线，但不处理上报数据；授权重新打开后，已在线的 channel 可以继续使用。

## 容器管理命令

`iot` 对 `efka` 的容器管理使用 command 语义：

```erlang
{<<"command">>, Ref, {<<"container">>, CommandMap}}
```

`efka` 回复：

```erlang
{<<"command_response">>, Ref, {<<"container">>, Reply}}
```

`Reply` 取值：

```erlang
<<"ok">>
{<<"ok">>, Result}
{<<"error">>, Reason}
```

`CommandMap` 使用 binary key 和 binary action；`efka` 接收后直接按 binary key/action 匹配，Docker 参数链路继续使用 binary-key map，不再转换成 atom-key map。

### list

```erlang
#{<<"action">> => <<"list">>}
```

返回当前 `efka` 主机上的容器列表。

### deploy

```erlang
#{
    <<"action">> => <<"deploy">>,
    <<"task_id">> => TaskId,
    <<"params">> => Params
}
```

触发容器部署。部署过程中的流式日志不通过该 command response 返回，而是通过 `message` 的 `task_event` 上报。

### start

```erlang
#{
    <<"action">> => <<"start">>,
    <<"target">> => Target
}
```

### stop

```erlang
#{
    <<"action">> => <<"stop">>,
    <<"target">> => Target,
    <<"timeout_seconds">> => TimeoutSeconds
}
```

### kill

```erlang
#{
    <<"action">> => <<"kill">>,
    <<"target">> => Target,
    <<"signal">> => Signal
}
```

### remove

```erlang
#{
    <<"action">> => <<"remove">>,
    <<"target">> => Target,
    <<"force">> => Force,
    <<"remove_volumes">> => RemoveVolumes
}
```

### config

```erlang
#{
    <<"action">> => <<"config">>,
    <<"target">> => Target,
    <<"config">> => Config
}
```

更新容器配置文件。

### Target

容器目标使用 map 表示：

```erlang
#{
    <<"name">> => ContainerName,
    <<"id">> => ContainerId
}
```

`name` 和 `id` 至少一个非空；优先使用 `name`，`name` 为空时使用 `id`。

## 异步消息

`message` 不带 `Ref`，不要求对端回复。

### efka -> iot: data

```erlang
{<<"message">>, {<<"data">>, #{
    <<"route_key">> => RouteKey,
    <<"metric">> => Metric
}}}
```

用于 `efka` 上报业务指标数据。

### efka -> iot: task_event

```erlang
{<<"message">>, {<<"task_event">>, #{
    <<"task_id">> => TaskId,
    <<"type">> => Type,
    <<"stream">> => Stream
}}}
```

任务事件流关闭时：

```erlang
{<<"message">>, {<<"task_event">>, #{
    <<"task_id">> => TaskId,
    <<"type">> => <<"close">>,
    <<"stream">> => Reason
}}}
```

`task_event` 本身不携带 `uuid`。`iot` 在接收该消息时使用当前已鉴权 `ssl_channel` 绑定的 host UUID，把事件路由到内部任务进程 `{UUID, TaskId}`。HTTP 页面通过 SSE 订阅时也必须使用同一组参数：

```http
GET /event_stream?uuid=<host_uuid>&task_id=<task_id>
```

`iot` 会为每个 `{UUID, TaskId}` 维护一个独立的任务进程，用于缓存最近的部署日志、支持多个 SSE listener，并在收到 close 事件后结束事件流。

### efka -> iot: ping

```erlang
{<<"message">>, <<"ping">>}
```

用于 TLS 长连接的应用层保活。`efka` 在鉴权成功后周期发送，当前发送间隔为 30 秒。

`iot` 收到后回复：

```erlang
{<<"message">>, <<"pong">>}
```

`ping/pong` 只表示 TLS 连接仍可读写，不参与 host online/offline 判定。host 上下线仍由 UDP 心跳和 `iot_host` 本地连接状态共同维护。

### iot -> efka: pub

```erlang
{<<"message">>, {<<"pub">>, #{
    <<"topic">> => Topic,
    <<"qos">> => Qos,
    <<"content">> => Content
}}}
```

用于 `iot` 向 `efka` 本地订阅系统发布 topic 消息。

## 状态与超时

- `efka` 鉴权超时时间：5 秒。
- `iot` command inflight 超时时间：60 秒。
- `iot` SSL channel 空闲超时时间：120 秒。120 秒内没有收到任何 TLS 包，包括 `ping`、业务 `message`、`request`、`command_response`，`iot` 会主动关闭该连接。
- `iot` 管理多个 `efka` 时，每个连接有独立 `ssl_channel` 和独立 inflight 表。
- command 超时后，`iot` 删除 inflight 记录；之后如果迟到的 `command_response` 到达，会被视为未预期响应。

## 兼容性

当前协议不兼容旧 tuple：

- 旧容器管理：`{request, Ref, {container_request, ...}}`
- 旧容器回复：`{response, Ref, {container_response, ...}}`
- 旧授权控制：`{message, {auth_control, Command}}`
- 已移除的 auth command：`{command, Ref, {auth, activate | deactivate}}`
- 旧 Ref：Erlang `reference()`，例如 `make_ref()` 生成的值。

如果需要滚动升级，应先增加临时兼容分支或引入协议版本协商。
