# RosWire 开发计划

本文是 `roswire` 的实现规格与阶段计划。用户入口、安装方式和使用示例见 [`../README.md`](../README.md)。

## 1. 项目目标

`roswire` 是面向 AI Agent 与自动化脚本的 RouterOS CLI 桥接工具。它不追求人类交互体验，而是优先保证输出稳定、错误可解析、调用可组合。

### 目标

- 提供非交互、JSON 优先（JSON-first）、可脚本化的 RouterOS 操作入口。
- 支持 RouterOS 原生 API 的 v6/v7 方言，以及 RouterOS v7 REST API。
- 严格隔离 `stdout` 与 `stderr`，便于 Agent 运行时捕获结果与错误。
- 产出稳定错误结构，帮助 Agent 根据错误上下文自我修正。
- 编译为单个原生二进制文件，不依赖外部运行时。

### 非目标

- 不替代 WinBox、WebFig 或 RouterOS 交互式终端。
- 不在 CLI 内保存长期会话或连接池。
- 不默认输出彩色文本、加载动画、进度条或分页器（pager）。
- 不在命令行交互式询问密码、确认操作或二次输入。

## 2. 核心设计原则

### 无状态执行

每次执行都是一次独立的 TCP/TLS/HTTP 生命周期。CLI 不假设上一次调用留下的本地状态。

### 确定性输出

在相同输入与相同 RouterOS 设备状态下，默认输出应尽量保持字节级稳定。

实现约束：

- 默认错误载荷不包含时间戳（timestamp）、随机 ID、耗时等运行时变化字段。
- JSON 对象中需要稳定顺序的字段使用结构体或 `BTreeMap`，避免直接序列化 `HashMap`。
- 调试信息必须显式打开，例如 `--debug`；默认不能污染 `stdout`。

### 流隔离

- `stdout`：只输出成功结果载荷。
- `stderr`：只输出诊断、调试信息与错误载荷。
- 所有错误都以单个 JSON 对象写入 `stderr`，并以非零状态码退出。

### 零交互

如果缺少必要参数，立即输出结构化错误并退出。禁止出现 `Are you sure? [y/n]`、密码提示或任何等待用户输入的流程。

## 3. CLI 与配置约定

### 命令格式

```text
roswire [global-options] <path...> <action> [key=value ...]
```

示例：

```bash
roswire ip address print --json
roswire ip address add address=192.168.88.2/24 interface=bridge --json
roswire ip address remove .id=*1 --json
```

### 全局选项

设备、连接和传输字段不再从 `ROS_*` 环境变量读取；统一优先级为命令行参数 > 本地配置 profile > 默认值。`ROSWIRE_HOME`、`ROSWIRE_DEBUG`、`ROSWIRE_MASTER_KEY` 以及 profile secret `type=env` 指向的自定义变量仍是进程级/secret 后端变量。

| 选项 | profile / secret | 说明 |
| --- | --- | --- |
| `--profile <name>` | `default_profile` / 单 profile | 选择 `config.toml` 中的 profile |
| `--host <host>` | `host` | RouterOS IP 地址或 DNS 主机名；不支持 MAC 地址直连 |
| `--user <user>` | `user` | RouterOS 用户名 |
| `--password <password>` | secret `password` | RouterOS 密码；不推荐在 shell history 中直接传入 |
| `--protocol <mode>` | `protocol` | `auto`、`api`、`api-ssl` 或 `rest` |
| `--routeros-version <mode>` | `routeros_version` | `auto`、`v6` 或 `v7`；用于选择原生 API 方言 |
| `--transfer <mode>` | `transfer` | 文件传输后端；当前唯一支持值为 `ssh` |
| `--ssh-port <port>` | `ssh_port` | RouterOS SSH 服务端口，默认 `22` |
| `--ssh-user <user>` | `ssh_user` | SSH 文件传输用户名；默认复用 API user |
| `--ssh-password <password>` | secret `ssh_password` / `password` | SSH 文件传输密码；默认复用 API password |
| `--ssh-key <path>` | `ssh_key` | SSH 私钥路径；设置后优先使用 key auth |
| - | secret `ssh_key_passphrase` | 加密 SSH 私钥 passphrase；通过 profile secret 非交互提供 |
| `--ssh-host-key <fingerprint>` | `ssh_host_key` | RouterOS SSH host key 指纹；用于非交互校验服务器身份 |
| `--allow-from <cidr>` | `allow_from` | 允许访问 SSH 服务的客户端来源 CIDR，用于 `/ip service ssh address` |
| `--ensure-ssh` | - | 允许 `roswire` 通过 API/REST 启用 SSH 服务并设置白名单 |
| `--restore-ssh` | - | 文件传输结束后恢复进入任务前的 SSH 服务配置 |
| `--port <port>` | `port` | 覆盖显式协议的默认端口；`auto` 模式下不接受单一端口覆盖 |
| `--json` | - | 输出机器可解析 JSON |
| `--debug` | `ROSWIRE_DEBUG` | 在 `stderr` 输出调试信息，必须脱敏 |

### 本地客户端目录

`roswire` 在用户目录下维护一个本地工作目录，用于配置、日志和少量运行状态：

```text
~/.roswire/
├── config.toml        # 默认配置文件，保存 profile、非敏感选项和 secret 引用
├── logs/              # 本地 JSONL 日志，最多保留 30 天
├── state/             # 运行状态；不得保存长期会话或 RouterOS 凭据明文
└── cache/             # 可删除缓存，例如远端 schema、能力探测和短期调试材料
```

实现约束：

- 首次运行可通过 `roswire config init` 创建目录和默认 `config.toml`。
- 目录权限必须尽量收敛：Unix/macOS 目标为 `0700`，`config.toml` 目标为 `0600`。
- 如果配置文件权限过宽，且包含任何 secret 或 secret 引用，返回 `CONFIG_INSECURE_PERMISSIONS`。
- 不在 `~/.roswire/` 中保存 RouterOS 长期会话、API token、明文日志或未脱敏错误上下文。
- `ROSWIRE_HOME` 可覆盖默认目录，仅用于测试、CI 或便携环境；错误上下文中只记录脱敏后的路径尾段。

