[{"data":1,"prerenderedAt":9},["ShallowReactive",2],{"document-zh-manual":3},{"slug":4,"label":5,"description":6,"text":7,"plain":8},"manual","User manual","Installation, serial binding, protocols, licensing, and troubleshooting.","# BetterOne Console Server 操作手册\n\n[English edition](..\u002Fen\u002FUSER_MANUAL.md)\n\n**更新日期：2026 年 10 月 13 日**\n\n面向部署和使用本产品的客户。开发、打包及授权协议细节请见[内部文档](..\u002FREADME.md#内部开发维护)。\n\n网页“系统”页底部的“文档与法律信息”可打开操作手册及法律文档；登录页底部也可查看用户协议和隐私政策。阅读页在新标签页打开，无需登录或连接外网，可切换中文 \u002F English。\n\n\n## 安装与首次登录\n\n将适合 Windows、Linux 或 Raspberry Pi 的发布包部署到主机，启动对应可执行文件，并确认串口驱动及设备访问权限。Linux 应给运行账户必要的串口访问权限；Windows 应确认设备管理器中可见目标 COM 口。\n\n当前发布目标：Windows x64（amd64）；Linux amd64、arm64、armv7（32 位 ARM，GOARM=7）。树莓派按操作系统架构选择 arm64 或 armv7，不只按板子型号选择；Windows 不提供 x86 安装包。\n\n默认 Web\u002FAPI 监听为 `0.0.0.0:80`，通过 `http:\u002F\u002F\u003C服务器IPv4>\u002F` 访问；实际以 `http_host`、`http_port` 配置及启动日志为准。端口占用或权限不足会影响启动。当前仅支持 IPv4。\n\n新数据库只创建待初始化的 `admin`，没有默认密码，不自动创建 `engineer`。首次打开 Web 页面，设置并确认至少 8 个字符，不能仅含字母或仅含数字的管理员密码，完成后正常登录。初始化前空密码、Web 登录、SSH 登录及管理员操作不可用；其他用户由管理员创建。初始化接口仅能成功一次，已有数据库中的账户及密码保持原样。\n\n管理员负责账户、API Key、许可证及全量配置操作；工程师可进行日常串口和协议配置。工程师账户需要由管理员自行创建。\n\nAPI 自动化初始化：先使用 `GET \u002Fapi\u002Fauth\u002Finitialization` 查询 `required`，再向 `POST \u002Fapi\u002Fauth\u002Finitialize` 提交 `password` 和 `confirm_password`。两次密码须一致且至少 8 个字符，不能仅含字母或仅含数字；成功返回 204，已初始化返回 409，不会重置已有密码。随后通过正常登录接口登录。`--init-config` 仍只生成启动配置及密钥，不设置管理员密码。\n\n### 选择安装方式\n\n- **Windows 服务版**：以管理员身份运行 `betterone-cs_\u003C版本>_Windows_x86_64_setup.exe`。安装器通过 WinSW 注册 `BetterOneConsoleServer` 服务并立即启动；安装目录为 Program Files 下的 `BetterOneConsoleServer`，配置、数据和日志在 ProgramData 下。\n- **Linux deb\u002Frpm**：安装对应架构的软件包。安装器使用专用账户 `betterone-console-server`，启用并重启 systemd 服务；普通用户直接启动无法绑定 80 时可改为 8080。\n- **绿色版 ZIP\u002Ftar.gz**：解压后先运行 `betterone-cs --init-config`，检查配置，再运行 `betterone-cs`。Windows 使用 `.exe`；Linux 当前目录执行时加 `.\u002F`。默认 `portable` 模式，配置和数据位于程序旁的 `config\u002F`、`data\u002F`。\n\n安装版升级会重启服务、断开当前串口会话。安装器不自动搬移其他目录的旧数据；升级前先停止程序并备份。卸载安装版保留配置、数据库、许可证、密钥、安装 ID 及日志，不等于恢复出厂。\n\n### 服务管理与日志\n\nWindows 管理员 PowerShell：\n\n```powershell\nGet-Service BetterOneConsoleServer\nRestart-Service BetterOneConsoleServer\nStop-Service BetterOneConsoleServer\nGet-Content \"$env:ProgramData\\BetterOne\\ConsoleServer\\logs\\betterone-console-service.err.log\" -Tail 50\n```\n\nLinux：\n\n```sh\nsudo systemctl status betterone-console-server\nsudo systemctl restart betterone-console-server\nsudo systemctl stop betterone-console-server\njournalctl -u betterone-console-server.service -n 50\n```\n\n启动日志中的 `configuration file:` 和 `data directory:` 是实际使用路径；排查“改了配置却未生效”时先核对这两项，再确认服务已重新启动。\n\n**Linux 上不要用 root 直接运行服务程序**（例如 `sudo betterone-cs -system`）：它写出的数据库等文件会属于 root，服务账户之后打不开。程序会拒绝这样运行；调试时请用 `systemctl` 管理服务，或以服务账户运行（`sudo -u betterone-console-server betterone-cs -system ...`）。\n\n### 初始化都做什么\n\n“初始化”由四件事组成，发生在不同的时机，都**不会覆盖已有的内容**。\n\n| 时机 | 做什么 |\n| --- | --- |\n| **配置初始化**：`--init-config`，或任何一次启动的开头 | 创建配置目录和数据目录，并检查数据目录能写（写一个临时文件试探）；生成配置文件 `settings.toml`（权限 0600）：默认值（Web 监听 `0.0.0.0:80`、`ssh_host_key`、`license.txt`、`license_server`、`runtime.db`），随机的 `secret_key`（会话加密认证）和 `api_key_pepper`（API Key 哈希），以及 `device_uuid`（设备身份里的 `generated`，同时写入机器级文件，见“设备标识”）。`--init-config` 只做这一步，然后退出：**不创建数据库，也不启动服务**。deb、rpm 的安装脚本调用的就是它 |\n| **数据库初始化**：第一次正常启动 | 打开或新建 `runtime.db`（SQLite，WAL 模式）并建表；写入 48 个逻辑控制台的默认配置；创建用户 `admin`，**没有密码**（待初始化），不创建 `engineer`，没有默认密码。只有 48 个控制台的配置不完整时才会重写，已有数据不动 |\n| **SSH 主机密钥**：第一次启动 SSH 监听 | `ssh_host_key` 不存在就生成一把 Ed25519 密钥并保存（权限 0600），已有的不替换 |\n| **管理员首次设置密码**：第一次打开网页 | 库里只有“没有密码的 `admin`”时，登录页显示“设置管理员密码”，输入两遍（至少 8 个字符，不能仅含字母或仅含数字）；只能成功一次，之后再调用接口返回 409；设置完成后回到正常登录，不会自动登录 |\n\n**48 个控制台的默认配置**\n\n| 项 | 默认值 |\n| --- | --- |\n| 串口参数 | 115200，8 位数据位，1 位停止位，无校验，无流控，没有绑定任何串口 |\n| 连接数上限 | 1 |\n| 协议 | SSH 和 WebSocket 开启；TCP 和 Telnet 关闭 |\n| TCP 监听 | `127.0.0.1:5001–5048` |\n| SSH 监听 | `0.0.0.0:6001–6048` |\n| Telnet 监听 | `127.0.0.1:7001–7048` |\n| WebSocket | 不单独监听，使用 Web 端口，路径 `\u002Fconsole\u002F\u003C编号>` |\n\n**想回到初始状态**：用命令行的 `--factory-reset`（见“恢复出厂设置”），它会删除数据库，再按上面的“数据库初始化”和“管理员首次设置密码”重新开始。\n\n**初始化不会做的事**\n\n- 不会自动联网：许可证激活和续期都要管理员主动点击。没有许可证时是免费版（2 个端口）。\n- 不创建工程师账号、API Key，也不绑定任何串口。\n- `--init-config` 不动数据库。\n\n**新装后的提醒**：管理员设置密码之前，任何能访问网页的人都可以抢先设置。deb、rpm 安装后服务会自动启动并监听 80 端口，请在安装后尽快打开网页设置密码。如果设备接在不可信的网络里，先把 `http_host` 改成 `127.0.0.1` 或用 `--addr` 限制监听地址，设好密码再放开。\n\n### 恢复出厂设置\n\n恢复出厂设置**只能用命令行**，在设备本机的终端里运行；网页上没有这个功能（网页上的“清除所有串口配置”只清串口配置，见“配置导入、恢复默认与备份”）。\n\n```sh\nbetterone-cs --factory-reset\n```\n\n**设备要转给别人时**，加上 `--new-ssh-host-key`，同时换掉 SSH 主机密钥：\n\n```sh\nbetterone-cs --factory-reset --new-ssh-host-key\n```\n\n旧密钥被删除，服务启动时生成新的。这样即使原使用者留有旧密钥的副本（例如在备份里），也无法再冒充这台设备；代价是以前连过的 SSH 客户端会提示“主机密钥已改变”，需要从 `known_hosts` 里删掉旧条目。只是自己重新开始时不必加。\n\n**先停止服务，并先备份。** 命令只会在执行期间暂存数据库以便失败时恢复；成功后会删除暂存文件，它不是长期备份。需要保留数据，请先把下面“会删除”的文件复制到别处。\n\n**运行时发生什么**\n\n1. 程序检查服务是否还在运行（有程序在 Web 地址上应答就算运行）。**服务运行时不会继续**，请先停止服务。\n2. 显示要删除的文件、要保留的文件，并用大写字母提醒“先备份数据”。\n3. 让你输入 `RESET`（全大写，完整输入）。输入别的任何内容都会终止，**什么也不改**。\n4. 确认后先检查写入权限和文件类型，把数据库暂存到同目录；写好重置记录、轮换运行时密钥后才清理暂存文件。中途失败会尽量恢复旧数据库；若意外中断而留下暂存目录，下次启动会拒绝创建空数据库，并提示先恢复。\n\n**会删除**：数据库 `runtime.db`（以及 `runtime.db-wal`、`runtime.db-shm` 等附属文件），也就是：\n\n- 所有用户和密码、所有 API Key；\n- 审计记录；\n- 全部 48 个逻辑串口的配置（备注、绑定、串口参数、协议开关、监听地址），恢复为默认值（见“初始化都做什么”）。\n\n数据库处理成功后，还会删除下面这些文件（有才删）：\n\n| 文件 | 内容 | 删除后 |\n| --- | --- | --- |\n| 数据目录的 `web-settings.toml` | 在网页上改过的 Web 监听地址、端口、HTTPS 开关和证书设置 | 回到 `settings.toml` 里的设置；完成时会显示恢复后的监听地址和端口 |\n| `web-tls.pem`（数据目录，以及旧版本留在配置目录的副本） | 网页上传的 HTTPS 证书**和私钥** | 需要 HTTPS 时重新上传 |\n| `web-tls-self-signed.pem` | 自签名 HTTPS 证书和私钥 | 重新开启自签名时自动生成新的 |\n| Windows：`ProgramData\\BetterOne\\ConsoleServer\\logs\\betterone-console-service*.log` | 服务运行日志，含用户名、来源地址和操作记录 | 服务启动后重新记录 |\n\n这些文件删不掉时（例如旧副本在 root 所有的 `\u002Fetc` 下），命令会列出它们，请手动删除；数据库部分已经完成，不受影响。\n\nLinux 的服务日志在系统日志（journal）里，程序无法只删本服务的部分。需要一并清除时运行 `sudo journalctl --rotate && sudo journalctl --vacuum-time=1s`；注意这会清空**所有服务**的历史日志。\n\n**会保留**：\n\n| 保留的内容 | 原因 |\n| --- | --- |\n| `settings.toml` | 保留管理员写在里面的 Web 监听地址、许可证服务器地址等基础设置（网页上改过的部分会被清除，见上）；恢复出厂会在数据目录的 `runtime-identity.toml` 中轮换 `secret_key` 和 `api_key_pepper`，所以恢复前发出的登录状态和 API Key 全部失效 |\n| 许可证 `license.txt` | 它属于这台设备，不属于数据；删掉要重新激活，可能多占席位 |\n| SSH 主机密钥 | 换掉会让所有 SSH 客户端提示“主机密钥已改变”；加 `--new-ssh-host-key` 时才换（见上） |\n| `device_uuid`（配置文件和机器级文件） | 它是设备身份的一部分，没有硬件标识的机器丢了要重新激活 |\n\n**完成后**：启动服务；服务会用自己的账户创建新数据库，并写入一条 `factory-reset` 审计记录（操作者是运行命令的系统用户；通过 sudo 运行时记录执行 sudo 的人，例如 `alice (sudo, as betterone-console-server)`；来源标为“本机”）。然后打开网页，按“首次登录”设置新的管理员密码；管理员在设置密码之前，任何能访问网页的人都可以抢先设置，所以请尽快完成。\n\n**其他说明**\n\n- 必须在交互式终端里运行；脚本、管道、服务一律拒绝，防止被自动确认。\n- 只对已有数据库生效，参数要和服务一致（`-user` \u002F `-system` \u002F `-portable`、`--config-dir`、`--data-dir`，同“忘记管理员密码”里的表），不会在错误的位置新建数据库。\n- Linux 的 deb、rpm 安装：先停止服务，再运行 `sudo betterone-cs -system --factory-reset`。程序发现自己是 root 时，会自动切换成数据目录的属主（服务账户）再动手，所以写出的文件都属于服务账户；新数据库由服务重启后创建。没有 sudo 的系统可以用 root 运行 `runuser -u betterone-console-server -- betterone-cs -system --factory-reset`。\n- 普通用户（既不是 root，也不是数据目录的属主）运行会被拒绝，提示里会写明应使用的账户。\n- 权限或写入失败时会报错；如果提示遗留 `.runtime.db.factory-reset-stage-*` 暂存目录，不要直接重启或删除它，应先检查其中的旧数据库及 `previous-runtime-identity.toml`，按需恢复两者，再处理该目录。用 root 手动复制或移动文件后，要把属主改回服务账户：`sudo chown -R betterone-console-server: \u002Fvar\u002Flib\u002Fbetterone-console-server`，否则服务打不开这些文件。\n- 命令只清理上面列出的文件，不会动其他目录。\n\n### 忘记管理员密码\n\n**还有别的管理员账号**：用它登录，在“用户”页给忘记密码的账号重置密码。对方下次登录必须改密码，原有登录会全部失效。\n\n**只有一个管理员，而且忘了密码**：在安装程序的这台机器上，打开一个终端，用**和服务相同的启动参数**运行：\n\n```sh\nbetterone-cs --reset-admin-password\n```\n\n- 程序先显示它用的数据库路径（用来确认没有指向别的安装），然后在终端里让你输入新密码、再输入一次。输入时屏幕上不显示字符；至少 8 个字符，不能仅含字母或仅含数字。\n- 重置的对象固定是内置的 `admin` 账号。设置完成后，所有已登录的会话都会失效，`admin` 下次登录必须再改一次密码；如果 `admin` 被禁用了，会同时重新启用。\n- 程序做完就退出，**不会启动服务**；服务可以不停，登录状态会立即失效。\n- 每次重置都会写进审计记录（事件 `reset-admin-password`，操作者是运行命令的系统用户，通过 sudo 运行时记录执行 sudo 的人，来源标为“本机”），日志里也有一行。记录里没有密码。\n- 必须在本机的终端里运行：命令要求交互式终端，管道、脚本、服务都会被拒绝；不提供在命令行或文件里直接写密码的办法，避免密码留在 shell 历史或磁盘上。\n- 没有这台机器的操作权限就无法重置。这是有意的：能读写数据库的人本来就拥有这些权限。\n\n**按安装方式运行**：数据库的位置由运行模式决定，所以参数要和服务一致，否则指到另一个库，程序会提示“找不到数据库”而不是悄悄建一个新的。\n\n| 安装方式 | 命令 |\n| --- | --- |\n| Linux 的 deb、rpm（系统服务） | `sudo betterone-cs -system --reset-admin-password` |\n| 绿色版（portable） | 在程序所在目录：`.\u002Fbetterone-cs -portable --reset-admin-password` |\n| 显式 user 模式 | 用平时运行程序的同一个用户：`betterone-cs -user --reset-admin-password` |\n\n- Linux 上命令以数据目录的属主（通常是服务账户）身份执行：用 sudo 或 root 运行时，程序在加载配置、创建任何文件之前就自动切换到这个账户，不会留下 root 所有的文件。没有 sudo 的系统用 root 运行 `runuser -u betterone-console-server -- betterone-cs -system --reset-admin-password`。其他普通用户运行会被拒绝。\n- `-system` 在 Linux 上默认就是 `\u002Fetc\u002Fbetterone-console-server` 和 `\u002Fvar\u002Flib\u002Fbetterone-console-server`，与安装包一致，不需要再写 `--config`、`--config-dir`、`--data-dir`；只有改过路径时才需要。\n- Windows 服务版：在“以管理员身份运行”的终端里执行 `betterone-cs.exe -system --reset-admin-password`（`-system` 默认就是 ProgramData 下的安装路径）；服务本身使用 LocalService，数据目录权限授予 LocalService、SYSTEM 和 Administrators。\n- **没有交互式终端（例如只能通过无终端的远程通道）时无法使用**；这时只能先登录到有终端的会话。\n\n**删库不是办法**：删掉 `runtime.db` 会丢失全部端口配置、用户、API Key 和审计记录，只有在确实不需要这些数据时才考虑。\n\n### 部署目录与启动参数\n\n配置文件默认名为 `settings.toml`，目录按以下模式选择：\n\n忘记配置项名称时，可查看带中文注释的 [配置模板](..\u002F..\u002Fsettings.example.toml)，其中列出了配置项、默认值和用途。模板不会自动加载；先使用 `--init-config` 生成本机配置，再复制需要修改的项目，**不要覆盖已有密钥和设备 UUID**。未填写的配置项继续使用程序默认值。\n\n发布包会附带 `settings.example.toml`：压缩包中位于解压后的根目录，Windows 安装版位于程序安装目录，deb\u002Frpm 安装版位于 `\u002Fusr\u002Fshare\u002Fdoc\u002Fbetterone-console-server\u002Fsettings.example.toml`。它仅供查阅，不会替换实际运行配置。\n\n| 模式 | Linux 配置目录 \u002F DataDir | Windows 配置目录 \u002F DataDir |\n| :--- | :--- | :--- |\n| `-user` | `$XDG_CONFIG_HOME\u002Fbetterone-console-server` \u002F `$XDG_DATA_HOME\u002Fbetterone-console-server`，默认 `~\u002F.config\u002Fbetterone-console-server` \u002F `~\u002F.local\u002Fshare\u002Fbetterone-console-server` | `KnownFolderLocalAppData\\BetterOne\\ConsoleServer\\config` \u002F `KnownFolderLocalAppData\\BetterOne\\ConsoleServer\\data` |\n| `-system` | `\u002Fetc\u002Fbetterone-console-server` \u002F `\u002Fvar\u002Flib\u002Fbetterone-console-server` | `KnownFolderProgramData\\BetterOne\\ConsoleServer\\config` \u002F `KnownFolderProgramData\\BetterOne\\ConsoleServer\\data` |\n| 默认 portable，或 `-portable` | 可执行文件目录的 `config\u002F` \u002F `data\u002F` | 可执行文件目录的 `config\\` \u002F `data\\` |\n\n`-user`、`-system`、`-portable` 只能选择一个，也支持双横线写法。未指定模式参数时，使用 `NBC_MODE`，未设置则默认 portable。\n\nLinux 忽略相对的 XDG 变量值；Windows 目录通过 Known Folder API 解析。非空 `--config`、`--config-dir`、`--data-dir`、`-user` \u002F `-system` \u002F `-portable` 分别优先于 `NBC_CONFIG_FILE`、`NBC_CONFIG_DIR`、`NBC_DATA_DIR`、`NBC_MODE`，最后使用模式默认值。指定配置文件时路径独立于配置目录；否则使用 `\u003CConfigDir>\u002Fsettings.toml`。命令行相对路径按启动时 cwd 解析一次。\n\n数据库、SSH 主机密钥及许可证的相对路径按 `DataDir` 解析，默认为 `runtime.db`、`ssh_host_key`、`license.txt`；绝对路径保留，不按程序基础目录或配置目录解析。修改启动设置后需重启程序。\n\n配置文件是管理员维护的基础值；网页保存的 HTTP\u002FHTTPS 地址及证书设置写入 `DataDir\u002Fweb-settings.toml`，启动时覆盖对应基础值，不修改 `settings.toml`。网页管理的证书、自签名证书和本机授权解除记录也位于 `DataDir`。升级时保留整个数据目录；要恢复基础 Web 设置，请先停止服务，备份并移走 `web-settings.toml`，再启动服务。\n\n首次部署可执行 `betterone-cs -user --init-config`（Windows 使用 `.exe`；Linux 当前目录运行需加 `.\u002F`），先检查配置，再正常启动。`--init-config` 只生成启动配置及缺失的 `secret_key`、`api_key_pepper` 并退出，不生成数据库、默认用户或 SSH 主机密钥，不启动监听。已有配置不重写；若缺少自动生成的密钥或设备 UUID，则保存在 `DataDir\u002Fruntime-identity.toml`，不要求服务账户修改只读配置。DataDir 必须可写，初始化也会检查，正常运行需要写入 SQLite WAL 等文件。\n\nLinux 普通用户一般不能绑定 HTTP 80：初始化后将 `http_port` 改为例如 `8080`，或由管理员配置 capability。deb\u002Frpm 的 systemd unit 使用专用服务账户并仅授予 `CAP_NET_BIND_SERVICE`；配置目录为 `root:betterone-console-server`、`0750`，配置文件 `0640`，数据目录归服务账户、`0700`。安装\u002F升级自动启用并重启服务；请及时设置管理员密码并审查配置。\n\nWindows 服务版通过 WinSW 使用 LocalService 运行，安装后自动启动；ProgramData 配置\u002F数据目录 ACL 仅授予 LocalService、SYSTEM、Administrators。卸载保留配置及数据。客户无需安装 Inno Setup、Go 或 WinSW 开发工具；它们只供构建主机使用。\n\n### 配置文件 settings.toml\n\n配置文件可以只写需要覆盖的字段：未写的字段使用当前程序内置的默认值；明确写出的值（包括允许的空字符串、`0`、`false`）优先，不会被默认值覆盖。例如，不写 `license_server` 使用内置授权服务器地址；写 `license_server = \"\"` 关闭在线授权；写 `license_server = \"127.0.0.1:8080\"` 则继续使用这个显式地址。修改配置后需重启程序。\n\n启动设置保存在 `settings.toml`，位置见上表。**修改后需重启程序**。文件首次启动时自动生成，已有的项不会被覆盖。\n\n| 配置项 | 默认值 | 作用 |\n| :--- | :--- | :--- |\n| `http_host`、`http_port` | `0.0.0.0`、`80` | 网页界面、REST API 和 WebSocket 终端共用的监听地址。也可以用接口修改，见“修改 Web\u002FAPI 监听地址” |\n| `websocket_allowed_origins` | 空 | 额外允许的 WebSocket 来源（如反向代理的域名），格式 `host[:port]` |\n| `https_enabled`、`https_host`、`https_port` | `false`、`0.0.0.0`、`443` | 独立的 HTTPS 开关和监听地址；开启后 HTTP 入口跳转到 HTTPS。端口不得与 HTTP 相同 |\n| `tls_cert_file`、`tls_key_file` | 空 | HTTPS 证书和私钥；可不填而使用自签名证书。相对路径按配置目录解析 |\n| `max_connections_per_ip` | `128` | 同一个来源地址同时最多保持多少个 SSH、Telnet、Raw TCP 控制台连接（所有控制台合计）。超出的连接被立即关闭。`0` 表示不限制；经 NAT 或代理访问时，同一出口地址下的所有用户共用这个额度 |\n| `secure_cookies` | `false` | 由反向代理终结 TLS 时，让会话 Cookie 带 `Secure` 标志；内置 HTTPS 开启时自动生效 |\n| `ssh_host_key` | `ssh_host_key` | SSH 主机密钥文件；相对路径按数据目录解析 |\n| `license_file` | `license.txt` | 当前唯一授权文件（激活与续期共用）；相对路径按数据目录解析 |\n| `license_server` | `https:\u002F\u002Flicense.betterone.top:8443` | 在线激活和续期检查使用的许可证服务器；写空字符串关闭在线功能。可选项，不写使用程序默认值 |\n| `license_portal_url` | `https:\u002F\u002Flicense.betterone.top:8443\u002F#devices` | 官网设备管理页；用于误激活、名额满或设备释放的引导，不调用管理接口。未填写用默认值，显式空值隐藏链接；修改后重启服务 |\n| `license_offline_url` | `https:\u002F\u002Flicense.betterone.top:8443\u002F#offline` | 官网“离线激活 \u002F 续期”页，按内容识别 `.request.txt` 激活请求、`.renewal.txt` 续期请求和 `.license.txt` 授权文件，下载返回的授权文件。未填写用默认值，显式空值隐藏链接；修改后重启服务。与接口地址 license_server 独立 |\n| `database_dsn` | `runtime.db` | SQLite 数据库文件，保存控制台配置、用户、API Key 校验数据和审计记录；相对路径按数据目录解析 |\n| `secret_key` | 首次启动自动生成 | 登录会话的加密密钥，见下 |\n| `api_key_pepper` | 首次启动自动生成 | API Key 的校验密钥，见下 |\n| `device_uuid` | 首次启动自动生成 | 安装身份 UUID v4，同时保存在机器级文件；不要手动修改，详见“设备标识” |\n\n#### `secret_key`：登录会话的加密密钥\n\n用户登录后浏览器收到的会话 Cookie，是用这个密钥做 AES-256-GCM 加密并认证的，里面装着用户 ID、会话版本和过期时间。程序靠它判断“这个 Cookie 是不是我签发的、有没有被改动”。网页界面和 WebSocket 终端的登录认证都用它。\n\n- 格式：64 位十六进制字符（32 字节），首次启动自动生成。\n- **更换或丢失的后果**：所有人当前的登录立即失效，需要重新登录。用户、配置等数据不受影响。\n- 泄露的风险：拿到它的人可以伪造登录 Cookie，冒充任何用户（包括管理员）。\n\n#### `api_key_pepper`：API Key 的校验密钥\n\n数据库里不保存 API Key 的明文，只保存它的 HMAC-SHA256 哈希，`api_key_pepper` 就是这个 HMAC 的密钥。即使数据库文件被拿走，没有这个密钥也无法验证或伪造 API Key。创建 API Key 时明文只返回一次，之后无法找回。\n\n- 格式：64 位十六进制字符（32 字节），首次启动自动生成。\n- **更换或丢失的后果**：所有已有的 API Key 都验证不通过，且无法恢复，必须重新创建并更换使用方的配置。\n- 泄露的风险：它本身不能直接登录，但会降低数据库泄露时 API Key 哈希的保护强度。\n\n#### 保管与备份\n\n- 这两项是敏感信息。配置文件应只允许运行程序的用户读写（程序写入时权限为 `0600`），不要发给他人，不要提交到代码仓库，不要贴到工单或聊天里。系统诊断信息里这两项只显示“已设置”或“未设置”，不含取值。\n- **备份和恢复时必须与数据库配套保存**。只还原数据库而密钥不同，会出现全部 API Key 失效；只还原密钥而数据库不同，则登录状态与用户数据对不上。\n- 升级、迁移时保留原来的值（见“升级旧部署”）。\n- **怀疑泄露时**：停止程序，检查 `settings.toml` 和 `DataDir\u002Fruntime-identity.toml` 中对应的密钥，移除所有旧值后再启动；仅删除基础配置中的一行可能仍会使用数据目录中的旧值。代价是全部重新登录（`secret_key`）或重新创建全部 API Key（`api_key_pepper`）。操作前先备份，并保护备份中的密钥。\n- `betterone-cs --init-config` 只补齐缺失的项，已有的值不会改动。\n\n### 升级旧部署：先停机、备份，再手动迁移\n\n本次文件名切换**不兼容旧默认名称，也不自动迁移**。新 `settings.toml` 或 `runtime.db` 不存在时，即使同目录留有旧 `nbc_settings.toml` 或 `nbc.db`，程序也会忽略旧文件并创建新配置或数据库；旧文件不会被删除。若要保留旧部署的数据，升级前停止程序\u002F服务，备份旧配置、数据库及存在的 `-wal`、`-shm`、SSH 主机私钥、`license.txt` 和原程序，再手动迁移。将旧 `nbc_settings.toml` 改名为新配置目录中的 `settings.toml`，将旧 `nbc.db` 改名为数据目录中的 `runtime.db`；先确认 SQLite 已完整关闭，若仍有 WAL\u002FSHM 文件，应使用一致性备份方式迁移，不能只移动主数据库。自定义 `--config`、`database_dsn` 的部署需核对实际路径。\n\n必须保留原 `secret_key`、`api_key_pepper` 和 SSH 主机密钥，防止会话\u002FAPI Key 校验失效或 SSH 主机身份变化。若旧 TOML 显式写了 `database_dsn = \"nbc.db\"`，也要改为 `\"runtime.db\"`；否则程序会继续使用显式指定的旧路径。旧相对路径 `data\u002Fnbc.db`、`data\u002Fssh_host_key` 在新 DataDir 下也须核对。许可证路径同样要核对。不要删除备份；确认启动日志中的配置\u002F数据库路径及原数据可用后再完成升级。\n\n日志输出到 stdout\u002Fstderr，systemd 服务由 journal 收集（`journalctl -u betterone-console-server.service`），绿色版日志写到终端，Windows 服务版由 WinSW 捕获并轮转文件日志。`\u002Frun` 仅为 systemd 运行目录，目前不写 PID 文件。\n\n## 串口绑定与通信参数\n\n选择逻辑控制台，按设备名（Windows 的 `COMx`、Linux 的 `\u002Fdev\u002Ftty*`）、USB 物理端口位置（槽位）或 USB 设备标识（VID、PID、序列号）三种方式之一进行绑定，三者互斥；各自的使用场景和前提条件见下文“三种绑定方式”。设备名可能随重新枚举改变；槽位绑定适用于保持 USB 连接位置稳定的场景；设备标识绑定适用于适配器固定、位置可能变化的场景。相同设备名、相同槽位或同一个 USB 设备不能重复绑定多个控制台；用不同方式指向同一物理设备仍可能出现打开冲突。\n\n默认通信参数为 `115200 8N1`、无流控。可配置波特率范围为 300–4,000,000；该范围是软件输入校验范围，实际可用速度取决于串口芯片、驱动和目标设备。设置时与目标设备保持一致，先测试再应用到生产环境。\n\n已打开的串口句柄可继续使用原参数；保存修改后，下次重新打开使用新配置。可通过状态 API 的 `active_serial_config` 核对当前实际参数。每个控制台的最大连接数单独配置，取值范围为 **1–256**，默认 **1**，所有协议共用该上限；不再支持 `-1` 无上限模式或 `0`。新提交的越界值会被拒绝，不提供旧数据库或无上限配置的自动迁移。多个客户端共用同一串口流，发送命令前应协调操作。\n\n### 三种绑定方式：使用场景与前提条件\n\n每个逻辑控制台的串口可以用下面三种方式之一绑定，三者**互斥**：设置其中一种会自动清除另外两种，同一请求里同时设置两种以上会被拒绝。\n\n| | 按设备名 | 按 USB 端口位置 | 按设备标识 |\n| :--- | :--- | :--- | :--- |\n| 配置字段 | `device` | `slot` | `vid`、`pid`、`serial_number` |\n| 示例 | `COM11`、`\u002Fdev\u002FttyUSB0` | `00.1400@2.3` | `0403`、`6015`、`D30J42Y6A` |\n| 绑定的对象 | 终端名本身 | USB 物理端口，找出这个口上现在的串口 | USB 适配器本身，不管它插在哪个口 |\n| 终端名变化 | 找不到设备 | 不受影响 | 不受影响 |\n| 换适配器（插回同一口） | 取决于终端名是否不变 | **不用改配置** | 必须重新绑定 |\n| 换 USB 口或换线 | 取决于终端名是否不变 | 必须重新绑定 | **不受影响** |\n| 适用的设备 | 任意串口，包括非 USB 的 | 仅 USB 串口 | 仅有序列号的 USB 串口 |\n\n#### 1. 按设备名（`device`）\n\n**使用场景**\n- 非 USB 的串口：主板串口、`com0com` 这类虚拟串口、PTY，它们没有 USB 位置，也没有 USB 序列号，只能这样绑定。\n- 一个 USB 设备带多个串口（FT4232H、CP2105、CH348 等多口芯片）：各口共用同一个 USB 位置和同一组 VID、PID、序列号，另外两种方式都会报歧义，只能按设备名绑定。\n- 现场只有一个适配器、终端名已经固定、也不会换设备的简单场景。\n\n**前提条件**\n- 终端名必须保持不变。Windows 的 COM 号可以在设备管理器里为设备固定，Linux 的 `\u002Fdev\u002FttyUSBn` 由内核按探测顺序分配，重启或重新插拔后可能互换，不能直接依赖；需要时用 udev 规则建立固定名称，或使用系统自带的 `\u002Fdev\u002Fserial\u002Fby-path\u002F`、`\u002Fdev\u002Fserial\u002Fby-id\u002F` 链接，再把这个链接名当作设备名填入。\n- 这个终端名没有被其他程序占用。\n\n**不适合的情况**：Linux 上有多个 USB 串口、又没有用 udev 固定名称时，终端名会漂移，可能绑到错误的设备。\n\n#### 2. 按 USB 端口位置（`slot`）\n\n**使用场景**\n- 线缆固定接在机架上的某个口，要求“某号控制台就是这个口上的串口”，适配器坏了换一个新的，**插回同一个口不用改配置**。\n- 适配器没有序列号或序列号重复（很多 CH340、部分 PL2303 仿制芯片），无法按设备标识绑定。\n- Windows 和 Linux 都支持，格式一致（形如 `00.1400@2.3`）。\n\n**前提条件**\n- **计算机硬件不改动，尤其是主板。** 槽位由主板上的 USB 控制器、接口和 Hub 的物理连接结构决定；更换主板、增减或更换扩展卡、改变设备所接的接口或 Hub，槽位必然改变。\n- 带多个串口的设备，槽位不写接口号时绑定第一个口，其他口须写明接口号，见下文“多口设备”。\n- 更新 BIOS\u002F固件等变化也可能让槽位改变（PCI 根总线号由固件分配）。**出现这类变化后必须重新核对槽位。** 详见下文“槽位的稳定性”。\n- Linux 需要能读取 `\u002Fsys`：容器、WSL 或权限不足的环境里槽位为空，只能用设备名。\n- 不适用于非 USB 串口。\n\n#### 3. 按设备标识（`vid`、`pid`、`serial_number`）\n\n**使用场景**\n- 要求“就是这个适配器”，**不依赖它插在哪个口，也不要求不换线**：适配器会在不同接口、不同 Hub 之间移动，或线缆位置经常调整。\n- 不能满足“硬件不改动”这个前提的现场，例如机器经常更换、扩展卡变动，但适配器本身固定。\n\n**前提条件**\n- **设备必须有序列号**，并且在同时在线的设备中唯一。没有序列号的适配器（很多 CH340、部分 PL2303 仿制芯片）不能使用这种方式；个别批次序列号重复时，两个设备同时在线会报歧义。\n- **VID、PID、序列号三个字段必须同时填写**，缺一不可：同型号的多个适配器共用 VID 和 PID，只有序列号能区分。\n- **一个设备只有一个串口。** 多口芯片的各口共用同一组标识，会报歧义。\n- 换了适配器必须重新绑定。\n- **FTDI 适配器的序列号在 Windows 上带一个通道字母后缀**（如 `D30J42Y6A`），Linux 上是原值（`D30J42Y6`），所以 FTDI 设备的这种配置在两个平台之间不通用，请填写串口列表中 `usb_serial` 实际显示的值。\n- VID、PID、序列号由 Go 串口库 `go.bug.st\u002Fserial` 提供；该库读取失败时，本次枚举里这三项为空，按设备标识绑定的控制台暂时找不到设备并重试，不影响另外两种方式。\n\n#### 怎么选\n\n1. 不是 USB 串口，或设备带多个串口 → **按设备名**。\n2. 线缆位置固定、适配器可能更换，且硬件不改动 → **按 USB 端口位置**。\n3. 适配器固定、位置可能变化，且设备有唯一序列号 → **按设备标识**。\n4. 不确定时：先在串口列表（`GET \u002Fapi\u002Fsystem\u002Fserial-ports`）查看设备的 `slot`、`usb_vid`、`usb_pid`、`usb_serial`；有序列号的 USB 适配器两种 USB 方式都可以用，按上面第 2、3 条的场景选择；无序列号的只能选端口位置或设备名。\n\n三种方式找不到设备或有歧义时，控制台按串口故障处理并进入重试（超时后断开客户端），状态里的错误提示可区分“设备不可用”“USB 位置不可用”和“匹配到多个串口”。\n\n### 槽位（Slot）是什么\n\n槽位识别的是**串口接在电脑的什么位置**，Windows 和 Linux 写法完全一致：\n\n```\n00.1C00.0000@1.4:2\n│  │    │     │ │ └ 接口号（可省略，省略就是第一个）\n│  │    │     │ └── 从 @ 开始是 USB 端口链：电脑的口、Hub 的口……\n│  └────┴────────── 各级 PCI 设备（4 位十六进制：设备号+功能号）\n└────────────────── PCI 根总线号（2 位十六进制）\n```\n\n- `@` 之前是 USB 控制器在主板上的位置，`@` 之后是 USB 端口链，最后的 `:数字` 是 USB 接口号（只有多口芯片才需要写）。\n- 十六进制不区分大小写，程序统一显示为大写；端口号是十进制 1–255。\n- 例：主板自带 USB 口接 Hub 第 3 口 → `00.1400@2.3`；FT4232H 的第 3 个口 → `00.1400@2.3:2`。\n- 板载串口、PCI\u002FPCIe 串口卡没有 `@` 部分，如 `00.1C00.0000`，同一控制器上有多个口时用 `:序号`（按地址从小到大）。\n- ARM\u002F树莓派等设备树平台写 `dt.平台设备名@…`，如 `dt.3f980000.usb@1.2`。\n\n槽位**不是**串口的序列号，也不是终端名。程序按槽位找到该位置上当前出现的串口，取得它此刻的终端名（`COMx`、`\u002Fdev\u002FttyUSBx`），再打开它。因此把适配器换到同一个接口上，槽位不变；终端名只是解析结果，不能作为长期绑定依据。\n\n“位置”是系统报告的位置，与机箱外壳上的接口并不总是一一对应：USB 3.0 接口在系统中对应 2.0 与 3.0 两个逻辑端口，不同速度的设备会落在不同的位置；主板内部设备（蓝牙、无线网卡、前置面板 Hub）会占用端口号；树莓派 3B\u002F3B+ 的所有 USB 口都在板载 Hub 后面，端口链会多一段。\n\n### 槽位的稳定性\n\n槽位由主板的物理连接结构决定，**不使用**会随启动顺序变化的 USB 总线号和 Windows 根 Hub 序号。唯一由固件分配的是 PCI 根总线号（`@` 前面的第一段），它由 BIOS 决定，同一台机器上固定。\n\n**前提：计算机硬件不改动，尤其是主板。** 更换主板、增减或更换 PCIe\u002FUSB 扩展卡、改变设备所接的接口或 Hub，槽位都会改变。以下情况即使不改硬件也可能让槽位变化，**可能导致控制台绑定到另一个口或找不到设备**：\n\n- 更新 BIOS\u002F固件、修改 BIOS 中的 PCI\u002FUSB 设置（根总线号或设备布局可能变化）；\n- 速度协商回退：xHCI 主机上 USB 3.0 与 USB 2.0 设备走不同的根端口，线缆或 Hub 不佳时设备降速，位置随之改变；\n- 更换 Hub 型号：不同型号的内部端口编号可能不同（USB 3.0 Hub 内部是 2.0 与 3.0 两个子 Hub）。\n\n槽位变化后，原绑定解析不到设备（控制台进入重试，超时后断开客户端）。变更后请逐一核对串口列表（`GET \u002Fapi\u002Fsystem\u002Fserial-ports`）中的 `slot`，并做连接验收。\n\n其他限制：\n\n- Linux 读取 sysfs；容器、WSL 或权限不足的环境里槽位为空，只能用设备名。WSL 经 `usbipd` 转发的设备与物理位置无对应关系。\n- Windows 从系统的 LocationPaths 和 Hub 查询取得位置，不受 COM 号影响。\n- 非 USB、非 PCI 的虚拟串口（com0com、PTY）没有槽位，只能用设备名。\n- **请使用正规厂家生产的串口线材\u002F芯片。** 盗版、山寨芯片可能无法正确报告接口号、序列号等信息，由此造成的问题由使用者自行承担。\n\n### 多口设备\n\n一个 USB 设备带多个串口（FT2232H\u002FFT4232H、CP2105、CH348 等）时，各口位置相同，只靠接口号区分：\n\n- 串口列表里每个口的槽位会带上接口号，如 `00.1400@2.3:0`、`00.1400@2.3:1`；\n- 绑定时写不带接口号的 `00.1400@2.3` 等于绑定第一个口（接口号最小的串口）；要用其他口，写明接口号；\n- 接口号来自芯片固件，不随插拔、重启变化；\n- 一个接口下再带多个串口的芯片（MCS7840、CH348 等）的口序号是否稳定，需在真实设备上验证；此类设备也可按设备名绑定，并配合 udev 规则（`\u002Fdev\u002Fserial\u002Fby-path\u002F`、`\u002Fdev\u002Fserial\u002Fby-id\u002F`）固定名称。\n- 带内置 Hub 的多口卡（Hub 加几个单口芯片）：每个芯片接在 Hub 的不同端口上，端口链多一段，各口槽位各不相同。\n\n### 串口线材和芯片的选择\n\n**建议使用正规厂家生产的串口线材、USB 转串口适配器和 USB Hub，并通过正规渠道购买。** 常见的正规芯片厂商有 FTDI、Silicon Labs、Prolific（正品）、WCH（正品）等，也可以直接选用知名品牌的成品线缆。多路部署使用带外接电源的 Hub。\n\n盗版、仿冒、山寨或来路不明的芯片和线材，可能出现（但不限于）下面的问题：\n\n- 序列号缺失或多块相同，无法按设备标识区分，必须改用 USB 槽位；\n- 官方驱动识别出仿冒芯片后拒绝工作，或改写它的内部配置；\n- 数据丢失或乱码、波特率不准、反复断开重连；\n- 供电不足、电平不匹配（3.3 V、5 V、RS-232 电平混用）、缺少隔离和保护，严重时损坏所连接的设备。\n\n**使用盗版、山寨或来路不明的线材和适配器造成的识别错误、绑定到错误的串口、数据丢失、通信中断和设备损坏，由使用者自行承担风险和责任。** 本软件无法保证在这类产品上正常工作。\n\n买到新的线材或适配器后，建议先在“物理串口”页查看它的 USB 标识（VID、PID、序列号）：序列号为空，或同一批的几块序列号相同，就不能按设备标识绑定。\n\n### 通过 Hub 扩展串口：槽位与更换 Hub\n\n用 USB Hub 把几路到几十路串口适配器接到一台电脑上是最常见的部署方式。接了 Hub 以后槽位仍然是同一种格式，只是端口链里每多一级 Hub 就多一段 `.端口号`：\n\n| 接法 | 槽位（`@` 之后的端口链） | Windows `LocationPaths` 末尾 |\n| :--- | :--- | :--- |\n| 电脑的口直接接适配器 | `00.1400@2` | `…#USB(2)` |\n| 电脑的口接 Hub，适配器接 Hub 第 3 口 | `00.1400@2.3` | `…#USB(2)#USB(3)` |\n| Hub 再接一级 Hub | `00.1400@2.3.1` | `…#USB(2)#USB(3)#USB(1)` |\n\nHub 内部的端口号是 Hub 芯片自己的硬件编号，插拔、重启、断电都不会变，所以接 Hub 并不增加槽位不稳定的风险；仍然只有\"主机控制器那一段\"（`@` 之前）可能因固件变更而变化，见上文“槽位的稳定性”。\n\n**接 Hub 时槽位保持稳定的前提**\n\n- Hub 一直接在电脑的**同一个口**上。把 Hub 换到电脑的另一个口，路径前缀改变，Hub 上所有设备的槽位都会跟着改变。\n- Hub 的**型号**不变，级联顺序不变。端口号是 Hub 芯片内部的编号，不同品牌、不同型号可能不同。\n- 不要根据 Hub 外壳上的丝印数字推算槽位。很多 7 口 Hub 内部是两颗 4 口芯片级联的，外壳上的第 5 口对应的槽位可能是 `00.1400@2.4.2`，不是 `00.1400@2.5`。同一型号上这个对应关系是固定的，但第一次配置时要实际把适配器插上、读出槽位，不要凭丝印猜。\n- USB 3.0 Hub 内部是 USB 2.0 和 USB 3.0 两个子 Hub，各有各的端口编号。串口适配器几乎都是 USB 2.0 设备，始终走 USB 2.0 那一半，路径是固定的；但把 USB 3.0 Hub 插到只有 USB 2.0 的接口上，或者插到另一个主机控制器上，路径会和插在 USB 3.0 口上时不同。\n- 接较多适配器（建议 4 个以上）的 Hub 使用带外接电源的型号。供电不足时 Hub 会反复断开重新枚举，槽位不变，但串口会时有时无。\n\n**更换 Hub 的例子**（端口号是示意值，实际以读到的槽位为准）\n\n假设 7 口 Hub A 插在电脑的一个固定口上，路径前缀为 `00.1400@2`。它由两颗 4 口芯片级联：第一颗的前 3 个口直接引出，第 4 个口接第二颗芯片，第二颗的 4 个口再引出。适配器和绑定如下：\n\n| 外壳上的口 | 槽位 | 绑定的控制台 |\n| :--- | :--- | :--- |\n| 第 1 口 | `00.1400@2.1` | 控制台 1 |\n| 第 2 口 | `00.1400@2.2` | 控制台 2 |\n| 第 3 口 | `00.1400@2.3` | 控制台 3 |\n| 第 4 口 | `00.1400@2.4.1` | 控制台 4 |\n| 第 5 口 | `00.1400@2.4.2` | 控制台 5 |\n| 第 6 口 | `00.1400@2.4.3` | 控制台 6 |\n| 第 7 口 | `00.1400@2.4.4` | 控制台 7 |\n\n- **Hub 坏了，换成同一型号并插回电脑的同一个口**：内部结构相同，每个口的槽位完全相同。把适配器按原来的位置插回去，配置不用改，7 路控制台自动恢复。\n- **换成另一个品牌的 7 口 Hub B**：假设它把级联的那颗芯片接在第 1 个口上，各口的槽位变成：\n\n| 外壳上的口 | 新槽位 |\n| :--- | :--- |\n| 第 1 口 | `00.1400@2.1.1` |\n| 第 2 口 | `00.1400@2.1.2` |\n| 第 3 口 | `00.1400@2.1.3` |\n| 第 4 口 | `00.1400@2.1.4` |\n| 第 5 口 | `00.1400@2.2` |\n| 第 6 口 | `00.1400@2.3` |\n| 第 7 口 | `00.1400@2.4` |\n\n  把适配器按外壳上的口位插回去，会出现两种后果：\n  1. **找不到设备**：控制台 1 绑定的是 `00.1400@2.1`，原来插在第 1 口的适配器现在在 `00.1400@2.1.1`，没有对应的绑定，控制台 1 显示找不到设备。\n  2. **接到别的设备上，而且不报错**：控制台 2 绑定的是 `00.1400@2.2`，现在这个槽位对应外壳上的第 5 口。如果这个口上插着另一个设备，控制台 2 就会连到错误的设备。这是最危险的情况。\n\n**更换 Hub 的处理步骤**\n\n1. 尽量换成**同一型号**，插回电脑的同一个口。\n2. 如果只能换成不同型号，换完后所有槽位绑定都要重新核对：先不要启用相关控制台，在\"物理串口\"页把适配器逐个插上，读出每个适配器新的槽位，再更新对应控制台的绑定。\n3. 配置完成后，向每个控制台各发一条测试数据，确认连到的是预期的设备。\n4. 适配器带有 USB 序列号的（FTDI、CP210x 等大多数都有），可以改用\"按设备标识\"绑定，这样更换 Hub、换口都不受影响。没有序列号的廉价芯片（很多 CH340）才需要依赖槽位。\n5. 在 Hub 上贴标签，写明每个口的槽位和对应的控制台编号，换设备或维修时对照使用。\n\n### 串口绑定的 API\n\n绑定通过 `PATCH \u002Fapi\u002Fconsoles\u002F{id}\u002Fserial` 设置，需要 `admin` 或 `engineer` 权限。请求体是部分更新：至少提供一个字段，没提供的字段保持不变，`null` 和未知字段会被拒绝。\n\n**三种绑定方式的字段**（互斥，只能设置其中一种）：\n\n| 方式 | 请求体字段 | 示例 |\n| :--- | :--- | :--- |\n| 设备名 | `device` | `{\"device\":\"COM11\"}` |\n| USB 端口位置 | `slot` | `{\"slot\":\"00.1400@2.3\"}` |\n| 设备标识 | `vid`、`pid`、`serial_number` | `{\"vid\":\"0403\",\"pid\":\"6015\",\"serial_number\":\"D30J42Y6A\"}` |\n\n**设备标识的规则**\n- **`vid`、`pid`、`serial_number` 三个必须同时提供，缺一不可。** 同型号的适配器共用 VID 和 PID，只有序列号能区分，所以序列号不能省；只提供 `vid` 和 `pid` 会被拒绝。\n- `vid`、`pid` 各为 4 位十六进制（不区分大小写，保存时转大写），`serial_number` 不能为空白。\n- 取值直接从串口列表（`GET \u002Fapi\u002Fsystem\u002Fserial-ports`）的 `usb_vid`、`usb_pid`、`usb_serial` 抄用。`usb_serial` 为空的设备没有序列号，不能使用这种方式。\n- 更新时三个字段要么都提供，要么都不提供；如果当前已经是设备标识绑定，只想改其中一项，也要把三项一起带上。\n\n**互斥与清除**\n- 设置一种非空绑定，会自动清除另外两种。\n- 传空字符串清除对应绑定：清除设备标识时，三个字段都传 `\"\"`，例如 `{\"vid\":\"\",\"pid\":\"\",\"serial_number\":\"\"}`。\n- 同一个请求里同时给出两种以上的非空绑定会被拒绝，不会按先后顺序取舍。\n- 只更新串口参数而保留绑定时，不要带 `device`、`slot`、`vid`、`pid`、`serial_number`，例如 `{\"baudrate\":115200}`。\n\n**错误响应**\n\n| 状态码 | 原因 |\n| :--- | :--- |\n| `401` | 未登录 |\n| `403` | 角色不足 |\n| `404` | 控制台不存在 |\n| `409` | 同一设备或同一槽位已绑定到另一个控制台（响应里说明是哪个控制台） |\n| `422` | 绑定不合法：缺少 `vid`、`pid`、`serial_number` 中的任何一项、VID 或 PID 不是 4 位十六进制、同时设置了多种绑定方式、参数越界。响应的 `detail` 说明具体原因，例如 `invalid serial configuration: vid, pid and serial_number must all be provided` |\n\n**相关的只读字段**\n- `GET \u002Fapi\u002Fsystem\u002Fserial-ports`：每个串口的 `port_name`、`slot`（槽位）、`usb_vid`、`usb_pid`、`usb_serial`（设备标识，没有序列号时 `usb_serial` 为空）。\n- `GET \u002Fapi\u002Fconsoles` 和 `GET \u002Fapi\u002Fconsoles\u002F{id}` 的 `serial` 对象：`device`、`slot`、`vid`、`pid`、`serial_number`，没有使用的方式为空字符串。\n- `GET \u002Fapi\u002Fconsoles\u002F{id}\u002Fstatus`：`configured_device`、`configured_slot`、`configured_vid`、`configured_pid`、`configured_serial_number` 是已保存的绑定；`resolved_device` 是此刻实际打开的设备名。\n\n保存后立即替换桥的串口打开方式：已打开的句柄保持原参数，下次连接尝试（包括自动重连）使用新绑定。找不到设备或匹配到多个串口时，控制台按串口故障处理并重试，状态里的错误提示区分“未找到”和“匹配到多个串口”。\n\n### 按设备标识绑定（VID、PID、序列号）\n\n除按 USB 端口位置绑定外，还可以按**设备本身**绑定：配置串口时同时填写 `vid`（厂商 ID）、`pid`（产品 ID）、`serial_number`（序列号）三个字段，例如 `vid=0403`、`pid=6015`、`serial_number=D30J42Y6A`。适配器插在哪个 USB 口都能找到，**不依赖具体位置，也不要求不换线**；代价是换了适配器要重新绑定。串口列表（`GET \u002Fapi\u002Fsystem\u002Fserial-ports`）的 `usb_vid`、`usb_pid`、`usb_serial` 三个字段给出这些值，可直接抄用。控制台状态里对应的是 `configured_vid`、`configured_pid`、`configured_serial_number`。\n\n规则：\n\n- `vid`、`pid` 各为 4 位十六进制（不区分大小写，保存时转大写），`serial_number` 非空。**三个必须同时填写**，缺一不可：同型号的多个适配器共用 VID 和 PID，只有序列号能区分。\n- 与 `device`、`slot` **三选一**。设置其中一种会自动清除另外两种；同一请求里同时设置两种以上会被拒绝。传空字符串可清除。\n- 同一设备不能绑定到两个控制台（序列号比较不区分大小写）。\n- 数据库中新增 `serial_vid`、`serial_pid`、`serial_number` 三列，旧数据库启动时自动补列，原有配置不受影响。\n\n两种位置\u002F身份方式互补，不是替代：\n\n| | 按 USB 端口（`slot`，如 `00.1400@2.3`） | 按设备标识（`vid`、`pid`、`serial_number`） |\n| :--- | :--- | :--- |\n| 识别的是 | 这个 USB 口上现在的串口 | 这个适配器 |\n| 换适配器 | 不用改配置 | 必须重新绑定 |\n| 换接口、换线 | 槽位改变，必须重新绑定 | 不受影响 |\n| 没有序列号的适配器 | 可用 | **不能用** |\n\n**不能覆盖的情况**（这种方式不可能覆盖所有设备）：\n\n- **FTDI 适配器的序列号带一个字母后缀。** VID、PID、序列号由 Go 串口库 `go.bug.st\u002Fserial` 的枚举器提供，按它的值原样使用。Windows 上 FTDI 驱动会在序列号后追加通道字母，该库把它当作序列号的一部分，所以序列号为 `D30J42Y6` 的 FT230X 在 Windows 上显示为 `D30J42Y6A`，多通道芯片的各通道依次为 `…A`、`…B`……；Linux 上读到的是 USB 描述符里的原值 `D30J42Y6`。因此 **FTDI 设备的这种绑定配置在 Windows 与 Linux 之间不能通用**，请使用串口列表里 `usb_serial` 实际显示的值。其他厂家（Prolific、Silicon Labs 等）两边一致。\n- **该库读取失败时**（它在任一端口读取失败时整体失败）：本次枚举里所有设备的 `usb_vid`、`usb_pid`、`usb_serial` 为空，按设备标识绑定的控制台暂时找不到设备并重试；按 USB 端口位置或设备名的绑定不受影响。\n- **没有序列号的设备**（很多 CH340、部分 PL2303 仿制芯片）：`usb_serial` 为空，不能按设备标识绑定，请改用 USB 端口或设备名。只有 VID 和 PID 不够。Windows 上系统为无序列号设备临时生成的实例号（含 `&`）不会被当作序列号。\n- **序列号重复的设备**：个别批次的适配器序列号相同。两个设备同时在线时，按该标识绑定会拒绝打开并提示 “configured USB slot matches more than one port”，日志列出各候选串口，不会任选其一。\n- **一个设备带多个串口**（FT4232H 等多口芯片）：各口共用同一组 VID、PID、序列号，同样报歧义，请改用设备名。\n\n## 临时禁用逻辑 Console\n\n管理员或工程师可通过以下接口暂停或恢复已授权的逻辑 Console：\n\n```http\nPATCH \u002Fapi\u002Fconsoles\u002F1\u002Fenabled\nContent-Type: application\u002Fjson\n\n{\"enabled\": false}\n```\n\n将 `enabled` 改为 `true` 即可恢复。该字段必须提供且为布尔值，缺失、null 或未知字段返回 Huma 参数校验错误（422）。\n\n- 禁用立即断开该 Console 已进入串口桥接的会话，并拒绝 TCP、Telnet、SSH、WebSocket 的新转发会话；串口由 owner 结束当前操作后关闭。\n- 原串口参数、绑定、连接上限及各协议开关保持不变，网络监听端口仍保留。若需要释放独立监听端口，请关闭对应协议。\n- 只保存在内存，**服务重启后自动解除临时禁用**；配置重载和许可证变更均不会解除暂停。该接口不是持久化禁用配置。\n- 列表和详情返回 `enabled=false`、`disabled_reason=\"manually_disabled\"`；状态返回 `state=\"disabled\"`、`message=\"manually_disabled\"`。暂停期间仍可编辑配置。\n- 重复启用或禁用是幂等操作，且会记录敏感操作审计。未授权 Console 返回 409，不能通过该接口绕过许可证限制。\n\n### 禁用后仍运行哪些组件？\n\n**当前禁用是“暂停串口转发”，不是完全停止或卸载该 Console。** 对于已启用的协议，具体行为如下：\n\n| 组件 | 临时禁用后的行为 |\n| :--- | :--- |\n| 物理串口 | 取消串口会话，由 owner 结束当前操作后关闭；暂停期间不会重新打开 |\n| 已进入桥接的客户端 | 断开，不再收发串口数据 |\n| TCP \u002F Telnet 监听器 | 继续监听并占用端口，新连接无法注册桥接会话并被关闭；Telnet 可能先执行初始协议协商 |\n| SSH 监听器 | 继续监听并占用端口；客户端仍可能完成握手、认证或创建 session，但不能进入串口桥接 |\n| 共享 WebSocket 服务 | 继续运行以服务其他 Console；拒绝该 Console 的新转发接入 |\n| Console owner goroutine | 保留并等待，不执行新的串口打开操作 |\n| 配置及管理接口 | 配置保留，仍可查询和修改 |\n\n临时禁用不会关闭尚未进入桥接的 SSH 握手连接或待启动 session，这些连接仍受 SSH 连接上限、握手超时和 session 启动超时约束。因此，禁用不等于停止网络监听或禁止所有协议认证。\n\n重新启用后，**不会立即打开串口**；新客户端成功进入桥接时才触发打开。临时禁用状态仅存内存，服务重启后解除。\n\n若需要释放该 Console 的 TCP、Telnet 或 SSH 独立监听端口，请通过对应协议开关关闭监听器；恢复时需重新开启协议。关闭某个 Console 的 WebSocket 协议不会停止其他 Console 共用的 WebSocket 服务。\n\n该接口及返回结构已写入 `\u002Fapi\u002Fopenapi.json` 和 `\u002Fapi\u002Fdocs`。可在网页对应页面操作，也可调用该接口。\n\n## 访问协议与网络保护\n\n| 协议 | 默认配置 | 认证与加密 |\n| :--- | :--- | :--- |\n| SSH | 启用，端口 `6000 + 控制台编号` | 使用启用的本地用户账户认证，SSH 加密 |\n| WebSocket | 启用，与 Web 界面共用端口（`http_port`，默认 `80`），路径 `\u002Fconsole\u002F\u003C编号>` | 按接口支持的认证方式接入；默认 `ws:\u002F\u002F` 不加密 |\n| Raw TCP | 默认关闭，地址 `127.0.0.1`，端口 `5000 + 控制台编号` | 无应用层身份认证，明文 |\n| Telnet | 默认关闭，地址 `127.0.0.1`，端口 `7000 + 控制台编号` | 无应用层身份认证，明文 |\n\nSSH 和 WebSocket 的默认地址为 `0.0.0.0`，访问时使用服务器的实际 IPv4；`0.0.0.0` 表示监听所有本地 IPv4 地址。TCP 和 Telnet 默认关闭，预设地址为 `127.0.0.1`，开启后默认仅允许本机访问；如需远程访问，应显式修改监听地址并配置防火墙。这些默认值用于新建配置及恢复默认，不覆盖已保存的配置。SSH 示例：`ssh -p 6001 admin@\u003C服务器IPv4>`。\n\nWebSocket 客户端可使用三种认证之一：`X-API-Key` 请求头、HTTP Basic（本地用户名与密码）或 Web 登录后的会话 Cookie（`nbc_session`）。Web 界面的终端使用会话 Cookie，不再需要先申请一次性 ticket，也已取消 `POST \u002Fapi\u002Fconsoles\u002F{id}\u002Fwebsocket-ticket` 接口。三种方式按上述顺序判断，先出现的一种决定结果，凭据错误时不会改用其他方式。会话 Cookie 仅限管理员和工程师角色使用，并且只在来源校验通过（见下文）时才会被接受。具体请求以 `\u002Fapi\u002Fdocs` 为准。Web 登录并不为 Raw TCP、Telnet 增加认证。部分配置和状态 GET 接口可匿名读取，也应纳入网络访问控制。\n\nWebSocket 来源校验与保活：\n\n- WebSocket 不再有独立端口：它与 Web 界面共用端口（`http_port`，默认 `80`，或 `--addr` 指定的端口），地址为 `ws:\u002F\u002F\u003C地址>:\u003CWeb 端口>\u002Fconsole\u002F\u003C编号>`，页面与终端同源，不需要额外端口。是否对某个逻辑控制台开放 WebSocket，由该控制台自己的 WebSocket 开关决定。原先的独立端口（`8001`）和该端口上的 `\u002Fhealth` 已取消，监控请改用 `GET \u002Fapi\u002Fsystem\u002Fhealth`；接口返回的 WebSocket 监听器地址与端口即 Web 界面的地址与端口，`PATCH \u002Fapi\u002Flisteners\u002F{id}` 不能修改它（返回 409），请改 Web 监听地址。配置库中保存的 WebSocket 主机与端口仅为兼容保留，不再校验，也不会产生端口冲突。\n- 浏览器发起的 WebSocket 连接会校验 `Origin`，用于阻止其他网站的页面借用户浏览器连接串口：来源必须与请求的 `Host` 相同（同主机名和端口），即从本服务 Web 界面打开的终端。其他网站的页面会被拒绝。\n- 不带 `Origin` 头的非浏览器客户端（脚本、`websocat` 等）不受此校验限制，仍需通过上述认证。\n- 通过反向代理访问时，请让代理保留原始 `Host` 头（如 Nginx 的 `proxy_set_header Host $host;`）并转发 WebSocket 升级。若浏览器的 `Origin` 仍与 `Host` 不一致，可在配置文件 `settings.toml` 中设置 `websocket_allowed_origins`，列出允许的来源主机（可含端口，支持 `*` 通配），例如：`websocket_allowed_origins = [\"console.example.com\", \"console.example.com:8443\"]`。修改后重启服务生效。\n- 服务端每 30 秒向 WebSocket 客户端发送 ping，15 秒内无响应即断开连接并释放连接名额。\n\n连接被拒绝时的提示（控制台连接数已满、已被暂停等）：\n\n| 协议 | 客户端看到什么 |\n| :--- | :--- |\n| SSH | 认证成功后打开 shell 时，终端显示 `maximum connections reached`（其他原因同样显示文字）。握手阶段连接过多时在握手前被关闭，无法提示，仅服务端日志有记录 |\n| Telnet | 先显示 `[bridge] maximum connections reached` 等文字，然后断开 |\n| WebSocket | 浏览器收到一条同样的终端文字，并以关闭码 1013（连接数已满）或 1008（其他拒绝）断开，关闭原因里也带有文字 |\n| Raw TCP | **不发送任何文字**，直接关闭连接：Raw TCP 传输的是串口设备自己的字节协议，插入文字会被上位机当作设备数据。原因请看服务端日志 |\n\nWebSocket 先认证再判断控制台状态：未认证者一律收到 `401`，不会得知控制台是否存在。认证后若控制台不存在或未开放 WebSocket、超出授权或被手动暂停，浏览器（带 `Origin` 头）会先完成握手再收到上述文字，因为浏览器读不到握手失败的状态码和内容；脚本等非浏览器客户端仍收到 HTTP 状态码 `404`、`403`、`409`。\n\n资源保护边界：\n\n- SSH 的连接数分两部分单独计算：正在认证（握手阶段）的连接最多 16 条，未认证的连接不会占用控制台配置给正常用户的名额；认证成功的连接数不能超过该控制台的最大连接数（配置的 1–256 值，修改后对新连接立即生效）。认证成功但控制台已满的客户端仍能完成登录，打开 shell 时会看到 `maximum connections reached`，且该连接约 30 秒后被关闭。握手阶段超过 16 条时，新的 TCP 连接会被直接关闭。两类拒绝都会在日志中记一条记录（每秒最多一条），握手须在 10 秒内完成；每条连接最多 8 个 session，未启动的 session 最多等待 30 秒，PTY 请求不会延长期限。已启动的正常 shell 不受该启动超时限制，仍遵循控制台共享连接上限。\n- Telnet 初始协商写入超时为 5 秒；空子协商、非法子协商命令或超过 1024 字节的子协商会终止连接，防止解析越界和缓冲无限增长。\n- 网页端口（HTTP\u002FWebSocket）同时最多保持 4096 个连接，超出的连接排队等待；请求头最大 64 KB，连接建立后 5 秒内没有发出完整请求头会被断开。每个浏览器终端占用一个连接。\n- 网页接口同时处理的普通请求最多 512 个（不含 WebSocket 终端），超出的请求立即返回 `503`（`Retry-After: 1`），不排队。\n- 登录和首次设置（`POST \u002Fapi\u002Fauth\u002Flogin`、`POST \u002Fapi\u002Fauth\u002Finitialize`）按来源地址限速：允许连续 30 次，之后每 2 秒恢复一次，超出返回 `429`。这个限速比较宽松，因为经反向代理访问时所有用户共用代理的地址；真正防暴力猜密码的是上面\"登录保护\"里的账户锁定。程序不信任 `X-Forwarded-For`。\n- SSH、Telnet、Raw TCP 三类控制台连接合计最多 4096 个（各控制台自己的连接上限仍然适用），超出的连接排队等待，防止连接洪水耗尽进程的文件描述符。\n- 没有为服务设置内存上限（systemd 的 `MemoryMax`）：内存很小的设备上设了上限，正常使用时可能被误杀。需要的话可以在 unit 的 drop-in 里自行添加。\n- 网页端口无法打开时（被占用、没有权限、`http_host` 不是本机地址），启动错误里会附带原因和处理办法。\n- WebSocket 握手的资源保护：控制台被手动暂停时返回 `409`，超出授权时返回 `403`，认证失败返回 `401`，均发生在升级之前。\n- 客户端取消或由服务端关闭时，会解除该客户端阻塞的串口入队等待；写队列持续满 30 秒仍无法入队时，也会结束对应客户端，避免在无法及时感知对端断开的情况下永久保留等待 goroutine。这些限制不替代防火墙或部署侧全局连接、内存预算。\n\n限制防火墙和监听范围，优先使用 SSH；HTTP\u002FWS 需要加密时启用内置 HTTPS（见“启用 HTTPS”）或配置 TLS 代理。原始串口数据可能包含目标设备密码等敏感内容，TLS 代理只保护经过该代理的连接。\n\n## 启用 HTTPS\n\n管理员可在「网络」页的「HTTPS 与证书」区域选择 PEM 格式的证书和对应私钥，点击「启用 HTTPS」。为避免私钥在网络上明文传输，上传只允许通过 HTTPS 或服务器本机的 `localhost` 页面进行。无图形界面的设备若当前通过远程 HTTP 管理，可先不选择证书文件，点击「启用 HTTPS」并确认使用自签名证书，保存后通过 SSH 或服务管理方式重启；随后用 HTTPS 地址打开同一页面上传正式证书。也可通过 SSH 安全传送证书文件并在服务器上配置。若反向代理已提供 HTTPS，通常无需再启用内置 HTTPS。首次启用或关闭 HTTPS 需重启服务，保存操作不会立即中断现有管理连接。通过页面管理的证书保存在数据目录的 `web-tls.pem`，应限制该文件的读取权限；续期时在同一页面更换有效证书，新连接会自动使用新证书，无需重启。\n\n没有证书时可直接点击「启用 HTTPS」，确认使用自签名证书后启用；上传的证书或私钥无效时，也会改用自签名证书，并在页面标明。自签名证书保存在数据目录的 `web-tls-self-signed.pem`，重启后复用；浏览器会提示不受信任，正式对外使用建议换成可信证书。若配置文件指定的证书在启动时不可用，服务仍以自签名证书启动 HTTPS，不会退回明文 HTTP；原证书文件不会被覆盖。\n\n网页界面、REST API 和 WebSocket 终端共用 Web 服务。HTTP 和 HTTPS 分别监听独立端口；开启 HTTPS 后，HTTP 入口会跳转至 HTTPS。在 `settings.toml` 中可以配置：\n\n```toml\nhttp_port = 80\nhttps_enabled = true\nhttps_host = \"0.0.0.0\"\nhttps_port = 443\ntls_cert_file = \"server.crt\"   # 相对路径按配置目录解析，也可以写绝对路径\ntls_key_file  = \"server.key\"\n```\n\n- `https_enabled = false` 时只提供 HTTP；设为 `true` 后，HTTP 和 HTTPS 端口不能相同。未提供证书、只填一个文件、文件不存在或内容无效时，程序改用持久保存的自签名证书启动 HTTPS，不会退回明文 HTTP。\n- HTTP 与 HTTPS 独立启动：其中一个监听端口绑定失败或运行中退出，控制台会记录错误，另一个继续运行。HTTPS 不可用时，仍在运行的 HTTP 直接提供网页，不会跳转到失效的 HTTPS 地址；两个 Web 端口都无法启动时，服务启动失败。仅 HTTP 可用时请先排除 HTTPS 故障，不要在不可信网络上传输敏感信息。\n- 旧配置若没有 `https_enabled`，但已设置证书和私钥，会继续按原来的单端口 HTTPS 方式运行；在网页重新保存 HTTPS 设置后才切换为独立端口。\n- 开启后访问 HTTP 入口会跳转到 HTTPS 端口。最低 TLS 1.2，只使用 HTTP\u002F1.1（WebSocket 终端依赖它）。\n- 浏览器会自动改用 `wss:\u002F\u002F` 连接终端；会话 Cookie 自动带 `Secure` 标志。\n- 证书续期不需要重启：替换文件后，下一次新连接就会使用新证书。如果新文件无效，日志会记录一次错误，继续使用旧证书。已建立的连接不受影响。\n- 正式对外使用建议准备公共可信证书（如 Let's Encrypt）或企业内部 CA 签发的证书。程序生成的自签名证书会触发浏览器警告，需要在客户端手动信任。\n- 私钥文件应只允许运行服务的账户读取。\n- 只覆盖 Web 端口。SSH 本身已加密；Telnet 和 Raw TCP 是明文协议，不受影响。\n- 如果由 Nginx、Caddy 等反向代理终结 TLS，保持本程序使用 HTTP，并设置 `secure_cookies = true`。\n- 「系统诊断信息」会显示 TLS 是否启用和证书文件状态。\n\n## 登录保护\n\nWeb 登录、WebSocket 的 HTTP Basic 认证和 SSH 密码认证都走同一套保护，用来防止暴力猜密码，并避免登录请求占满 CPU 和内存：\n\n- **失败锁定**：同一个用户名连续失败 5 次后，第 6 次失败开始被锁定 30 秒，之后每多失败一次锁定时间翻倍，最长 5 分钟。锁定期间即使密码正确也会被拒绝（Web 接口返回 `429`，WebSocket 和 SSH 当作认证失败）。成功登录会清零；15 分钟内没有新的失败也会清零。\n- **不存在的用户名同样计数**：不会泄露哪些账户存在，用户名不区分大小写。\n- **同时校验的数量有限**：密码哈希很耗内存，同时最多校验 2 个，其余排队，等待超过 3 秒就拒绝，返回 `429`。\n- **不会拖慢管理接口**：密码校验在配置锁之外进行，登录请求再多也不会卡住配置读写。\n- **数据库不可写时仍能登录**：登录成功后\"最后登录时间\"写不进去（磁盘满、数据库只读）只记日志，不影响登录，管理员仍然能进来排查。\n- 锁定状态保存在内存里，重启后清空。API Key 不受影响。被误锁时等待锁定结束，或用 `--reset-admin-password` 重置后重启程序。\n\n## 监听器状态与手动重试\n\nConsole TCP\u002FTelnet\u002FSSH、共享 WebSocket 初次绑定失败或 SSH 主机密钥准备失败不会阻止其他监听器启动。运行中 serve 意外退出或可恢复 panic 会将对应监听器标为 `failed` 并从活动监听器中移除，不退出整个程序。管理 HTTP 是必要服务，初次绑定或运行失败仍为致命错误。\n\n管理员或工程师查询 `GET \u002Fapi\u002Flisteners`，返回全部 145 项，含协议关闭和未授权的项；不要把“配置存在”当成“正在监听”。原 `listener_id\u002Fprotocol\u002Fconsole_number\u002Fhost\u002Fport` 字段保留，增加：\n\n| `runtime` 字段 | 含义 |\n| :--- | :--- |\n| `state` | `running` 已绑定并服务；`failed` 启动或运行失败；`stopped` 未运行；`disabled` 协议关闭；`license_blocked` 授权阻止；`unavailable` 运行状态能力不可用 |\n| `last_error` | 始终为字符串，无错误时为空；重试成功清除旧错误 |\n| `retry_allowed` | 是否允许发起重试，布尔值 |\n\n`running` 不保证串口硬件可用或用户认证成功。手动暂停 Console 后仍运行的监听器保持 `running`，不是 `disabled`。WebSocket 是一个共享物理监听器，`console_number=null`，不能按 Console 分别重试。\n\n```sh\n# 使用已有 API Key；将示例值替换为实际值\ncurl -sS -H 'X-API-Key: YOUR_API_KEY' http:\u002F\u002F127.0.0.1:80\u002Fapi\u002Flisteners\n# 从列表取 listener_id；下面假设目标 ID 为 1，无请求体\ncurl -i -X POST -H 'X-API-Key: YOUR_API_KEY' http:\u002F\u002F127.0.0.1:80\u002Fapi\u002Flisteners\u002F1\u002Fretry\n```\n\n重试使用最新持久化配置，只操作请求的物理监听器，不修改设置。已运行时直接幂等返回，不重启、不断开客户端。未知 ID 返回 404；协议关闭、授权阻止或不可重试返回 409；运行层不支持状态\u002F重试返回 503。绑定尝试失败仍为 **HTTP 200**，例如（ID、地址和错误内容仅作示意）：\n\n```json\n{\n  \"listener_id\": 1,\n  \"protocol\": \"tcp\",\n  \"console_number\": 1,\n  \"host\": \"127.0.0.1\",\n  \"port\": 5001,\n  \"runtime\": {\n    \"state\": \"failed\",\n    \"last_error\": \"listen tcp 127.0.0.1:5001: bind: address already in use\",\n    \"retry_allowed\": true\n  }\n}\n```\n\n客户端应先处理非 200 的请求错误，再检查响应 `runtime.state`：只有 `running` 表示监听成功；`failed` 应展示 `last_error`，排除端口占用、地址或密钥文件问题后再手动重试。成功返回 HTTP 200、`state=\"running\"`、`last_error=\"\"`，不能仅以 HTTP 200 判定恢复。\n\n相同 host 的 `PATCH \u002Fapi\u002Flisteners\u002F{id}` 不触发重试，应调用 POST。配置重载或新许可证应用也可按启用配置再次尝试，但没有自动定时重试。`restart_required` \u002F `service_restart_required` 与监听器失败无关。许可证 `runtime_error` 是最近一次许可证应用的部分失败报告，最新监听器状态以列表为准。\n\n接口及 Schema 已提供于 `\u002Fapi\u002Fopenapi.json`、`\u002Fapi\u002Fdocs`，可用于前端状态与重试按钮；网页“网络”页面提供状态与手动重试入口。重试动作纳入敏感操作审计，仅记录身份、目标和结果等元数据，不记录终端内容。\n\n## 配置导入、恢复默认与备份\n\n管理员通过 `PUT \u002Fapi\u002Fconfiguration\u002F` 导入完整 48 项控制台配置。提交前检查设备或槽位重复、串口参数及协议监听冲突，并备份原配置。接口结构见 `\u002Fapi\u002Fdocs`，控制台列表可通过 `GET \u002Fapi\u002Fconsoles` 获取。\n\n导入在数据库事务中保存，然后立即尝试重载运行配置；会调整连接上限、下次打开的串口配置及协议监听器，可能中断连接。当前重载不是整体原子切换，重载错误不会通过保存响应返回。因此成功响应只证明配置保存成功，不能证明所有监听器已经应用。导入后检查 `GET \u002Fapi\u002Flisteners`、启动\u002F运行日志及控制台状态；排除监听故障后可单独 POST 重试，再验证实际连接。当前系统重启标志不能作为导入是否全部生效的依据。\n\n`POST \u002Fapi\u002Fconfiguration\u002Frestore-defaults` 恢复控制台默认配置并立即尝试重载，同样可能影响连接；不重置用户、API Key 或许可证。\n\n**网页上叫“清除所有串口配置”**（“系统”页的“危险操作区”，仅管理员），**它不是恢复出厂设置**：只把全部 48 个逻辑串口的备注、硬件绑定、串口参数、连接数上限、协议开关和监听地址恢复为默认值（见“初始化都做什么”里的默认配置）。不会动的有：用户和密码、API Key、审计记录、许可证和维护令牌、`settings.toml`（密钥、Web 监听地址、`device_uuid`）、SSH 主机密钥。执行后需要重启服务才能完全生效，操作无法撤销，建议先导出配置备份。\n\n要把账户、审计记录等一起清除，用命令行的“恢复出厂设置”（见“恢复出厂设置”一节）。\n\n完整备份应在停止服务后复制 SQLite 数据库、`settings.toml`、SSH 主机密钥和许可证文件；如仍有 SQLite `-wal`、`-shm` 文件，应一并保留，或采用可靠的 SQLite 一致性备份方式。仅导出控制台列表不包含账户、API Key、审计或启动密钥。\n\n恢复时停止服务，按原路径还原完整文件集，限制文件权限，再启动验证。丢失 `api_key_pepper` 会使原 API Key 无法验证；变更 `secret_key` 会使原登录 Cookie 无法使用；变更 SSH 主机密钥会触发客户端主机身份警告。数据库及备份包含敏感认证数据，不应作为普通配置附件公开发送。\n\n## 许可证申请与安装\n\n未安装许可证时，免费授权允许运行逻辑控制台 1–2。当前直接售卖的商业授权为 8P、16P、48P 三档，其他档位不直接售卖；实际可用容量以签发的许可证为准：\n\n| 商业授权版本 | 最大逻辑串口使用数量 | 允许使用的逻辑控制台编号 |\n| :--- | ---: | :--- |\n| 8P | 8 | 1–8 |\n| 16P | 16 | 1–16 |\n| 48P | 48 | 1–48 |\n\n授权容量是总上限，不是新增数量，也不是从 48 个编号中任选相应数量。例如 8P 只允许使用逻辑控制台 1–8，不是在免费版基础上再增加 8 个；升级到 16P 后，总范围变为 1–16，而不是再增加 16 个。启动时全部 48 路已存储配置均创建 SerialBridge 和常驻 owner；其余未授权控制台保留实例及配置，但拒绝接入、不打开串口、不启动 TCP\u002FTelnet\u002FSSH 独立监听器。共享 WebSocket HTTP 服务继续运行，并逐控制台检查授权。没有客户端时，已授权控制台也不打开硬件串口。容量不是网络监听端口数，也不是客户端连接数。\n\n### 获取许可证的三种方式\n\n许可证文件由许可证服务器签发，续期也重新签发该文件。本程序只负责向服务器申请、验签和保存。\n\n| 方式 | 适用场景 | 做法 |\n| :--- | :--- | :--- |\n| 在线激活 | 设备能访问许可证服务器 | 管理员调用 `POST \u002Fapi\u002Fsystem\u002Flicense\u002Factivate`，请求体 `{\"code\":\"ACT-23456-789AB-CDEFG-HRZQE\"}`。程序把激活码、本机的设备标识（只有哈希）、主机名和程序版本发给服务器，验签后保存并立即生效。同一台设备重复激活不占新名额 |\n| 离线激活 | 设备不能上网 | 管理员生成离线请求，每份请求只能成功使用一次；在联网电脑上传至官网，下载授权文件并导入。设备时钟不影响激活，created_at 原文参与 nonce 校验，生成后不要修改。 |\n| 手动导入文件 | 已有许可证文件 | `PUT \u002Fapi\u002Fsystem\u002Flicense` 提交文件内容，或停机后把文件放到 `license_file` 指定的路径 |\n\n激活确认框会说明：激活会把这台设备绑定到授权上，并上传本机的设备标识哈希和系统架构用于识别；**激活错了，72 小时内可在官网撤销，其他换机需要联系卖家**。\n\n激活码用完即可丢弃，**不要保存在设备上**，续期也完全不需要它。\n\n### 文件格式与大小\n\n许可证文件和离线请求文件都是 UTF-8 纯文本 JSON（不带 BOM）。本机导出的文件统一命名为 `cs-YYMMDDHHmmss.license.txt` 和 `cs-YYMMDDHHmmss.request.txt`（生成时的本地时间）；激活和续期使用同一种授权文件。程序内部仍按 `license_file` 配置保存授权，默认是 `license.txt`。\n\n- **服务端签发的文件请原样保存**，不要用编辑器重新排版或另存为其他格式。签名只覆盖其中的 `payload`，改动 `payload` 或 `sig` 会导致验签失败。\n- **大小上限 8192 字节**，与许可证服务器一致。超过的内容不是真正的许可证文件：通过接口导入超限时返回 `413` 或 `422`（按校验路径），放在许可证路径下的会被判为无效，令牌文件超限会被忽略。\n- 网页选择 `.txt` 授权文件；旧 `.lic` 文件不再支持。按内容提示请求文件类型选错的情况，旧 `.token` 也不再接受。后端验签后再核对类型。自动化需要保留原文时使用 `POST \u002Fapi\u002Fsystem\u002Flicense\u002Fimport-file` 的 `file` 字符串；旧 `PUT \u002Fapi\u002Fsystem\u002Flicense` 签名信封接口仍兼容。\n\n### 许可证服务器地址\n\n在线功能连接的服务器由配置文件 `settings.toml` 中的 `license_server` 指定，格式是 `host:port` 或完整的 `http(s):\u002F\u002F` 地址。当前程序默认地址是 `https:\u002F\u002Flicense.betterone.top:8443`。设为空字符串则关闭在线激活和续期检查，只能手动导入文件。旧配置文件没有这一项时使用程序默认值；已有配置中显式写出的地址不会自动替换。修改后需重启程序。\n\n服务器不可达或回复异常时接口返回 `502`；没有配置服务器时返回 `503`。\n\n### 设备标识\n\n许可证绑定一个设备标识 `device_id`，由三个来源组成。请求、许可证文件里只有它们的 SHA-256 哈希（加产品盐和类型），不含原始值（唯一的例外是管理员主动生成的“系统诊断信息”，其中有明文，见该节）：\n\n| 字段 | 来源 | 是否一定有 |\n| :--- | :--- | :--- |\n| `hardware` | 主板 UUID（Windows 读 SMBIOS，Linux 读 DMI `product_uuid`）；没有时用 CPU 序列号（树莓派等 ARM 板） | 否 |\n| `install` | 系统安装 ID（Windows 的 `MachineGuid`，Linux 的 `\u002Fetc\u002Fmachine-id`） | 否 |\n| `generated` | 软件首次安装时生成的随机 UUID v4，保存在配置文件或数据目录的运行时身份文件，并尽可能另存一份机器级副本（见下） | 是 |\n\n- 激活请求还会带上 `platform`（操作系统和架构，如 `linux\u002Farm64`），只作参考，不参与匹配。请求里还带 `device_name`（本机主机名，读不到时为空），同样只是方便人工识别设备的标签，不参与匹配。不再使用网卡 MAC。\n- 每个来源都严格校验，不合格的视为不存在：\n  - 主板 UUID 必须是标准 36 位 UUID，不能是全 0、全 F、同一字节重复，或主板厂商常见的默认值（如 `03000200-0400-0500-0006-000700080009`）。\n  - CPU 序列号必须是 8 到 32 位十六进制，不能是同一字符重复（很多没有序列号的板子会显示全 0）。\n  - 安装 ID 去掉连字符和花括号后必须正好是 32 位十六进制，不能是同一字符重复，也不能是 `uninitialized`。\n  - `device_uuid` 必须是小写的 v4 UUID。它在首次初始化时生成，之后一直沿用；格式不对时程序拒绝启动。只读配置缺少此项时写入数据目录的 `runtime-identity.toml`；机器级副本还在时也可用于恢复。\n- Linux 上 `product_uuid` 只有 root 能读。安装包的 systemd 服务会在启动前把它复制到 `\u002Frun\u002Fbetterone-console-server\u002Fproduct_uuid` 供服务账户读取；手动以普通用户运行时读不到，`hardware` 就缺失。\n\n**匹配规则**：\n- 许可证有 `hardware`：**只比较** `hardware`（主板 UUID 或 CPU 序列号）。重装系统、丢了配置都不影响；换主板、把树莓派的 SD 卡克隆到另一块板子则不匹配；本机读不到硬件标识时也不匹配，不会退到别的来源。\n- 许可证没有 `hardware`（虚拟机、容器等读不到硬件标识的机器）：`install` 和 `generated` **两项都必须存在并且都与本机一致**。本机既没有 `hardware` 也没有 `install` 时（例如没有 `machine-id` 的容器或精简系统），无法识别身份，不能激活；`generated` 只是配置文件里的一个字符串，拷走文件就拷走了身份，所以不能单独作为依据。这样由同一个模板克隆出来的机器（系统安装 ID 相同，但各自首次启动时生成的 UUID 不同）不会共用一份许可证。代价是这类机器重装系统或配置被重建后需要重新激活。\n\n**`generated` 的保存**：首次创建配置时 UUID 写入 `settings.toml`；只读配置缺失或与机器级值不一致时，改写数据目录的 `runtime-identity.toml`，不修改管理员所有的基础配置。程序也尽可能另存一份独立于运行模式的机器级文件：\n\n| 系统 | 机器级文件 |\n| --- | --- |\n| Linux | `\u002Fvar\u002Flib\u002Fbetterone-console-server\u002Fdevice_uuid`（作为系统服务运行时就是数据目录里的文件） |\n| Windows | `%ProgramData%\\BetterOne\\ConsoleServer\\device_uuid` |\n\n- 启动时使用已有 UUID；都不存在才新生成。机器级文件与配置值不一致时**以机器级文件为准**，结果持久化到数据目录而非改写只读配置。\n- 机器级文件写不进去（例如普通用户在 Linux 上直接运行，没有权限写 `\u002Fvar\u002Flib`）不算错误，此时由配置或数据目录里的运行时身份文件保留 UUID。\n- 机器级文件的内容不是小写 v4 UUID 时程序拒绝启动，与配置文件里的 `device_uuid` 同样处理。\n- 可以用环境变量 `NBC_DEVICE_UUID_FILE` 指定别的路径，设成 `off` 则关闭这一份；测试和特殊部署才需要。\n- 卸载软件时这个文件和配置文件一样不会被删除。要重新来过（接受重新激活），需要把两处都删掉。\n- 重装操作系统会丢掉它，这时靠 `hardware`（有的机器）找回身份，没有 `hardware` 的机器需要重新激活。\n\n整机克隆（虚拟机克隆、整盘复制）会复制全部来源，无法在本机识别，需要靠服务器限制重复激活。复制配置文件或机器级文件也会复制 `generated`。需要换机请联系卖家。\n\n### 安装与生效\n\n`POST \u002Fapi\u002Fsystem\u002Flicense\u002Fimport-file` 接受许可证原文（type 必须为 license），降档时先要求确认。安装接口验证并原子保存有效文件后立即协调运行状态，**无需重启**，响应 `restart_required=false`。升级只启动新增授权控制台已配置为启用的协议；降级会断开超出授权范围的桥接会话、停止串口并关闭各自的 TCP\u002FTelnet\u002FSSH 监听器，共享 WebSocket HTTP 服务保留并逐控制台检查授权。手动禁用状态不会被许可证变更解除。无效文件或保存失败保持原许可证和运行状态不变。\n\n响应可含管理员专用的可选字符串 `runtime_error`：表示许可证已成功安装，但监听器应用未全部完成，不是安装失败或必须重启；非管理员不会收到该字段。该字段是最近一次许可证应用的部分失败报告，不随单监听器重试实时更新；最新状态以 `GET \u002Fapi\u002Flisteners` 为准。管理员应核对授权容量及日志，排除故障后可通过 `POST \u002Fapi\u002Flisteners\u002F{id}\u002Fretry` 单独重试，也可配置重载或重新导入许可证按启用配置再次尝试；没有自动定时重试。\n\n验签用的公钥按文件里的 `kid` 选择。程序只认内置的公钥表，**不会从网络上取公钥**；表里没有的 `kid` 按文件无效处理（原因 `unsupported_key`），需要升级到内置了新公钥的版本。\n\n### 维护期与升级\n\n授权是**永久**的。许可证里的 `maintenance_until` 只决定能升级到哪些版本，不是软件运行到期日。\n\n```\n有效到期日 = 许可证.maintenance_until\n版本可用   = 版本构建日期 \u003C= 有效到期日\n```\n\n比较的是软件的**构建日期**，不是当前时间，也不联网。构建日期等于到期日的版本可用，晚一天就不可用。到期后原版本照常运行，只是构建日期更晚的新版本不能升级。\n\n**安装构建日期晚于有效到期日的新版本并启动后，这个新版本的商业授权不生效，按免费 2P 运行，只允许使用逻辑控制台 1–2。这不是把授权降回去：许可证仍然有效，发布日期不晚于到期日的版本仍然可以完整使用全部端口。** 原授权范围内编号 3 及以上的逻辑控制台仍保留 SerialBridge 和常驻 owner，但拒绝接入、不打开物理串口、不启动独立协议监听器，也不能建立 WebSocket 转发会话。已有配置仍保留，不会被删除。这时许可证都还保留着：状态里 `state` 为 `update_not_covered`（不是错误，`valid` 仍为 `true`），`licensed_ports`、`license_id`、`maintenance_until` 仍然可见；导入或换取一个覆盖该版本的最新授权文件后立即恢复，无需重启。\n\n例如：有效到期日为 `2027-10-01`，构建日期为 `2027-10-01` 的版本仍在覆盖范围内；安装构建日期为 `2027-10-02` 的版本后，即使此前持有 48P 授权，启动后也只能使用编号 1–2，直到续期。\n\n### 换机、重装、激活错设备\n\n**程序本身不提供释放、解绑或停用本机的功能。** 客户端在用户手里，把批量解绑的入口做进去，等于交给破解者；而且设备上已有的授权文件释放后仍然有效，释放只是服务器上名额的账面操作，所以必须放在有订单验证、失败锁定和次数上限的官网上。\n\n| 情况 | 谁来操作 | 怎么做 |\n| :--- | :--- | :--- |\n| 设备激活后 **72 小时内**激活错了 | 买家自己 | 到官网“查看设备”页，用激活码加订单号（或邮箱）验证后撤销。每个授权的免费撤销次数有限 |\n| 超过 72 小时、次数用完、或授权没有登记订单信息 | 卖家 | 联系卖家，由卖家在管理控制台释放 |\n\n撤销之后，买家到新设备上按正常流程激活即可，程序不需要知道撤销这件事。\n\n`GET \u002Fapi\u002Fsystem\u002Flicense` 返回 `portal_url`（设备管理页地址，任何人可见；地址确定前为空，界面在为空时不显示入口），界面应在“授权”页提供入口，并写一句“更换电脑或激活错了设备，请先在官网撤销旧设备”。激活遇到 `seats_full`、续期遇到 `device_released` 时，接口的错误消息会附上说明，设备管理页地址确定后还会附上地址。\n\n### 维护期按设备计算\n\n每台设备从自己首次激活起算维护期，续期只延长这一台设备的维护期。显示和升级校验以本机授权文件中的日期为准，不假设同一激活码下的设备到期日相同。\n\n### 许可证状态字段（给界面和脚本）\n\n`GET \u002Fapi\u002Fsystem\u002Flicense` 的 `state` 已经把所有检查合并成一个值，界面只需要判断它，不用再去比较日期或匹配错误文字：\n\n| `state` | 含义 | 界面建议 |\n| :--- | :--- | :--- |\n| `free` | 没有安装许可证，正常的免费版（2 个端口） | “当前使用免费版”，按 `actions.activate` 显示激活入口 |\n| `licensed` | 许可证有效，并覆盖此版本 | 显示档位和维护到期日 |\n| `update_not_covered` | 许可证有效，但**此版本**发布于维护期结束之后，此版本按免费版运行。**不是错误，授权没有被降回去**：发布日期不晚于 `maintenance_until` 的版本仍然可以完整使用 `licensed_ports` 个端口 | 提示“此版本（`build_date`）超出维护期（到 `maintenance_until`），可续期，或改用维护期内的版本”，按 `actions.renew` 显示续期按钮 |\n| `invalid` | 有许可证但不能使用 | 按 `reason`（`device_mismatch`、`signature_invalid`、`unsupported_key`、`file_too_large`、`unreadable`、`license_invalid`、`device_unavailable`）显示对应文案 |\n\n“激活与续期”共用一个输入框，按 ACT \u002F RNW 码类型自动识别操作，已激活后也可输入新激活码更新授权；未配置服务器时在线按钮禁用，可下载对应的离线请求。离线请求下载与许可证导入按钮位于同一输入框下方。替换授权可能改变可用端口范围，请确认后再操作。\n\n其余字段：`edition` 是显示用的档位名；`max_ports` 是此版本现在实际可用的端口数；`licensed_ports` 是许可证授予的端口数；`actions` 里的 `activate`、`renew` 表示对应在线操作是否可用（需要配置了 `license_server`）。`valid` 已废弃，只在 `invalid` 时为 `false`。`reason`、`licensed_ports`、`actions` 和各种详情只有管理员能看到；匿名请求只有 `state`、`edition`、`max_ports`、`build_date` 等足够画提示条的内容。\n\n### 续期：检查最新授权文件\n\n“检查维护续期”使用本机已保存的授权原文与设备身份调用服务端 `POST \u002Fv1\u002Flicense\u002Frefresh`，返回新的 `license_file`。不需要激活码，且不受重新激活间隔限制；新文件保持授权 ID、设备与端口数不变，只更新维护期、签发时间等。\n\n同一授权只有维护期更晚才替换；日期相同或更早不写入，提示当前日期。被卖家释放的设备不能获取新文件，但原文件仍可继续使用已覆盖版本。\n\n#### 离线续期\n\n设备不能联网时，到能上网的电脑上通过官网“离线激活 \u002F 续期”页完成。官网只收一个文件，按文件内容自动识别，不用在网页上填续期码：\n\n1. 准备文件，拷到联网电脑：\n   - 买了续期码：在“激活与续期”输入 RNW 续期码，点击“生成续期请求文件”（文件名以 `.renewal.txt` 结尾）；在线兑换连接失败时，也可按提示确认生成离线请求；\n   - 卖家已经在后台续期：在“激活与续期”展开“设备无法联网？离线续期”，导出本机授权文件（文件名以 `.license.txt` 结尾），用于获取服务器上最新的授权文件，不会兑换续期码。\n2. 打开官网“离线激活 \u002F 续期”页（`\u002F#offline`，旧地址 `\u002F#renew` 打开的也是这一页），上传上一步的文件。\n3. 从官网下载新的 `.license.txt` 授权文件，拷回本机。\n4. 使用“导入许可证”选择文件或粘贴原文，验证后安装。每台设备分别操作。\n\n导入会验签并匹配本机设备。同一授权的旧文件不倒退日期；不同授权可以替换，但新文件端口数更少时须先确认降档。取消不改原文件。当前端口数取已购买授权，不是免费回退路数。文件最大 8192 字节，选择文件不会自动安装。\n\n管理员 API：`GET \u002Fapi\u002Fsystem\u002Flicense\u002Fexport` 导出原文（不缓存）；`POST \u002Fapi\u002Fsystem\u002Flicense\u002Fimport-file` 接受 `{ \"file\": \"原文\", \"allow_downgrade\": false }`。只在用户明确确认后改为 true 重发原文件。无新日期不庆祝，续期成功保留烟花，激活成功保留礼花。\n\n### 输入续期码\n\n激活码为 `ACT-` 前缀，续期码为 `RNW-` 前缀，后接 20 个字符（16 位随机部分、4 位校验位）。只接受 `23456789ABCDEFGHJKMNPQRSTUVWXYZ`；规范化时忽略大小写、连字符、空格和制表符。可以省略前缀，此时按两种前缀分别验证校验位以识别种类。激活与离线请求框拒绝续期码，续期框拒绝激活码；错误码或校验位无效时不会向授权服务端发请求。\n\n授权页提供“兑换续期码”：使用本机授权文件及设备身份向授权服务端 `POST \u002Fv1\u002Frenew` 兑换。一个码续一台设备，码的路数不能低于授权路数。兑换后自动验签并导入新的授权文件，无需再点检查续期；兑换不可撤回或转移。\n\n本地接口为 `POST \u002Fapi\u002Fsystem\u002Flicense\u002Fredeem-renewal`，请求 `{\"renewal_code\":\"RNW-23456-789AB-CDEFG-H3G4C\"}`，仅管理员可用。重复兑换同一码安全，`already_redeemed=true` 表示不重复增加年数或扣次数。代码只用于此次请求，不保存。\n\n续期码覆盖的路数高于本机授权时，软件会弹出确认框，显示续期码和本机各自的路数，并说明兑换后不能撤回或转给其他设备。确认后才携带 `confirm_ports: true` 重新兑换原码；取消不会再次请求，续期码未被使用。路数相同直接续期，路数不足则提示规格不符且码未使用。续期只延长维护期，不增加授权路数；路数比授权多的续期码不会带来额外的路数或其他好处，续期时长和路数相符的码一样，请确认买对了续期码。\n\n维护期按设备独立计算，同一激活码下各设备的维护日期可以不同；日期一律以本机签名授权文件为准。续期后的维护期最多到“今天 + 5 年”，超过时会提示维护期已经很长，请过段时间再续，续期码没有被使用。客户端不自行预测续期后的日期；自然月和月末规则由服务端处理。\n\n文件导入使用 `POST \u002Fapi\u002Fsystem\u002Flicense\u002Fimport-file`，请求 `{\"file\":\"\u003C完整文件文本>\"}`，保留原始文本；旧 `PUT \u002Fapi\u002Fsystem\u002Flicense` 信封接口继续可用。\n\n### 在线接口的错误\n\n| HTTP | 含义 |\n| :--- | :--- |\n| 400、403、409、422、429 | 许可证服务器的拒绝，状态码原样保留，消息里带服务器的错误码，例如 `seats_full`（设备数已满，消息会提示 72 小时内激活错的设备可在官网撤销）、`code_voided`（授权已作废）、`code_expired`（超过激活截止日期）、`device_released`（设备已被释放）、`invalid_license`、`rate_limited`（失败次数过多或请求太频繁，同一激活码或 IP 连续失败 5 次，首次锁定 3 分钟，之后按服务端规则递增） |\n| 422 | 激活码格式不对；服务器认为激活码不存在或无效（`invalid_code`，服务器返回 404，这里统一为 422）；文件验签失败、不是本机的许可证等 |\n| 409 | 没有安装许可证就检查续期 |\n| 502 | 服务器不可达，或回复格式不对 |\n| 503 | 没有配置 `license_server` |\n\n“激活码不存在”和“格式正确但无效”对外都是 `invalid_code`，这是为了防止被用来试探，请不要想办法区分。\n\n### 升级前\n\n升级前请核对维护覆盖日期，并停机备份原程序、数据库、配置、SSH 主机密钥和许可证，避免超出授权范围的串口业务中断。要使用维护期之外的新版本，请联系作者续期，然后用“检查续期”换取最新授权文件，立即生效，无需重启。若回退原版本，请先确认数据库和配置兼容性，必要时恢复升级前的备份。\n\n**更新维护期结束后，用户选择继续使用旧版本，应自行承担旧版本缺陷、安全漏洞、兼容性问题及其引发的数据损失或业务中断风险。在适用法律允许的最大范围内，作者不对由继续使用旧版本导致的问题及损失承担责任；适用法律规定不得免除的责任除外，另行书面约定的维护或支持义务仍按约定执行。** 建议及时评估风险、做好备份，并按需联系作者续期。\n\n设备 UUID 或 Serial 改变时，需联系开发者核实迁移或补发；软件没有自动迁移功能。\n\n## 串口状态提示\n\n已配置设备绑定后，串口打开失败或运行中断线时，TCP、Telnet、SSH 和 WebSocket 客户端会话会在重试窗口内保持；此期间输入直接丢弃，不会缓存到重连后的串口。未配置设备名或 USB 槽位时，不进入持续重连流程；重试超过 300 秒仍未恢复时关闭客户端会话。\n\n服务端会向所有已连接客户端写入以下状态提示：\n\n| 触发条件 | 客户端收到的原始文本 |\n| :--- | :--- |\n| 首次打开失败或运行中断线 | `\\r\\n[serial] device unavailable; input will be discarded while reconnecting\\r\\n` |\n| 持续无法连接 | 同上，最多每 30 秒发送一次 |\n| 断线后重新连接成功 | `\\r\\n[serial] device connected\\r\\n` |\n| 重连超过 300 秒仍未恢复 | `\\r\\n[serial] reconnect timeout; closing session\\r\\n`，发送后关闭客户端会话 |\n\n首次正常打开成功时不会打印 `device connected`；该提示只用于发生故障后的恢复。\n\n以上状态提示会直接进入客户端数据流，并不来自物理串口设备。使用 Raw TCP 或 WebSocket 传输纯二进制协议时，客户端必须能够识别并处理这些状态文本。\n\n监听器故障与硬件故障是两个诊断层：`GET \u002Fapi\u002Flisteners` 检查网络绑定\u002F服务；`GET \u002Fapi\u002Fconsoles\u002F{conf_id}\u002Fstatus` 检查串口打开、读写及重连。监听器为 `running` 时串口仍可能不可用；POST 监听器重试不会修复驱动、线缆或设备占用，也不会重新启动已运行监听器。下述串口自动重连不等于监听器自动定时重试。\n\n### 串口打不开：权限与占用\n\n`GET \u002Fapi\u002Fconsoles\u002F{conf_id}\u002Fstatus` 的 `last_error` 和网页上的串口状态会写明原因：\n\n| 提示 | 含义 | 常见于 |\n| :--- | :--- | :--- |\n| `serial device access denied` | 运行程序的账户没有读写这个串口设备的权限 | Linux |\n| `serial device is busy` | 设备被别的程序占用 | Windows、Linux |\n| `serial device is unavailable` | 设备不存在：没插、设备名写错、USB 转串口线被拔掉，或驱动没装好 | Windows、Linux |\n\n#### Linux\n\n串口设备（`\u002Fdev\u002FttyUSB0`、`\u002Fdev\u002FttyACM0`、`\u002Fdev\u002FttyS0` 等）一般属于 `root:dialout`、权限 660，只有 root 和 `dialout` 组能读写。deb\u002Frpm 安装包会把服务账户 `betterone-console-server` 加进 `dialout` 组（RPM 系发行版是 `uucp`），所以按安装包使用时通常不会遇到权限问题。以下几种情况仍会遇到：\n\n1. **树莓派板载串口**（GPIO 上的 `\u002Fdev\u002FttyAMA0`、`\u002Fdev\u002FttyS0`、`\u002Fdev\u002Fserial0`）：系统默认把它当作登录控制台，设备属于 `root:tty`，而且被登录程序占着。USB 转串口线（`\u002Fdev\u002FttyUSB0`）不受影响。\n2. 系统里既没有 `dialout` 组也没有 `uucp` 组（少数精简系统），安装脚本无组可加。\n3. 不用安装包、自己用普通用户直接运行程序，而这个用户不在 `dialout` 组。\n4. 自定义的 udev 规则把设备权限改得更严。\n\n先确认原因：\n\n```sh\nls -l \u002Fdev\u002FttyUSB0                              # 设备的属主和组，通常是 root dialout\nid betterone-console-server                     # 服务账户在不在 dialout 组\njournalctl -u betterone-console-server -n 50    # 打开串口时的具体报错\nsudo fuser \u002Fdev\u002FttyUSB0                         # 设备被哪个进程占用\n```\n\n临时处理办法（按上面的原因选）：\n\n| 原因 | 处理 |\n| :--- | :--- |\n| 服务账户不在 `dialout` 组 | `sudo usermod -aG dialout betterone-console-server`，再 `sudo systemctl restart betterone-console-server`。**必须重启服务**，组变更才对它生效 |\n| 自己用普通用户运行 | `sudo usermod -aG dialout \u003C用户名>`，然后**退出登录再登录**（或重启），再启动程序。树莓派默认的 `pi` 用户已经在这个组里 |\n| 树莓派板载串口被当作登录控制台 | `sudo raspi-config` → Interface Options → Serial Port：“login shell over serial” 选 **No**，“serial port hardware enabled” 选 **Yes**，然后重启树莓派。之后设备属于 `dialout` 组（不同系统版本的菜单名可能略有不同） |\n| 系统没有 `dialout`\u002F`uucp` 组 | 看 `ls -l` 显示的设备所属组，把服务账户加进这个组（`sudo usermod -aG \u003C组名> betterone-console-server`），再重启服务 |\n| udev 规则太严 | 改规则，或加一条规则让设备属于 `dialout` 组、权限 660，然后 `sudo udevadm control --reload && sudo udevadm trigger`，重新插拔设备 |\n| 设备被其他程序占用 | 用 `sudo fuser` 找到占用它的进程，关掉它（例如另一个串口工具，或树莓派上的串口登录控制台） |\n\n不要用 `chmod 666 \u002Fdev\u002FttyUSB0` 这类办法：设备重新插拔或系统重启后就失效，而且会让所有用户都能读写串口。\n\n#### Windows\n\nWindows 的 COM 口没有 Linux 那样按组划分的权限，默认所有用户都能打开，所以一般**不会**出现 `serial device access denied`。打开时系统报“拒绝访问”，实际意思是**这个 COM 口已经被别的程序打开了**，程序会显示为 `serial device is busy`。\n\n| 提示 | 处理 |\n| :--- | :--- |\n| `serial device is busy` | 关掉正在使用这个 COM 口的程序，例如 PuTTY、SecureCRT、厂商调试工具、其他串口软件，或另一份正在运行的本程序。同一个 COM 口同一时间只能被一个程序打开 |\n| `serial device is unavailable` | 打开“设备管理器” →“端口 (COM 和 LPT)”，确认设备在列表里、COM 编号和配置一致；带黄色感叹号说明驱动没装好，装好厂商驱动（如 FTDI、CH340、CP210x）后重新插拔；也可以用按 USB 物理位置或设备身份绑定，避免 COM 编号变化 |\n\n少数企业电脑通过组策略禁用了串口或 USB 串口设备，这种情况需要请电脑管理员放开，程序这边无法处理。\n\n### 串口故障边界与硬件验收\n\n串口驱动调用中的可恢复 panic 会转换为错误，服务日志记录堆栈；空句柄（含 typed nil）及非法读写计数会被拒绝。部分写入会继续写剩余数据；读取同时返回数据和错误时，先转发当前有效数据，再处理故障。立即返回的空读取有可取消的短等待，避免忙循环；重试等待不超过剩余窗口，到期后不再发起打开尝试。每次打开使用独立身份，旧读取及旧句柄排队命令不会混入新连接，取消后的剩余写入不会在重连后重放；已交给硬件的字节无法撤回。\n\nWindows\u002FLinux 使用本地原生串口 I\u002FO，写超时默认 5 秒，Bridge 配置读超时 250 ms；正常读超时仅表示暂无输入，不应触发重连。停止时 `CancelIO()` 永久停止当前句柄的新操作并请求取消，不清空缓冲区，也不表示物理释放。owner 等待在途操作结束再关闭；Windows 必须确认 overlapped 完成后才能释放事件和缓冲区，串口或每次 I\u002FO 的事件句柄关闭失败均保留，由后续 Close 重试；只有所有自有 OS 句柄清理完成才标记 Released。Linux 使用非阻塞 poll\u002F取消信号及 100 ms 取消检查兜底，等待活动操作后先解除独占，再关闭 raw fd 一次，不在 EINTR 后重试数字 fd。\n\n打开失败也可能留下存活句柄，仍须清理。Bridge 在任何下一次 opener 前清理旧句柄；释放未确认时隔离保留，不能盲目反复打开。暂停、许可证控制或非阻塞 Bridge `Close()` 返回，以及状态显示 idle\u002Fdisabled，都不是物理释放证明。超时\u002Fcontext\u002F取消不能强制异常驱动完成取消或关闭；可能需要维护设备\u002F驱动、重启服务或系统，不保证绝对限时释放。owner 使用单个定时器按 `retryInterval` 重试保留句柄的 Close，即使没有客户端、手动禁用或 Bridge 永久关闭也继续清理；确认释放前不打开新句柄，但重试不是驱动强制释放保证。Windows\u002FLinux Drain 轮询等待输出队列为空，不保证硬件移位寄存器发送完成；Break 可取消。旧测试替身的 reset fallback 不是生产取消策略。实现也不保证抵御 OOM、致命运行时错误或操作系统故障。\n\n**真实硬件验收尚未完成。** 编译后的 Linux internal\u002Fserial PTY 套件已在 WSL Ubuntu 实际运行 3 次 PASS；实际 ConfiguredSerialOpener + Bridge 的 Linux PTY 集成测试运行 10 次 PASS，覆盖读写、700ms 空闲无意外重连、每次运行 10 轮 teardown\u002FReleased\u002F重开、最后客户端离开\u002F手动禁用\u002F许可证撤销、原生初始化错误清理和描述符泄漏检查。Windows syscall seam mock 测试已实际运行通过。本轮未启用 race；这些 WSL PTY 测试不是物理 USB 驱动测试或原生 Linux 发布验收，后两者仍待完成。可复现 Linux PTY 命令见 `internal\u002Fserial\u002FREADME.md`；这些测试不替代目标硬件验收。\n\n上线前使用真实设备、独立的正常 Console 和服务日志进行以下人工验收（驱动阻塞\u002F错误可配合故障注入环境）：\n\n- 通信中拔掉线缆：检查状态提示、错误及重试；重新插入后验证恢复，断线期间输入和旧排队命令不重放。\n- 绑定不存在或被其他程序占用的设备：检查打开失败、重试等待及到期关闭会话，到期后无额外打开尝试；解除占用后重新接入验证恢复。\n- 制造串口读\u002F写失败：核对日志与状态；读取带错误的有效尾部数据仍先转发，部分写入正常续写，失败或取消后的剩余命令不重放。\n- 持续输出并接入不读取数据的慢客户端：检查正常客户端和其他 Console 仍可通信，确认慢客户端达到 64 KiB\u002F512 帧输出队列上限或单次写入 10 秒超时时被断开并记录原因，而不是静默丢弃数据。SSH 关闭的是该客户端底层连接，该连接上的其他会话也会结束。\n- 持续写入时反复禁用、变更许可证或关闭 Bridge，注入取消完成\u002F关闭失败：检查控制 API 返回不被误判为释放、其他 Console 仍正常；旧句柄释放未确认时不得调用任何下一次 opener。Windows CloseHandle 失败后验证保留句柄和清理重试；Linux 验证独占解除后可重开且关闭不会误伤复用 fd。恢复后确认取消的剩余命令不重放；若驱动始终不完成，安排设备\u002F驱动维护或服务\u002F系统重启，不以自动恢复作为验收保证。\n\n## 逻辑串口状态查询\n\n通过 `GET \u002Fapi\u002Fconsoles\u002F{conf_id}\u002Fstatus` 查询逻辑控制台状态，例如 `GET \u002Fapi\u002Fconsoles\u002F1\u002Fstatus`。需要认证（会话 Cookie 或 `X-API-Key`），未登录返回 401，conf_id 范围 1–48。\n\n| 字段 | 含义 |\n| :--- | :--- |\n| `state` | idle、connecting、connected、retry、error；授权限制时为 disabled |\n| `message` | 当前状态说明；重试时包含原因及约多少秒后重试 |\n| `serial_release_pending` | 始终输出的布尔值；物理 Close 进行中、等待 abort 或释放失败仍保留句柄时为 true；确认释放前不会打开新句柄 |\n| `serial_stalled_operation` \u002F `serial_stalled_for_seconds` | 连续 30 秒未完成的串口操作及已持续秒数；多项阻塞时报告最早一项；无告警为空字符串和 null。表示疑似设备或驱动无响应，不能证明永久卡死。详情页显示处理建议，终端工具栏显示简短告警；操作结束后自动清除，授权限制不隐藏该告警。 |\n| `connection_count` | 所有协议合计的当前客户端会话数 |\n| `max_connections` | 当前配置的连接上限，范围 1–256，默认 1，无无上限模式；未授权时返回已保存上限 |\n| `configured_device` \u002F `configured_slot` | 已保存设备名或 USB 槽位绑定，无绑定时为空字符串 |\n| `resolved_device` | 当前实际打开设备名；未连接或信息不可用时为空字符串 |\n| `active_serial_config` | 当前句柄使用的串口配置；未连接或信息不可用时为 null |\n| `connected_for_seconds` | 当前串口已连接的整秒数；未连接时为 null |\n| `last_error` \u002F `last_error_ago_seconds` | 最后一次受控错误说明及距该错误的整秒数；新错误覆盖旧错误，连接成功后清除，返回空字符串和 null |\n| `retry_count` | 本次客户端会话周期内已安排的重试等待次数；最后一个客户端离开后保留，下次首个客户端接入时清零 |\n| `retry_elapsed_seconds` | 当前重试窗口已经过的整秒数，仅重试或重新连接时有值，其他情况为 null |\n| `retry_in_seconds` | 约多少秒后尝试打开串口，向上取整；仅 retry 等待期间有值，其他情况为 null |\n\n时长由服务端使用 Go 单调时钟计算，不依赖客户端时间，也不受进程运行期间系统校时影响；已过时长向下取整，剩余等待向上取整，最小为 0。0 表示不足一秒或已到计划时间，null 表示该字段不适用。前端通过轮询更新，剩余秒数仅作提示，不作为实际重试执行依据。状态接口不再返回 connected_at、last_error_at、retry_started_at、next_retry_at。审计日志仍记录绝对时间，按服务端时间解读。\n\n`serial_release_pending` 已包含在生成的 OpenAPI 中，与 `GET \u002Fapi\u002Flisteners` 的网络监听器状态独立。禁用\u002F断开连接响应及 idle\u002Fdisabled 状态不保证资源已释放，应另外检查该字段。\n\n最后一个客户端离开后串口按需关闭。如果最后一次连接未成功，保留最后一次错误；之后连接成功则立即清除错误及错误时长。last_error 不返回驱动原始错误或内部路径；详细驱动信息仍需查看服务日志。\n\n修改串口配置后，已保存配置与当前句柄配置可能不同。configured_device\u002Fconfigured_slot 为最新保存绑定；active_serial_config 为打开当前句柄时的配置，resolved_device 为真实设备名。USB slot 绑定时，active_serial_config.device 可以为空，通过 resolved_device 查看实际 COM\u002Ftty。其他已保存参数可通过 `GET \u002Fapi\u002Fconsoles\u002F{conf_id}` 查询；下次自动重连或重新接入使用新配置。\n\n授权限制禁用时，state 为 disabled、message 为 license_limit，连接数和重试次数为 0，当前句柄信息为空，时长字段为 null。`serial_release_pending` 仍独立反映未完成的释放，禁用不强制将其清零。接口暂未提供按协议连接数、收发字节数和丢帧统计。\n\n## 用户管理\n\n### 功能总览\n\n| 功能 | 操作权限 | 说明 |\n| :--- | :--- | :--- |\n| 查看用户 | `admin` | 查看账号、角色、启用状态、改密标志和相关时间，不返回密码哈希 |\n| 创建用户 | `admin` | 设置用户名、初始密码、角色和启用状态；密码至少 8 个字符，不能仅含字母或仅含数字，新用户带有首次改密标记，Web 界面要求改密 |\n| 编辑用户 | `admin` | 修改用户名、角色（`admin` \u002F `engineer`）或启用状态；修改后该用户的旧登录会话失效 |\n| 重置密码 | `admin` | 无需目标用户的原密码；重置后旧登录会话失效，设置改密标记，Web 界面要求下次登录后改密 |\n| 删除用户 | `admin` | 永久删除账号，使旧登录会话失效 |\n| 修改自己的密码 | 已登录的 `admin` \u002F `engineer` | 提供当前密码后修改；修改后当前及其他旧登录会话全部失效 |\n\n内置 `admin` 不能改名或删除，系统必须始终保留至少一个启用的管理员。具体限制和操作要求见下文。\n\n### 角色权限\n\n用户分为以下两个角色：\n\n| 角色 | 用户管理权限 |\n| :--- | :--- |\n| `admin` | 可以查看、创建、编辑和删除用户，也可以重置所选用户的密码 |\n| `engineer` | 不能访问用户管理功能，只能修改自己的密码 |\n\n### 创建用户\n\n- 用户名不能为空且不能与现有用户名重复。\n- 角色只能选择 `admin` 或 `engineer`。\n- 初始密码至少为 8 个字符。\n- 未明确指定状态时，新用户默认为启用。\n- 管理员创建的新用户带有 `must_change_password` 标记，当前 Web 界面要求先修改初始密码。该限制由界面实现，API 和 SSH 未以此标记统一拒绝其他操作。首次初始化的 `admin` 已自行设置密码，不会再次要求修改。\n\n### 编辑用户\n\n管理员可以修改普通用户的用户名、角色和启用状态。修改用户信息后，该用户已有的登录会话立即失效，需要重新登录。\n\n系统必须始终保留至少一个处于启用状态的管理员。因此，当目标用户是最后一个启用的管理员时，不能执行以下操作：\n\n- 将其角色从 `admin` 改为 `engineer`；\n- 将其状态改为停用；\n- 删除该用户。\n\n### 默认管理员保护\n\n系统内置的 `admin` 是保底管理账号，适用以下额外规则：\n\n- 不能删除；\n- 不能修改用户名；\n- 可以修改或重置密码；\n- 只有系统中还存在其他启用的管理员时，才能停用或降级该账号。\n\n### 密码修改与重置\n\n“修改自己的密码”和“管理员重置所选用户密码”是两种不同操作：\n\n| 操作 | 是否需要当前密码 | 操作结果 |\n| :--- | :--- | :--- |\n| 用户修改自己的密码 | 需要 | 新密码立即生效，当前及其他旧会话全部失效 |\n| 管理员重置所选用户密码 | 不需要 | 重置密码立即生效，目标用户的旧会话全部失效，设置改密标记，Web 界面要求其下次登录后改密 |\n\n管理员在用户管理页面为其他用户重置密码时，无需输入目标用户或管理员的当前密码，只需输入并确认至少 8 个字符，不能仅含字母或仅含数字的新密码。\n\n### 删除用户\n\n- 默认 `admin` 不能删除。\n- 最后一个启用的管理员不能删除。\n- 其他用户可以由管理员删除。\n- 用户删除后，其账号和已有会话立即失效，且该操作不可撤销；如需临时阻止登录，应优先停用用户。\n\n### 用户管理 API\n\n用户管理接口均要求 `admin` 权限，完整请求及响应结构可在 `\u002Fapi\u002Fdocs` 或 `\u002Fapi\u002Fopenapi.json` 中查看。\n\n`\u002Fapi\u002Fdocs` 的文档组件资源随程序内嵌，浏览器只需能连接本机服务，无需访问外网即可查看文档并测试本机接口。\n\n| 方法与路径 | 用途 |\n| :--- | :--- |\n| `GET \u002Fapi\u002Fusers` | 获取用户列表 |\n| `POST \u002Fapi\u002Fusers` | 创建用户 |\n| `PATCH \u002Fapi\u002Fusers\u002F{id}` | 修改用户名、角色或启用状态 |\n| `DELETE \u002Fapi\u002Fusers\u002F{id}` | 删除用户 |\n| `PUT \u002Fapi\u002Fusers\u002F{id}\u002Fpassword` | 重置所选用户密码，不需要当前密码 |\n\n### SSH 公钥登录\n\n连接 SSH 串口时，除了密码，也可以用 SSH 公钥登录，适合脚本、巡检和自动化工具：不用把密码写进脚本，也不怕被暴力猜测。两种方式同时有效，加了公钥不影响密码登录。\n\n**添加自己的公钥**：登录网页 → 右上角用户菜单 → “我的 SSH 公钥”，粘贴 `.pub` 文件的完整一行（例如 `id_ed25519.pub` 的内容），可另起一个名称，然后“添加公钥”。还没有密钥时，在自己的电脑上运行：\n\n```sh\nssh-keygen -t ed25519\n```\n\n**管理员替别人管理**：“用户”页面 → 该用户一行的“SSH 公钥”，可以查看、添加和删除他的公钥。\n\n**连接**：和密码登录一样，客户端会自动使用私钥：\n\n```sh\nssh -p 6001 alice@\u003C服务器IPv4>\n```\n\n**规则**\n\n- 支持 ed25519、ECDSA（含硬件安全密钥 `sk-` 类型）和至少 2048 位的 RSA；不支持 DSA、证书和带 `from=`、`command=` 等选项的行。\n- 每个用户最多 10 个公钥；同一个公钥不能给同一个用户加两次。\n- 只粘贴公钥（`.pub`）。私钥（以 `-----BEGIN ... PRIVATE KEY-----` 开头）会被拒绝，也不要交给任何人。\n- 账号被停用、被删除，或需要修改密码（新建用户、密码刚被重置）时，公钥同样不能登录。新用户请先在网页上登录一次并修改初始密码。\n- 删除公钥或停用账号后，新的连接会被拒绝；已经打开的 SSH 会话不会被断开，需要时在端口页面断开。\n- 列表显示每个公钥的指纹（与 `ssh-keygen -lf id_ed25519.pub` 的结果一致）和最近使用时间。\n- 用公钥登录成功会写一条 `ssh-login` 审计记录，目标里注明所用公钥的指纹；添加、删除公钥也都有审计记录。\n- API Key 不属于任何用户，不能有自己的 SSH 公钥。\n- 恢复出厂设置会清除所有用户，公钥随之清除。\n\n| 方法与路径 | 用途 |\n| :--- | :--- |\n| `GET \u002Fapi\u002Fauth\u002Fme\u002Fssh-keys` | 当前登录用户的公钥列表 |\n| `POST \u002Fapi\u002Fauth\u002Fme\u002Fssh-keys` | 给自己添加公钥，请求 `{\"public_key\": \"...\", \"comment\": \"可选名称\"}` |\n| `DELETE \u002Fapi\u002Fauth\u002Fme\u002Fssh-keys\u002F{key_id}` | 删除自己的公钥 |\n| `GET \u002Fapi\u002Fusers\u002F{id}\u002Fssh-keys` | 管理员查看某个用户的公钥 |\n| `POST \u002Fapi\u002Fusers\u002F{id}\u002Fssh-keys` | 管理员给某个用户添加公钥 |\n| `DELETE \u002Fapi\u002Fusers\u002F{id}\u002Fssh-keys\u002F{key_id}` | 管理员删除某个用户的公钥 |\n\n## API Key 管理\n\nAPI Key 的查看、创建、修改和删除均要求 `admin` 权限。\n\n### 创建与保存\n\n创建时填写名称 `client_name` 和角色 `role`（`admin` 或 `engineer`）。明文 `api_key` 只在创建成功时返回一次，请当场保存；之后无法通过列表或其他接口重新获取完整密钥。\n\n### 创建后修改\n\n创建后可以修改角色和启用状态，不能修改名称或密钥本身。\n\n| 字段 | 是否可修改 | 说明 |\n| :--- | :--- | :--- |\n| `role` | 可以 | 修改为 `admin` 或 `engineer` |\n| `enabled` | 可以 | `true` 启用，`false` 禁用；禁用后不能用于新的认证请求 |\n| `client_name` | 不可以 | 如需更改名称，应创建新的 API Key |\n| `api_key` | 不可以 | 如需更换密钥，应创建新的 API Key |\n\n通过 `PATCH \u002Fapi\u002Fapi-keys\u002F{id}` 部分更新，只提交需要修改的字段。例如，将角色改为工程师并禁用：\n\n```json\n{\"role\":\"engineer\",\"enabled\":false}\n```\n\n### 更换与删除\n\n需要更换名称、轮换密钥或遗失明文密钥时，创建新的 API Key，保存新密钥并更新使用它的客户端，然后删除旧 Key。名称必须唯一，新建时应使用未被占用的名称。删除不可撤销；如需临时暂停使用，可以先禁用。\n\n### API Key 管理接口\n\n| 方法与路径 | 用途 |\n| :--- | :--- |\n| `GET \u002Fapi\u002Fapi-keys` | 查看列表，密钥哈希脱敏，不返回明文密钥 |\n| `POST \u002Fapi\u002Fapi-keys` | 创建，明文密钥仅返回一次 |\n| `PATCH \u002Fapi\u002Fapi-keys\u002F{id}` | 修改角色或启用状态 |\n| `DELETE \u002Fapi\u002Fapi-keys\u002F{id}` | 永久删除 |\n\n## 修改 Web\u002FAPI 监听地址（重启生效）\n\n网页界面、REST API 和 WebSocket 终端共用一个监听地址，来自配置文件 `settings.toml` 的 `http_host` 和 `http_port`（默认 `0.0.0.0:80`）。管理员除了手工改配置文件，也可以用接口修改：\n\n| 接口 | 作用 |\n| :--- | :--- |\n| `GET \u002Fapi\u002Fsystem\u002Fweb-address` | 查看当前地址 |\n| `PUT \u002Fapi\u002Fsystem\u002Fweb-address` | 保存新地址，请求体 `{\"host\":\"0.0.0.0\",\"port\":8080}` |\n| `DELETE \u002Fapi\u002Fsystem\u002Fweb-settings` | 清除网页保存的 HTTP\u002FHTTPS 地址和证书设置，重启后恢复基础配置；需先确认地址可能变化 |\n\n只有管理员可以使用，每次修改都会写入审计记录。\n\n**不知道该填哪个地址时**：管理员或工程师可以调用 `GET \u002Fapi\u002Fsystem\u002Fipv4-addresses` 查看本机已启用网卡上的 IPv4 地址（接口名、地址、前缀长度，以及是否回环地址 `127.x.x.x`、是否自动分配的 `169.254.x.x`）。列表里的地址都可以用作监听地址；`0.0.0.0` 表示所有地址，也可以用，但不在列表中。客户端要连接的地址，一般选 `loopback` 和 `link_local` 都为 `false` 的那个。\n\n**只在重启后生效。** 保存只会改数据目录的 Web 设置，正在运行的服务不会换地址，所以修改后访问地址不变，直到重启程序。响应里的 `running` 是现在正在使用的地址，`saved` 是已保存、下次启动使用的地址，`restart_required` 为 `true` 表示需要重启；`GET \u002Fapi\u002Fsystem\u002Fstatus` 的 `service_restart_required` 也会变为 `true`。把地址改回正在运行的那个，`restart_required` 恢复为 `false`。\n\n**保存前会做的检查**（避免重启后程序连不上）：\n\n- `host` 必须是本机的 IPv4 地址，或 `0.0.0.0`（所有地址）；不是本机的地址返回 `422`。\n- `port` 为 1–65535，并且不能落在控制台监听端口的范围内：5001–5048（Raw TCP）、6001–6048（SSH）、7001–7048（Telnet），这些端口可能现在关着、以后会被启用；落在其中返回 `422`。\n- 换了端口时，会立刻试着绑定一次，端口被别的程序占用或没有权限（例如 Linux 非 root 使用 80）返回 `409`。只改 `host`、端口不变时不试绑，因为当前服务正占着这个端口，重启会先释放它。\n\n基础配置文件、注释、密钥和其他设置原样保留；只在数据目录的覆盖文件中修改 `http_host`、`http_port`，通过临时文件加重命名的方式原子替换。\n\n**用 `--addr` 启动时**：`--addr` 指定的地址每次启动都优先于配置文件，所以此时保存的地址不会生效，响应里 `command_line_override` 为 `true`，`restart_required` 恒为 `false`。要让保存的地址生效，请去掉启动参数 `--addr`。\n\n**如果改错了，重启后访问不到**：用 `--addr` 参数启动一次（例如 `--addr 127.0.0.1:8000`）回到可访问的状态，再用接口修改，或停止服务后编辑\u002F移走 `DataDir\u002Fweb-settings.toml`。覆盖文件仍在时，仅修改基础配置不会生效。检查虽然能挡住大多数错误，但不能保证新地址对你的网络环境一定可达（例如防火墙没放行新端口）。\n\n## 系统诊断信息（联系客服时使用）\n\n遇到程序问题需要联系客服时，管理员可以生成一份诊断信息，复制后发给客服。\n\n**怎么用**：以管理员身份登录后，进入网页界面的“系统”页，在“支持诊断”一节点击“生成报告”，**先弹出隐私风险提示，确认“我已了解，生成报告”后才会真正生成**（取消则什么都不会收集），再检查内容，点“复制报告”（或“下载报告（.txt）”），再发给客服。也可以直接调用 `POST \u002Fapi\u002Fsystem\u002Fdiagnostics`，返回的 `text` 字段就是整份报告。只有管理员可以使用；工程师和未登录用户会被拒绝。\n\n**报告正文一律是英文**，不随界面语言翻译，方便客服直接阅读；界面上的提示和按钮仍按所选语言显示。\n\n**报告编号**：报告开头有一行 `Report ID`，就是这次生成在审计记录里留下的那条记录的编号（响应头 `X-Request-ID` 也是它）。报告万一外泄，凭这个编号能在审计记录里查到是谁、什么时候、从哪个地址生成的。\n\n**不要和许可证文件一起发送**：报告里的设备标识不是秘密（本机有权限的人本来就能读到），但同时拿到报告和许可证文件的人，可能在别的机器上使用该授权。给客服发报告时，只发报告，不要附带许可证文件。确认框和报告开头都有这一提示。\n\n**请注意（确认框和报告开头都会显示）**：诊断信息**没有做脱敏**，包含主机名、运行用户名、文件路径、设备标识、串口与 USB 序列号、网络监听地址、配置摘要和最近的运行日志（日志中可能有客户端 IP 地址、串口设备名和错误详情），**仅用于协助排查问题**。程序不会自动上传这些内容，是否发送由您决定；请只提供给您联系的客服人员，不要公开发布。报告不包含用户密码、API Key、`secret_key`、`api_key_pepper` 和 SSH 主机私钥。\n\n**报告内容**\n\n| 部分 | 内容 |\n| :--- | :--- |\n| 程序与系统 | 版本、构建日期、平台和架构、Go 版本、操作系统版本、CPU 数、运行时长、主机名 |\n| 资源（Resources） | **生成报告那一刻的快照**，不是平均值：物理内存总量、可用量和使用率（可用量算上系统可回收的缓存）、交换区总量和已用、本程序占用的内存（Go 运行时从系统取得的总量和堆内使用量，不是进程常驻内存）、系统负载（1、5、15 分钟，只有 Linux 有）。Linux 容器里读到的是整机内存，不是容器限制 |\n| 文件与配置 | 可执行文件和工作目录、运行用户；配置文件、数据目录（并检查能否写入）、数据库、许可证、维护令牌、SSH 主机密钥文件的路径和是否存在、大小、修改时间；Web 监听地址、WebSocket 允许的来源、`license_server`；`secret_key`、`api_key_pepper` 只显示“已设置”或“未设置” |\n| 许可证 | 档位、`license_id`、有效维护到期日、本机的设备标识哈希、校验错误 |\n| 设备身份（明文） | **明文**的主板 UUID、CPU 序列号、操作系统安装 ID 和软件生成的 UUID，每项后面带它的哈希（`hashed as …`，就是许可证里的值）；读不到或被拒绝的来源写明原因（例如“占位 UUID 无效”“Windows 不读取 CPU 序列号”）；`hardware_source_used` 说明主板标识用的是 UUID、CPU 序列号还是都没有。这是整个程序里唯一会显示这些明文的地方，**请只发给您联系的客服，不要公开发布** |\n| 网络监听器 | 在运行或出错的监听器及最近的错误；已关闭或被授权限制的只统计数量 |\n| 控制台 1–48 | 每个控制台的授权和启用状态、串口绑定（设备名\u002F槽位\u002FVID·PID·序列号）、串口参数、各协议的监听地址、连接数、当前串口状态、最近的串口错误 |\n| 系统识别到的串口 | 串口名、USB 槽位、VID\u002FPID\u002F序列号、设备标识 |\n| 最近的运行日志 | 最近约 300 行日志（只保存在内存中，重启后清空） |\n\n“文件与配置”里的 `data_dir_disk` 一行是数据目录所在磁盘的剩余空间和总空间（磁盘满了会导致保存失败）。\n某一部分无法收集时，报告会在该处写明原因，其他部分照常生成。\n\n**每次生成都会写入审计记录**（事件 `create-system-diagnostics`，含操作者和来源 IP，被拒绝的尝试也记录），审计里不保存报告内容。报告只在点击按钮时才收集，页面本身不含任何数据。\n\n## 连接日志\n\n客户端接入控制台（TCP、Telnet、SSH、WebSocket 四种协议一视同仁）**只写入程序日志，不写入数据库，也不进入审计表**。审计表只记录管理类操作（见下一节），两者互不混杂；连接记录量大，且 TCP、Telnet 无需认证，被扫描时会产生大量记录。日志输出到标准输出\u002F标准错误，程序本身不写日志文件，也不做轮转，需要保存请由部署者通过 systemd\u002Fjournald、服务管理器或重定向自行处理。\n\n**每次连接有三类日志行**\n\n```\nconsole 3 TCP: client 192.0.2.10:51234 connected (1 of 4 connections)\nconsole 3 SSH: client 192.0.2.10:51240 (user admin) connected (2 of 4 connections)\nconsole 3 SSH: client 192.0.2.10:51240 (user admin) disconnected after 12m3s\nconsole 3 TCP: client 192.0.2.11:40022 refused: maximum connections reached\n```\n\n| 内容 | 说明 |\n| :--- | :--- |\n| 控制台 | `console N`，N 是逻辑控制台编号 |\n| 协议 | `TCP`、`Telnet`、`SSH`、`WebSocket` |\n| 客户端地址 | 对端 IP 和端口 |\n| 用户 | 只有经过认证的 SSH、WebSocket 才有（`(user admin)`）；TCP、Telnet 没有认证，不会有用户 |\n| 连接数 | `connected (1 of 4 connections)`：接入后的当前连接数和该控制台的最大连接数 |\n| 时长与原因 | 断开时给出持续时间；非正常断开才附原因（如 `connection reset by peer`、`timed out`），客户端正常退出不附 |\n\n**被拒绝的连接**会写明原因：`maximum connections reached`（超过最大连接数）、`console is disabled`（被暂停或超出授权）、`service is shutting down`。SSH 的密码错误写为 `login failed for user \"xxx\"`；WebSocket 在到达控制台之前被拒绝（未登录、控制台不存在、未开放 WebSocket、授权不足、被暂停）同样会记录。\n\n**防刷屏**：同一客户端 IP 因同一原因被拒绝，30 秒内只记一行，之后的那一行会注明被省略的次数（`3 similar refusals in the last 30s not logged`）。SSH 密码错误按“同一 IP”合并，换用户名不会绕过。用户名等由对端提供的文字会去掉换行等控制字符并限制长度，不能伪造日志行。\n\n**不记录终端内容**：日志只有上述元数据，不含串口收发的任何数据。\n\n**与其他功能的关系**\n\n- 这些行也会进入“系统诊断信息”最近约 300 行日志里（仅内存，重启清空）。日志中的客户端 IP 和用户名因此可能随诊断信息一起发给客服，诊断页面的告知文字已经提示这一点。\n- SSH 密码认证的成功与失败仍会写入审计表（`ssh-login`），与这里的日志并行存在。\n- 客户端 IP 和用户名属于个人信息，部署者对日志的保存期限、访问权限负责。\n\n## 敏感操作审计\n\n审计记录自动存入现有 SQLite 数据库的 `audit_events` 表。覆盖首次管理员初始化、Web 登录成功与失败、退出、修改与重置密码、用户及 API Key 管理、许可证导入、配置修改与恢复默认值、控制台配置修改和强制断开连接。SSH 密码认证成功与失败也会记录；当前 SSH 认证回调不提供客户端地址，因此这类记录的来源 IP 为空。\n\n记录包含时间（UTC）、操作类型、操作者身份与角色、来源 IP、请求 ID、目标路径和结果。Web 操作结果包括 `success`、`failure`、`denied`，HTTP 状态码一并保存。来源 IP 使用实际连接地址，代理部署时为代理地址，不直接信任客户端提交的转发头。\n\n密码、Cookie、API Key 明文、许可证原文及签名、请求和响应正文、终端内容均不写入审计记录。当前版本记录操作及结果，不保存配置字段的前后差异。普通查询、状态轮询和 WebSocket ticket 签发不产生审计写入。\n\n管理员通过 `GET \u002Fapi\u002Faudit-events?limit=50` 查询，结果按 ID 倒序排列。下一页使用上一页最后一条记录的 ID，例如 `GET \u002Fapi\u002Faudit-events?limit=50&before=123`。`limit` 范围为 1–200，默认 50；`before` 默认 0。使用现有登录 Cookie 或管理员 API Key 认证，可在 `\u002Fapi\u002Fdocs` 查看接口。当前 Nuxt 前端已有日志页面，管理员可查询审计记录；旧的嵌入式前端编译产物可能没有该页面，以实际部署的前端版本为准，API 查询不依赖该页面。\n\n每次敏感操作同步追加一条记录，沿用 SQLite WAL。每小时有新事件时批量清理一次，保留最近 90 天内的最多 100,000 条记录；清理间隔内数量可能暂时超过上限。清理日期依赖服务端系统时间，错误校时可能影响保留范围；前述串口相对时长与此不同。不提供修改或删除审计记录的 API。记录保存在本地数据库，拥有数据库文件访问权限的人仍可修改文件。\n\n审计写入失败时保留原操作结果，在系统日志中输出事件类型及请求 ID，Web 响应额外带有 `X-Audit-Status: failed`；敏感操作响应带有系统生成的 `X-Request-ID`。审计写入与业务变更分别提交，并非同一事务；意外终止可能造成业务已完成但审计尚未落盘。高频失败登录仍会产生写入，当前审计功能没有额外引入登录限流。\n",false,1792221811572]