### `config.toml` 结构

配置文件支持多个 profile。默认 profile 用于未显式传入 `--profile` 的调用。

```toml
version = 1
default_profile = "home"

[profiles.home]
host = "192.168.88.1"
user = "admin"
protocol = "auto"
routeros_version = "auto"
transfer = "ssh"
ssh_port = 22
ssh_host_key = "SHA256:replace-with-routeros-host-key"
allow_from = "203.0.113.10/32"

[profiles.home.secrets.password]
type = "keychain"
service = "roswire"
account = "profiles/home/password"

[profiles.home.secrets.ssh_password]
type = "same-as"
target = "password"

[logging]
enabled = true
retention_days = 30
level = "info"
```

配置解析规则：

- `--profile <name>` 选择 profile；未设置时使用 `default_profile`。
- 命令行参数覆盖环境变量；环境变量覆盖 profile；profile 覆盖默认值。
- `config.toml` 只保存非敏感字段和 secret 引用，不默认保存明文密码。
- 所有 profile 名称、secret account、日志路径都必须做最小化校验，避免路径穿越和日志污染。

### 密码与密钥保存策略

支持三种 secret 保存方式，但安全等级不同。

| 类型 | 配置写法 | 安全等级 | 结论 |
| --- | --- | --- | --- |
| 明文 | `type = "plain"` + `value = "..."` | 低 | 仅允许实验室、一次性测试或 CI 临时目录；必须配合 `0600` 权限和显式 `allow_plain_secrets = true` |
| 本地加密 | `type = "encrypted"` + `value = "v1:..."` | 中 | 可以降低误读配置文件的风险，但不能替代钥匙链；禁止使用编译进程序的固定密钥 |
| 本机钥匙链 | `type = "keychain"` + `service/account` | 高 | 默认推荐方案；macOS Keychain、Linux Secret Service、Windows Credential Manager |

`type = "same-as"` 只是引用另一个 secret，不是第四种保存方式；解析时必须检测循环引用。

明文示例：

```toml
[profiles.lab]
host = "192.168.88.1"
user = "admin"
allow_plain_secrets = true

[profiles.lab.secrets.password]
type = "plain"
value = "lab-only-password"
```

本地加密示例：

```toml
[profiles.edge.secrets.password]
type = "encrypted"
key_id = "default"
value = "v1:base64-nonce-and-ciphertext"
```

本机钥匙链示例：

```toml
[profiles.prod.secrets.password]
type = "keychain"
service = "roswire"
account = "profiles/prod/password"
```

评估结论：

- **默认使用本机钥匙链**。配置文件只保存 `service/account` 引用，实际密码交给操作系统凭据库保存。
- **明文密码保留为显式低安全选项**。它方便实验和离线调试，但如果文件权限不安全必须拒绝运行。
- **本地加密不能使用硬编码程序密钥**。固定密钥可以从二进制中提取，本质是可逆混淆；如果实现 `encrypted`，必须使用每机生成的 master key，并优先把 master key 存入本机钥匙链。
- 如果本机钥匙链不可用，`encrypted` 模式可以后续支持 `ROSWIRE_MASTER_KEY` 这类非交互 key source；MVP 阶段不交互式询问 passphrase。
- 所有 secret 进入内存后使用专门 secret 类型承载，日志、错误、debug、dry-run、shell 建议命令都必须脱敏。
- 写入钥匙链的命令必须支持 `--stdin` 或 `--env <VAR>` 输入 secret，避免出现在 shell history 中。

推荐命令形态：

```bash
roswire config init
roswire secret set home password --stdin
roswire secret set home ssh-password --same-as password
roswire secret set lab password --plain --stdin --allow-plain
```

### 日志与保留策略

`~/.roswire/logs/` 用于本地排障日志，不能影响 `stdout` / `stderr` 契约。

- 日志格式使用 JSON Lines（JSONL），每行一个事件。
- 日志文件按日期滚动，例如 `roswire-2026-05-16.log`。
- 默认保留 30 天；MVP 阶段 30 天是上限，可以配置更短或关闭日志，不能配置更长。
- 每次启动时执行轻量清理：删除超过保留期的日志文件。清理失败只写入脱敏 warning，不阻塞主命令。
- 日志中禁止记录密码、token、SSH 私钥路径全文、本地绝对路径、RouterOS backup 文件内容或完整命令行中的敏感值。
- `--debug` 只提高本次运行的日志详细度；仍必须遵守脱敏规则。

### Agent 自描述与详细帮助

`roswire` 的主要消费者是 Agent，因此必须提供稳定、机器可读、足够详细的自描述接口。Agent 不应解析人类帮助文本来理解命令。

设计目标：

- 所有帮助、配置检查、schema 输出都支持 `--json`。
- 自描述命令默认不访问 RouterOS，不产生设备侧副作用。
- 所有输出包含 `schema_version`，便于 Agent 做兼容判断。
- 所有配置输出必须脱敏，永不输出真实密码、token、私钥内容或本地绝对敏感路径。
- 人类 `--help` 可以简洁，但 JSON help 必须详细到足以让 Agent 生成安全命令。
- `roswire help --json` 是自定义机器可读帮助子命令，不是对 clap 人类 help 文本的二次解析；`roswire --help` 保留给人类阅读。

核心命令：

| 命令 | 作用 | 是否访问 RouterOS |
| --- | --- | --- |
| `roswire help --json` | 输出完整命令目录、全局选项和帮助 schema | 否 |
| `roswire help <command...> --json` | 输出单个命令的详细参数结构、示例、输出和错误 | 否 |
| `roswire commands --json` | 输出可用命令索引，适合 Agent 快速检索 | 否 |
| `roswire commands --remote --json` | 输出本地命令目录，并叠加目标设备能力状态 | 是 |
| `roswire schema command <command...> --json` | 输出命令参数 JSON Schema | 否 |
| `roswire schema command <command...> --remote --json` | 输出命令参数 JSON Schema，并叠加远端字段、枚举值和支持状态 | 是 |
| `roswire schema output <command...> --json` | 输出成功输出 JSON Schema | 否 |
| `roswire schema discover [path...] --remote --json` | 显式连接 RouterOS，生成当前设备的能力与 schema 覆盖快照 | 是 |
| `roswire config inspect --json` | 输出当前 profile 的解析后配置、来源和脱敏 secret 状态 | 否 |
| `roswire config profiles --json` | 输出本地 profile 列表和默认 profile | 否 |
| `roswire doctor --json` | 检查本地目录、配置权限、secret 后端和依赖状态 | 默认否；传 `--include-remote` 才访问 RouterOS |
| `roswire explain-error <code> --json` | 输出错误码含义、常见原因和建议下一步 | 否 |

`config inspect` 输出必须包含字段来源，便于 Agent 判断应修改 CLI 参数、环境变量还是 profile：

```json
{
    "schema_version": "roswire.config.inspect.v1",
    "active_profile": "home",
    "paths": {
        "home": "~/.roswire",
        "config": "~/.roswire/config.toml",
        "logs": "~/.roswire/logs"
    },
    "resolved": {
        "host": { "value": "192.168.88.1", "source": "profile" },
        "user": { "value": "admin", "source": "profile" },
        "protocol": { "value": "auto", "source": "default" },
        "transfer": { "value": "ssh", "source": "profile" },
        "password": { "status": "available", "type": "keychain", "source": "profile", "redacted": true },
        "ssh_host_key": { "value": "SHA256:...", "source": "profile" }
    },
    "logging": {
        "enabled": true,
        "retention_days": 30,
        "level": "info"
    },
    "warnings": []
}
```

命令帮助 JSON 必须包含以下结构：

```json
{
    "schema_version": "roswire.command.help.v1",
    "name": "ip address add",
    "summary": "添加 RouterOS IP address 记录。",
    "stability": "planned",
    "kind": "routeros-command",
    "syntax": "roswire ip address add address=<cidr> interface=<name> [disabled=<bool>] --json",
    "routeros_path": "/ip/address/add",
    "protocol_support": {
        "classic_api_v6": true,
        "classic_api_v7": true,
        "rest_v7": true,
        "rest_method": "PUT",
        "rest_path": "/rest/ip/address"
    },
    "side_effects": ["creates-routeros-record"],
    "idempotency": "not-idempotent",
    "requires_confirmation": false,
    "arguments": [
        {
            "name": "address",
            "style": "key-value",
            "required": true,
            "type": "cidr",
            "example": "192.168.88.2/24",
            "description": "要添加到接口上的 IP 地址。"
        },
        {
            "name": "interface",
            "style": "key-value",
            "required": true,
            "type": "string",
            "example": "bridge",
            "discovery_hint": "可先运行 roswire interface print --json 获取可用接口。"
        }
    ],
    "examples": [
        {
            "title": "给 bridge 添加地址",
            "command": "roswire ip address add address=192.168.88.2/24 interface=bridge --json",
            "expected_stdout_shape": "object",
            "notes": ["失败时检查 interface 是否存在。"]
        }
    ],
    "success_output": {
        "content_type": "application/json",
        "schema_ref": "roswire.output.routeros-record.v1"
    },
    "errors": ["USAGE_ERROR", "AUTH_FAILED", "NETWORK_ERROR", "ROS_API_FAILURE"],
    "self_correction": [
        {
            "when_error_contains": "no such interface",
            "next_command": "roswire interface print --json"
        }
    ]
}
```

命令目录规范：

- 每个命令必须登记 `kind`：`routeros-command`、`workflow`、`config`、`secret`、`introspection`。
- 每个会修改设备状态的命令必须声明 `side_effects` 和 `idempotency`。
- 每个参数必须声明 `style`：`positional`、`key-value`、`flag`、`option`。
- 每个参数必须声明 `required`、`type`、`description`，并尽量提供 `example`。
- 文件工作流必须声明本地路径、远端路径、临时文件、清理策略和 SSH 前置条件。
- 帮助示例中的命令不得包含真实密码、token、私钥路径或公网真实敏感地址。
- `doctor --include-remote --json` 才允许访问 RouterOS；普通 `help/schema/config inspect` 必须纯本地。

### 动态能力与 schema 发现

RouterOS 的菜单、字段和命令会随版本、package、硬件能力和授权状态变化。把所有 schema 都硬编码进 `roswire` 会很快过期；但反过来，完全依赖设备动态返回也不可靠，因为 RouterOS 官方公开文档中没有承诺提供完整、稳定、可直接生成 OpenAPI/JSON Schema 的元数据端点。REST API 本质上是 console API 的 JSON wrapper，支持通过 `POST` 调用任意 console command，但不等于提供完整 schema 注册表。

设计结论：采用“静态目录 + 远端覆盖 + 缓存”的混合模型。

| 层级 | 来源 | 用途 | 限制 |
| --- | --- | --- | --- |
| 静态目录 | `roswire` 随版本内置的命令目录、参数说明、示例、错误码和安全策略 | Agent 离线理解命令、生成基础调用、做稳定 snapshot 测试 | 需要随 `roswire` 发布更新，可能落后于最新 RouterOS |
| 远端覆盖 | 显式连接设备后，通过只读探测、版本信息、package 信息、协议能力和可观测字段生成 | 判断当前设备是否支持某命令、字段、协议和枚举候选值 | 只能作为 capability overlay，不应覆盖安全策略和副作用声明 |
| 运行时值发现 | 通过 `interface print`、`routing table print`、`system resource print` 等只读命令获取 | 为 Agent 提供接口名、表名、bridge 名称等候选值 | 这是当前设备数据，不是通用 schema；必须标记来源和时间 |
| 本地缓存 | `~/.roswire/cache/schema/` 下的可删除 JSON 快照 | 减少重复探测成本，便于 Agent 快速启动 | 缓存可失效、可删除、不可含 secret；不能作为唯一真相 |

动态发现命令必须显式要求访问远端：

```bash
roswire schema discover --remote --json
roswire schema discover ip address --remote --json
roswire schema command ip address add --remote --json
roswire commands --remote --json
```

默认的 `help --json`、`commands --json`、`schema command ... --json` 仍然只读取本地静态目录，不访问 RouterOS。这样可以保持自描述命令无副作用、低延迟、可离线，也避免 Agent 在“只是想查帮助”时误触发网络访问。

远端发现流程：

1. 按正常协议路由规则连接设备，解析 RouterOS 主版本、完整版本、build time、architecture、board-name 和 package 列表摘要。
1. 记录当前命中的协议：`rest`、`api-ssl` 或 `api`，以及原生 API 方言：`v6` 或 `v7`。
1. 对目标路径执行只读 capability probe，例如 `print`、`print count-only`、有限 `.proplist`、REST `GET` 或 REST `POST <path>/print`。
1. 从成功响应中提取可观测输出字段；从失败响应中只提取脱敏错误分类，不能把失败消息当成可靠 schema。
1. 对枚举值只做“候选值”标注，例如接口名、routing table、bridge、address-list 名称；不能把当前候选值误判为完整枚举。
1. 生成 `remote_overlay`，再与静态目录合并输出。
1. 如果启用缓存，把结果写入 `~/.roswire/cache/schema/<cache-key>.json`。

远端发现必须遵守安全边界：

- 只允许执行只读命令；禁止为了发现参数而执行 `add`、`set`、`remove`、`enable`、`disable`、`reset`、`run`、`import`、`backup save` 等有副作用命令。
- 禁止通过“故意发错参数再解析错误消息”的方式构造必填字段 schema。错误文本不稳定，而且容易制造设备日志噪音。
- `remote_overlay` 不能改变静态目录中的 `side_effects`、`idempotency`、secret 标记、文件传输安全规则和危险命令策略。
- 如果设备账号权限不足，返回降级后的 schema，并在 `warnings` 中标记 `CAPABILITY_PROBE_FAILED`；不能要求 Agent 提权。
- 动态发现默认不修改 SSH、API、REST 或防火墙服务配置；如果发现命令需要 SSH 文件传输，只能报告前置条件缺失。

缓存键建议包含：

```text
profile-name + host-id + routeros-version + build-time + architecture + board-name + packages-hash + selected-protocol
```

其中 `host-id` 必须是脱敏哈希，不能直接写入真实主机名、公网 IP 或用户名。缓存失效规则：

- RouterOS 版本、build time、package 摘要、协议能力任一变化时失效。
- 默认 TTL 不超过 7 天；用户可以配置更短，也可以关闭动态 schema 缓存。
- `roswire schema discover --remote --refresh --json` 强制绕过缓存重新探测。
- 普通命令执行不能因为缓存缺失而自动做全量 schema 发现；最多复用首次连接阶段已经拿到的版本与协议能力。

远端 schema 输出示例：

```json
{
    "schema_version": "roswire.remote.schema.v1",
    "schema_source": ["static_catalog", "remote_overlay", "runtime_values"],
    "profile": "home",
    "device": {
        "routeros_version": "7.15.3",
        "major": "v7",
        "architecture": "arm64",
        "board_name": "RB5009UG+S+",
        "selected_protocol": "rest"
    },
    "cache": {
        "status": "miss",
        "ttl_seconds": 604800,
        "cache_key": "sha256:..."
    },
    "commands": [
        {
            "name": "ip address add",
            "support": "supported",
            "schema_source": ["static_catalog", "remote_overlay"],
            "output_fields_observed": [".id", "address", "interface", "network", "disabled"],
            "runtime_value_hints": {
                "interface": ["bridge", "ether1"]
            },
            "warnings": []
        }
    ],
    "warnings": []
}
```

Agent 使用建议：

- 生成命令前先读本地 `commands --json` 或 `help <command> --json`。
- 需要匹配真实设备版本、接口名、REST/API 支持状态时，再调用 `schema ... --remote --json`。
- 如果 `remote_overlay` 与静态目录冲突，安全策略以静态目录为准；字段存在性和协议支持以远端覆盖为准。
- 如果远端探测失败，Agent 应根据 `warnings` 降级使用静态目录，而不是猜测未知字段。

### 默认端口

| 协议 | 默认端口 | 说明 |
| --- | ---: | --- |
| `api` | `8728` | RouterOS 原生 API |
| `api-ssl` | `8729` | 基于 TLS 的 RouterOS 原生 API |
| `rest` | `443` | RouterOS v7 REST，实际端口取决于设备上的 `www-ssl` 服务配置 |

### 协议自动选择与调用优先级

`protocol=auto` 是默认模式。它用于解决 RouterOS v7 同时支持原生 API 与 REST API 时的路由选择问题。

基本原则：显式配置优先，自动探测其次。

| 场景 | 选择策略 |
| --- | --- |
| 用户显式指定 `--protocol rest` | 只走 REST；如果设备不是 v7 或 REST 不可用，返回结构化错误 |
| 用户显式指定 `--protocol api` 或 `api-ssl` | 只走原生 API，并按 `--routeros-version` 选择或探测 v6/v7 方言 |
| `auto` + RouterOS v6 | 走原生 API v6 方言 |
| `auto` + RouterOS v7 + REST 可用 + 当前动作有 REST 映射 | 优先走 REST |
| `auto` + RouterOS v7 + REST 不可用 | 回落到原生 API v7 方言 |
| `auto` + RouterOS v7 + 当前动作没有 REST 映射 | 回落到原生 API v7 方言 |

自动探测候选顺序：

1. `rest`：尝试只读请求 `/rest/system/resource`，解析版本与 REST 可用性。
1. `api-ssl`：登录后执行 `/system/resource/print`，解析版本。
1. `api`：登录后执行 `/system/resource/print`，解析版本。

错误处理规则：

- 网络不可达、端口未开放、服务未启用：记录为候选失败，继续尝试下一个协议。
- 认证失败：立即返回 `AUTH_FAILED`，不再静默尝试其它协议，避免掩盖凭据问题。
- REST 返回 404/405 或缺少目标菜单映射：视为该动作不适合 REST，允许回落到 v7 原生 API。
- `auto` 模式下如果用户同时指定 `--port`，返回 `CONFIG_ERROR`；避免一个端口被误用于多个候选协议。

首次探测结果只在当前进程内复用，不写入磁盘缓存。CLI 仍保持无状态；下一次调用会重新探测，除非用户显式指定协议和版本。

### 原生 API 方言选择

RouterOS v6 与 v7 的原生 API 共享同一套 TCP/TLS sentence 协议，但不能在实现中完全混为一个后端。差异主要体现在登录流程、菜单字段、命令可用性、`!trap` 错误内容和返回字段归一化上。

设计结论：采用“共享传输层 + 分离方言层”。

- 共享部分：TCP/TLS 连接、word/sentence 编解码、`!re`/`!done`/`!trap` 解析、超时处理、脱敏日志、统一错误输出。
- v6 方言层：兼容旧式 challenge-response 登录与 v6.43+ 的现代登录流程，维护 v6 字段归一化和命令差异。
- v7 方言层：默认使用现代登录流程，维护 v7 字段归一化、命令差异，并与 REST 映射保持语义一致。
- `auto` 模式：默认先尝试现代登录；如遇到旧式登录特征再回退到 v6 challenge-response。登录成功后通过 `/system/resource/print` 的版本信息确认方言。
- 显式模式：`--routeros-version v6` 或 `--routeros-version v7` 跳过模糊探测，适合测试、CI 和受控生产环境。

允许 v6/v7 方言模块在映射表、归一化逻辑、错误 hint 上保留适度冗余。禁止复制底层 sentence codec、TCP/TLS I/O 和统一错误输出，否则后续 bug 修复会变成双份维护。

## 4. 数据模型与协议映射

Agent 通常使用 CLI 风格的层级命令。`roswire` 需要将空格分隔路径转换为 RouterOS API 路径，并将 `key=value` 参数转换为协议载荷。

### 路径映射

| CLI | 原生 API（v6/v7 方言） | REST API |
| --- | --- | --- |
| `roswire ip address print` | `/ip/address/print` | `GET /rest/ip/address` |
| `roswire ip address add address=1.1.1.1 interface=ether1` | `/ip/address/add` + `=address=1.1.1.1` + `=interface=ether1` | `PUT /rest/ip/address` + JSON 请求体 |
| `roswire ip address set .id=*1 disabled=true` | `/ip/address/set` + `=.id=*1` + `=disabled=true` | `PATCH /rest/ip/address/*1` + JSON 请求体 |
| `roswire ip address remove .id=*1` | `/ip/address/remove` + `=.id=*1` | `DELETE /rest/ip/address/*1` |

> REST 动词映射必须在实现阶段用 RouterOS v7 文档与真实设备测试验证；不要仅根据常见 REST 习惯推断。

### 参数规则

- `key=value` 原样保留 RouterOS 字段名，例如 `.id=*1`、`comment=wan uplink`。
- 布尔值、数字和字符串在 CLI 层保持字符串；协议层按目标 API 要求转换。
- 任何密码、令牌（token）、证书内容都不能出现在错误上下文或调试日志中。
- 未知动作（action）不应静默猜测；返回 `UNSUPPORTED_ACTION`，或在后续版本中设计显式原始命令（raw command）透传模式。

### 客户端文件上传下载

这里讨论的是本机运行 `roswire` 时，如何把本机文件上传到 RouterOS，或把 RouterOS 上的文件下载到本机。它不同于 `/file print`、`/file remove` 这类设备内文件菜单操作。

设计结论：`roswire` 应该提供一等公民的文件工作流，但内部必须分成“API/REST 编排”和“SSH 字节传输”两层。

- API/REST 负责控制面：生成备份、导出配置、执行导入、查询文件是否存在、删除临时文件。
- SSH transfer 后端负责数据面：真正把文件字节从本机传到路由器，或从路由器传回本机。
- 不能把 API sentence 或 REST JSON 当成通用大文件传输通道。

目标命令：

| 命令 | 工作流 |
| --- | --- |
| `roswire file upload <local> <remote>` | 通过 SSH transfer 后端上传本机文件到 RouterOS 文件系统 |
| `roswire file download <remote> <local>` | 通过 SSH transfer 后端把 RouterOS 文件下载到本机 |
| `roswire import <local.rsc>` | 上传 `.rsc` 到临时路径，执行 `/import file-name=...`，可选清理远端临时文件 |
| `roswire export download <local.rsc>` | 执行 `/export file=...`，等待文件出现，下载到本机，可选清理远端文件 |
| `roswire backup download <local.backup>` | 执行 `/system/backup/save name=...`，等待 `.backup` 文件出现，下载到本机，可选清理远端文件 |
| `roswire script put <name> --source @<local.rsc>` | 读取本地文本，通过 API/REST 写入 `/system/script source=...`，不创建 RouterOS 文件 |

SSH 认证规则：

- 默认复用 API `user` / `password` 作为 SSH 登录凭据，减少重复配置。
- 如果提供 `--ssh-user`、`--ssh-password` 或 `--ssh-key`，则 SSH transfer 使用显式 SSH 凭据，不影响 API/REST 控制面登录。
- 设置 `--ssh-key` 时优先使用 key auth；加密私钥 passphrase 不得交互式询问，必须通过 profile secret `ssh_key_passphrase` 非交互提供。
- SSH host key 必须非交互校验。MVP 阶段要求用户通过 `--ssh-host-key` 或 profile `ssh_host_key` 提供期望指纹；未知或不匹配时失败。
- 所有 SSH 凭据都属于敏感信息，不能进入错误上下文、debug 日志、dry-run 输出或 shell 建议命令。

SSH 服务准备：

| 步骤 | API/REST 动作 | 说明 |
| --- | --- | --- |
| 检查 SSH 服务 | `/ip/service/print where name=ssh` | 读取 `disabled`、`port`、`address` 当前值 |
| 启用 SSH | `/ip/service/set ssh disabled=no` | 仅当用户显式传入 `--ensure-ssh` 时允许执行 |
| 设置白名单 | `/ip/service/set ssh address=<merged-cidrs>` | 只允许把 `<cidr>` 追加/合并进现有 `address` 列表，禁止无提示替换为更宽范围 |
| 设置端口 | `/ip/service/set ssh port=<port>` | 仅当用户显式传入 `--ssh-port` 或 profile `ssh_port` 时修改 |
| 恢复配置 | `/ip/service/set ssh ...` | 仅当用户传入 `--restore-ssh` 时恢复任务前快照 |

SSH 白名单安全规则：

- `--ensure-ssh` 需要修改 SSH 服务时，必须提供 `--allow-from` 或 profile `allow_from`；不能默认写入 `0.0.0.0/0` 或 `::/0`。
- 如果当前 `/ip service ssh address` 已经覆盖客户端来源，则不需要修改白名单。
- 如果需要修改白名单，只能基于任务前快照做追加/合并，不能覆盖掉管理员已有地址限制。
- 如果传入的 CIDR 明显过宽（例如 `0.0.0.0/0`、`::/0`），返回 `SSH_WHITELIST_UNSAFE`。
- 如果启用了 `--restore-ssh`，必须在成功、失败和中断路径中尽力恢复任务前 SSH 服务配置；恢复失败要写入 `stderr` 结构化错误或警告，不能静默吞掉。

不做默认承诺：

- 不默认假设 REST 支持 multipart/form-data 上传。
- 不保留 FTP、`/tool fetch`、临时 HTTP server 等其它传输后端。
- 不在未传入 `--ensure-ssh` 时自动开启 RouterOS SSH 服务。
- 不自动修改防火墙规则；本阶段只管理 `/ip service ssh address` 白名单。
- 不默认通过 API/REST 读取或写入大文件内容。RouterOS 文件菜单对 `contents` 和 `/file/read` 有明确大小/分块限制，只能作为小文本或诊断能力。
- 不在用户未请求文件工作流时探测或修改 SSH 服务；普通 RouterOS 命令路径不应触碰 SSH。

MVP 阶段可以先实现 `dry-run` 计划和清晰错误：如果 SSH 服务未启用且用户没有传入 `--ensure-ssh`，返回 `SSH_SERVICE_UNAVAILABLE`；如果缺少白名单，返回 `SSH_WHITELIST_REQUIRED`。

所有文件传输命令都必须有：超时、大小限制、校验和校验、覆盖策略、临时文件清理策略和脱敏日志。文件路径必须做最小化规范化，禁止把本地绝对路径或敏感目录写入错误 payload。

## 5. 标准错误模型

为了让 Agent 能够解析错误并进行自我修正，所有错误必须使用稳定的 JSON 结构。

```rust
use serde::Serialize;
use std::collections::BTreeMap;

#[derive(Serialize)]
pub struct RosWireError {
    pub error_code: String,
    pub message: String,
    pub hint: Option<String>,
    pub context: ErrorContext,
}

#[derive(Serialize)]
pub struct ErrorContext {
    pub command: String,
    pub requested_protocol: String,
    pub selected_protocol: String,
    pub transfer_backend: Option<String>,
    pub routeros_version: String,
    pub host: String,
    pub resolved_args: BTreeMap<String, String>,
}
```

`requested_protocol` 记录用户请求值，例如 `auto`、`rest`、`api-ssl`。`selected_protocol` 记录实际命中的协议，例如 `rest`、`api-ssl`、`api` 或 `unknown`。`transfer_backend` 仅在文件传输工作流中设置，例如 `ssh`。`routeros_version` 使用解析后的方言值，例如 `v6`、`v7` 或 `unknown`。在登录失败、版本探测失败等早期错误路径中，不能为了填充这些字段而发起额外有副作用的探测命令。

### 错误码建议

| 错误码 | 含义 | 退出码 |
| --- | --- | ---: |
| `USAGE_ERROR` | CLI 参数缺失或格式错误 | `2` |
| `CONFIG_ERROR` | 环境变量或配置不完整 | `2` |
| `PROFILE_NOT_FOUND` | 请求的本地配置 profile 不存在 | `2` |
| `CONFIG_INSECURE_PERMISSIONS` | `~/.roswire` 或 `config.toml` 权限过宽 | `2` |
| `SECRET_BACKEND_UNAVAILABLE` | 请求的 secret 后端不可用，例如系统钥匙链不可用 | `2` |
| `SECRET_NOT_FOUND` | 配置引用的 secret 不存在 | `2` |
| `SECRET_DECRYPT_FAILED` | 本地加密 secret 解密失败 | `3` |
| `AUTH_FAILED` | RouterOS 认证失败 | `3` |
| `NETWORK_ERROR` | TCP/TLS/HTTP 连接失败 | `4` |
| `ROS_API_FAILURE` | RouterOS 返回 trap/error | `1` |
| `UNSUPPORTED_ACTION` | 当前 action 无映射或未实现 | `2` |
| `SSH_SERVICE_UNAVAILABLE` | RouterOS SSH 服务未启用，且用户未允许自动开启 | `2` |
| `SSH_HOST_KEY_REQUIRED` | SSH 文件传输缺少 RouterOS host key 指纹 | `2` |
| `SSH_HOST_KEY_MISMATCH` | RouterOS SSH host key 与期望指纹不匹配 | `3` |
| `SSH_WHITELIST_REQUIRED` | SSH 文件传输缺少来源白名单 CIDR | `2` |
| `SSH_WHITELIST_UNSAFE` | SSH 白名单 CIDR 过宽或会放宽现有访问限制 | `2` |
| `SSH_RESTORE_FAILED` | 文件传输后恢复 SSH 服务配置失败 | `6` |
| `FILE_TOO_LARGE` | 文件超过当前后端或安全策略限制 | `2` |
| `FILE_TRANSFER_FAILED` | SSH 文件传输执行失败 | `6` |
| `SERIALIZATION_ERROR` | JSON 编解码失败 | `5` |
| `HELP_TOPIC_NOT_FOUND` | 请求的帮助主题或命令不存在 | `2` |
| `SCHEMA_UNAVAILABLE` | 请求的命令 schema 尚未登记 | `2` |
| `REMOTE_SCHEMA_UNAVAILABLE` | 远端 schema 或能力覆盖不可用，只能使用静态目录 | `2` |
| `CAPABILITY_PROBE_FAILED` | 远端只读能力探测失败，输出已降级 | `1` |
| `REMOTE_SCHEMA_STALE` | 本地远端 schema 缓存已过期或与设备指纹不匹配 | `2` |
| `INTERNAL_ERROR` | 未预期内部错误 | `5` |

## 6. 模块划分

```text
src/
├── main.rs            # 入口；全局错误捕获；stdout/stderr 分流
├── args.rs            # clap 定义与 CLI 解析
├── error.rs           # 统一错误类型与 JSON 输出
├── mapping.rs         # CLI path/action 到协议请求的转换
├── config/
│   ├── mod.rs         # 配置解析入口与优先级合并
│   ├── paths.rs       # ~/.roswire、ROSWIRE_HOME、权限检查
│   ├── file.rs        # config.toml 读写与 schema version
│   ├── profiles.rs    # profile 解析、继承与默认值
│   ├── secrets.rs     # plain/encrypted/keychain secret 解析与脱敏
│   └── logging.rs     # JSONL 日志、滚动与 30 天保留策略
├── introspect/
│   ├── mod.rs         # Agent 自描述入口
│   ├── catalog.rs     # 命令目录与索引
│   ├── help.rs        # 机器可读详细帮助
│   ├── schema.rs      # 参数与输出 JSON Schema
│   ├── discovery.rs   # 远端 schema/capability 只读探测与覆盖合并
│   ├── cache.rs       # ~/.roswire/cache/schema 下的动态 schema 缓存
│   ├── config.rs      # config inspect/profiles 的脱敏输出
│   └── doctor.rs      # 本地/远端诊断检查
├── workflow/          # 跨协议编排，不直接实现底层协议
│   ├── mod.rs         # 工作流入口与计划模型
│   ├── import.rs      # 上传 .rsc 并执行 /import
│   ├── export.rs      # 生成并下载 .rsc
│   ├── backup.rs      # 生成并下载 .backup
│   └── script.rs      # 小文本写入 /system/script source
├── protocol/
│   ├── mod.rs              # Protocol trait 与协议路由器
│   ├── discovery.rs        # 首次连接探测、协议优先级与能力选择
│   ├── classic/
│   │   ├── mod.rs          # 原生 API 共享入口
│   │   ├── transport.rs    # TCP/TLS 连接与超时控制
│   │   ├── sentence.rs     # word/sentence 编解码
│   │   ├── login.rs        # 现代登录与旧式 v6 登录回退
│   │   ├── dialect.rs      # v6/v7 方言 trait 与能力描述
│   │   ├── v6.rs           # RouterOS v6 方言实现
│   │   └── v7.rs           # RouterOS v7 方言实现
│   └── rest_v7.rs          # RouterOS v7 REST 实现
└── transfer/               # 客户端 ↔ RouterOS SSH 文件传输
    ├── mod.rs              # 文件传输 trait、计划模型与统一错误映射
    ├── ssh.rs              # SSH 文件上传下载实现
    └── ssh_service.rs      # 通过 API/REST 检查、启用、白名单与恢复 SSH 服务
```

## 7. 依赖策略

限制依赖是项目目标之一，但“零运行时依赖”不等于“零 Rust crate”。优先选择同步、阻塞式实现，避免为了单次 CLI 调用引入完整异步运行时。

推荐起点：

```toml
[dependencies]
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "1"
toml = "0.8"
directories = "6"
keyring = "3"
secrecy = "0.10"
zeroize = "1"
chacha20poly1305 = "0.10"
rand = "0.9"
schemars = "0.8"

# REST 客户端候选：实现时二选一，并禁用默认 TLS 后端。
# reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
# ureq = { version = "3", default-features = false, features = ["json", "rustls"] }

# SSH 文件传输：选用 ssh2，统一封装 SCP/SFTP 能力。
ssh2 = "0.9"
```

约束：

- 不默认使用 `tokio = { features = ["full"] }`，除非后续证明需要长连接或并发任务。
- TLS 优先使用 Rustls，减少系统 OpenSSL 依赖带来的发布复杂度。
- SSH 文件传输依赖固定为 `ssh2`。实现时优先尝试 SFTP；如果目标 RouterOS 版本不支持 SFTP，再验证是否可用 SCP。
- `ssh2` 会引入 `libssh2` 及其 native 依赖链，发布计划必须覆盖 macOS/Linux 静态或半静态打包。
- 如果 `ssh2` 在目标平台无法可靠打包，必须先记录为发布 blocker，不自动切换到其它 SSH 库。
- `keyring` 是默认 secret 后端依赖；如果目标平台没有可用钥匙链，必须返回结构化错误，不回退为明文。
- `chacha20poly1305` 只用于 `encrypted` secret；master key 不能硬编码进程序。
- `schemars` 用于生成和测试 Agent 可读 JSON Schema；命令 help 仍需要维护人工说明、示例和自愈提示。
- 所有新增依赖都必须说明用途、二进制体积影响与替代方案。

## 8. 测试计划

### 单元测试

- CLI path/action 解析与协议映射。
- `stdout`/`stderr` 分流，不允许错误进入 `stdout`。
- 错误 JSON 快照，字段顺序和脱敏规则必须稳定。
- 配置优先级：CLI 参数 > 本地配置 profile > 默认值。
- 本地配置目录：`ROSWIRE_HOME` 覆盖、`~/.roswire` 创建、权限过宽返回 `CONFIG_INSECURE_PERMISSIONS`。
- `config.toml`：profile 选择、schema version、缺失字段默认值和未知字段报错策略。
- profile 选择：`--profile`、`default_profile`、单 profile 推导和 profile 不存在错误。
- Secret 解析：plain 需要显式允许、encrypted 解密失败、keychain 缺失、same-as 引用循环。
- 日志保留：默认 30 天、不能配置更长、清理过期文件、日志脱敏。
- Agent 自描述：`help --json`、`help <command> --json`、`commands --json`、`schema command/output --json` 输出稳定 snapshot。
- 动态 schema：默认 schema 命令不访问远端；`--remote` 才探测 RouterOS；缓存键、TTL、失效和脱敏规则稳定。
- 远端覆盖合并：静态目录的安全策略不被覆盖，远端字段和协议支持可以修正 capability。
- 能力探测降级：权限不足、路径不存在、REST/API 差异返回 `CAPABILITY_PROBE_FAILED` warning，而不是崩溃或猜测 schema。
- 配置检查：`config inspect --json` 输出来源、脱敏 secret 状态、日志策略和 warnings。
- 帮助安全：帮助示例、doctor 输出和 dry-run 不包含真实 secret 或本地敏感绝对路径。
- `auto` 协议选择：v7 同时可用 REST 与原生 API 时 REST 优先。
- `auto` 回落：REST 不可用或动作无 REST 映射时回落到 v7 原生 API。
- 显式协议优先：`--protocol api`、`api-ssl`、`rest` 不被自动改道。
- 认证失败不回落：任一候选协议确认认证失败后立即返回 `AUTH_FAILED`。
- SSH 文件传输工作流：上传脚本、导入 `.rsc`、生成并下载 backup/export 的计划生成。
- SSH 服务准备：未启用 SSH 且缺少 `--ensure-ssh` 时返回 `SSH_SERVICE_UNAVAILABLE`。
- SSH host key：缺少 `--ssh-host-key` / profile `ssh_host_key` 时返回 `SSH_HOST_KEY_REQUIRED`；不匹配时返回 `SSH_HOST_KEY_MISMATCH`。
- SSH 白名单：需要修改白名单但缺少 `--allow-from` 或 profile `allow_from` 时返回 `SSH_WHITELIST_REQUIRED`。
- SSH 白名单安全：拒绝 `0.0.0.0/0`、`::/0`，且不会覆盖管理员已有地址限制。
- SSH 服务恢复：成功、失败和中断路径都生成恢复计划；恢复失败返回 `SSH_RESTORE_FAILED`。

### 集成测试

- RouterOS CHR 或测试设备上的原生 API 登录、print、add、set、remove。
- RouterOS v6 原生 API：旧式 challenge-response 登录、v6.43+ 现代登录、典型字段差异。
- RouterOS v7 原生 API：现代登录、典型字段差异、与 REST 语义一致性。
- RouterOS v7 REST 登录、print、add、set、remove。
- RouterOS v7 双协议环境：验证 REST 优先、原生 API 回落和错误上下文中的实际协议。
- 认证失败、接口不存在、TLS 失败、REST 404/401 等错误路径。
- SSH 文件传输后端：验证上传 `.rsc` 后导入、生成 `.backup` 后下载、白名单设置、服务恢复、大小限制、超时、覆盖策略和校验和失败路径。
- SSH 凭据路径：密码复用、显式 SSH 凭据、key auth、host key 校验、加密私钥不支持路径都要有稳定错误。
- 本机钥匙链：macOS Keychain、Linux Secret Service、Windows Credential Manager 至少各有 smoke test 或 documented fallback；执行入口见 [`keychain-smoke.md`](keychain-smoke.md)。
- Agent 远端诊断：`doctor --include-remote --json` 访问 RouterOS 时必须声明 selected protocol、routeros version、服务状态和脱敏错误。

### 发布验证

- `cargo fmt --check`
- `cargo clippy -- -D warnings`
- `cargo test`
- Linux/macOS 发布二进制冒烟测试。

## 9. 里程碑

| 阶段 | 主题 | 交付物 |
| --- | --- | --- |
| M0 | 项目初始化 | 创建 `Cargo.toml`、`src/main.rs`、基础 CI |
| M1 | CLI、本地配置与自描述 | 实现 `args.rs`、`config/`、`introspect/`、`error.rs`、`mapping.rs`、`~/.roswire/config.toml` 和静态 schema 目录 |
| M2 | 协议抽象与自动选择 | 定义 `Protocol` trait、统一请求/响应类型、首次探测与优先级策略 |
| M3 | 原生 API 共享层 | 实现 RouterOS API sentence（协议句）编解码、TCP/TLS 传输和登录流程 |
| M4 | v6/v7 方言 | 实现原生 API v6/v7 方言、字段归一化和兼容测试 |
| M5 | REST API | 实现 v7 REST 映射、HTTP 状态码归一化和 JSON 解析 |
| M6 | 动态 schema 与 SSH 文件传输 | 实现远端 schema 覆盖、缓存、SSH 服务准备、白名单合并/恢复、上传下载和文件工作流编排 |
| M7 | 测试与文档 | 完成单元测试、集成测试、README 示例校正 |
| M8 | 发布与生产级门槛 | GitHub Releases、校验和、基础安装说明、[`production-readiness.md`](production-readiness.md) 中的 P0 blocker 关闭 |

## 10. Agent 自愈闭环

当 Agent 调用 `roswire` 失败时，设计闭环如下：

1. Agent 执行 `roswire ip address add interface=ether99 address=10.0.0.1/24 --json`。
1. RouterOS 返回 `no such interface (ether99)`。
1. `roswire` 向 `stderr` 写入标准 JSON 错误，退出码为 `1`。
1. Agent 运行时检测到非零退出码，读取并解析 `stderr`。
1. Agent 从 `message` 或 `hint` 中识别接口不存在。
1. Agent 自动执行 `roswire interface print --json` 获取可用接口，再生成下一步修正命令。

## 11. 给实现 Agent 的启动提示

```text
你是一名资深 Rust 工程师。请按照 docs/develop-plan.md 实现 roswire。

第一项任务：
1. 初始化 Rust 项目，创建 Cargo.toml 与 src/main.rs。
2. 实现 src/error.rs、src/args.rs、src/config/ 与 src/mapping.rs。
3. 实现 `protocol=auto` 的协议探测骨架：REST、api-ssl、api 的候选顺序和错误分类。
4. 实现 SSH 文件传输的 CLI 配置骨架：--transfer ssh、--ssh-host-key、--ensure-ssh、--allow-from、--restore-ssh。
5. 实现 Agent 自描述骨架：help --json、commands --json、config inspect --json、schema command --json；默认只读本地静态目录。
6. 预留动态 schema 骨架：schema discover --remote --json、schema command ... --remote --json、远端覆盖合并与 ~/.roswire/cache/schema 缓存接口。
7. 确保错误只以稳定 JSON 写入 stderr。
8. 默认载荷中不要包含时间戳或随机 ID。
9. 使用 BTreeMap 保证参数排序稳定。
10. 为 CLI 解析、本地配置、secret 解析、日志保留、Agent 自描述、动态 schema 缓存、协议自动选择、SSH 服务准备计划与错误序列化添加单元测试。
```
