ServerBox 工作原理全解析:双传输通道、Rust 共享解析器与加密 SQLite 存储架构
【免费下载链接】flutter_server_boxServerBox - server status & toolbox项目地址: https://gitcode.com/GitHub_Trending/fl/flutter_server_box
ServerBox 是一款基于 Flutter 与 Rust 的服务器状态监控工具箱,其客户端与服务器端 Agent(monitor)共享同一套命令清单与状态解析器,从而保证"同一命令输出、两端解读一致"。本文以仓库内 .claude/skills/serverbox-onboarding/references/principles.md 为骨架,结合源码逐层拆解其架构决策:一个服务器为何可以同时暴露 SSH 与 HTTP 两种传输通道、能力协商(Capabilities)如何取代"猜传输方式"、状态解析为何必须住在 Rust 且保持纯函数、全部数据如何收敛进一个加密 SQLite 文件,以及跨平台构建背后的两块原生机制。读完你将能够按图索骥,在仓库中一步定位到对应模块的实现与测试。
整体分层:视图、状态、数据三层 + 一颗 Rust 解析内核
从 principles.md 的"shape of it"一节可以看到,应用本身是经典的分层结构:
lib/view/负责渲染(页面与组件);lib/data/provider/持有基于 Riverpod 的状态;lib/data/model/与lib/data/store/构成数据层(模型定义与持久化访问)。
关键的架构决策在于:状态解析(status parsing)根本不是 Dart 实现的,而是一个与服务器端 Agent 共享的 Rust crate。这样一来,无论命令由 App 通过 SSH 远程执行,还是由 monitor Agent 在本机采集,两端对"一段命令输出到底意味着什么"的解读永远一致,不会出现"客户端以为的字段格式与 Agent 上报的不一样"这类漂移问题。
仓库布局与这一设计一一对应:
crates/sbm_parser—— 命令清单、解析器、脚本生成的唯一权威来源(single source of truth);crates/sbm_ffi—— flutter_rust_bridge 绑定 crate,供 Dart 侧通过 FFI 调用;crates/sbm_native—— 仅 monitor 使用的原生采样器(通过 syscall 而非 shell 命令采样)。
一个服务器可暴露两种传输通道,能力靠"询问"而非"试探"
从 SPI 出发的传输选择
每个服务器保存着"服务器私有信息"(Spi),其中可以同时携带SshCredential与monitorHttp两类凭据:
- 仅配置 SSH:纯 SSH 服务器;
- 仅配置 monitor:纯 Monitor 服务器(没有 SSH 凭据);
- 两者都配置:同时保留两种连接带来的能力。
ServerConnectCredential.fromSpi负责选出优先传输(preferred transport),而fallbackOf给出另一种。注意:一个服务器不能两者皆无——SpiValidationError.noConnectionMethod与建表约束CHECK (ssh_ip IS NOT NULL OR monitor_addr IS NOT NULL)双重拒绝这种非法状态。
ServerCapabilities:把"需要什么"与"由谁提供"解耦
一个功能在使用前通过ServerCapabilities询问"这台服务器能不能做这件事",而不去关心背后是 SSH 还是 monitor。这保证了"需要一个 shell 的功能"永远不必知道"SSH"才是提供 shell 的那个东西。接口定义见 lib/data/model/server/capabilities.dart,每个传输通道一个实现类:
| 能力 | 含义 | SSH | Monitor HTTP |
|---|---|---|---|
shell | 可执行命令并读取输出(进程/服务页、容器、电源控制) | ✅ | 取决于granted.fullAccess |
terminal | 可打开交互式终端(终端页、snippet、iperf) | ✅ | 取决于granted.fullAccess |
byteStream | 可建立双向字节流(SFTP 传文件、端口转发转 TCP) | ✅ | ❌ 恒为 false |
files | 可浏览和移动文件(文件页、传输两端) | ✅ | 取决于granted.files |
storedHistory | 传输通道自带趋势历史,可预填本地缓冲区 | ❌(App 自行采样,刚打开页面时缓冲区为空) | ✅(Agent 在 App 询问前就在采样) |
persistentSession | "已连接但尚未取到数据"这一状态是否可观察 | ✅ | ❌(只有"是否已应答") |
其中 Monitor 侧的两个回答值得展开:
byteStream恒为false,因为 Agent 没有任何端点会把连接中继到"App 指定的地址",所以 SFTP 与端口转发天然缺席;storedHistory恒为true,因为运行 Agent 的意义就在于"App 还没问的时候它已经在采样"。
当一个服务器同时具备两种传输时,ServerCapabilities.ofSpi返回的是两者的并集(UnionCapabilities,见 capabilities.dart):这样的服务器确实能同时做两套事情——Agent 持有 App 从未采样过的历史,sshd 持有 Agent 没有端点的字节流。因此能力协商回答的是"关于服务器的能力",而不是"关于某一次连接的能力";具体某个功能最终走哪条通道,在使用处按"谁扛得住"决定。
ensureExec 与 ensureShellClient:两条不同的落点路径
ServerNotifier.ensureExec()是"命令如何到达服务器"的唯一决策点:
- SSH 服务器 → 走
SshExec; - Monitor 服务器 → 走
POST /api/v1/exec。
Monitor 服务器永远不会回退到 sshd——回退就意味着向用户索要他刻意没有给过 App 的凭据。而ensureShellClient()仅走 SSH 路径,并且使用独立的TryLimiterkey(${id}#shell),这样"shell 打不开"不会连带拖垮状态页的刷新。
SSH 字节流的三条来源轴线
SSH 的字节流从哪里来,是另一条独立的轴线,统一在genClient(lib/core/utils/server.dart)中解析:
- 直连(direct):
SSHSocket.connect(ssh.ip, ssh.port); - 跳板机(jump server):对每个跳板递归调用
genClient,再通过forwardLocal(ssh.ip, ssh.port)转发目标地址; - ProxyCommand:通过
ProxyCommandSocket.connect执行用户配置的代理命令。
后两者互斥,由Spix.validate()强制。无论走哪条路,SSHSocket之上的一切逻辑完全一致,且每种情况下 App 都自行校验主机密钥。另外genClient内置了跳板链环路检测(分别按服务器 id 与addr:ip:port检测),并在诊断系统中记录via: jump|proxy|direct,便于区分"网络/跳板/代理故障"与"密钥/口令/主机密钥不匹配"两类失败阶段。
状态解析在 Rust 中完成,且保持纯函数
命令清单与分段协议
crates/sbm_parser/src/commands.rs是命令清单(command manifest)的所在地:每个命令键(如cpu、mem、net)映射到各平台的真实 shell 命令,脚本输出以SrvBoxSep.<cmd>分段(SEPARATOR = "SrvBoxSep"),App 与 monitor 都从这里取命令,从而保证脚本生成逐字节一致。
以 Linux 为例,快路径命令包括cat /proc/stat | grep cpu、cat /proc/meminfo | grep -E 'Mem|Swap'、cat /proc/net/dev、cat /proc/diskstats等(见 commands.rs)。解析入口是 lib.rs 的parse_status(system, raw):输入是"命令键 → 原始输出"的映射,输出是结构化的ServerStatus,按SystemType::{Linux, Bsd, Windows}分发到对应的平台解析器。任何一个命令缺失或解析失败只影响该字段(对应 App 的按段 try-catch 容忍),不会拖垮整轮结果。
纯函数设计:可变状态绝不跨 FFI
这是整个解析器最核心的约束:解析器只输出原始计数器(raw counters)——CPU 的 ticks、网卡累计字节数、磁盘扇区数;任何需要窗口计算的东西(如网络速率)都是"两个采样点之上的纯函数",而可变的时间序列状态留在调用方一侧。注释原文即:"No mutable state crosses the FFI boundary"(可变状态不跨越 FFI 边界)。这直接规避了 FFI 边界的并发与内存安全问题。
EXTENDED 慢路径:为磁盘寿命设计的取舍
commands::EXTENDED(见 commands.rs)把三类命令从快路径中剥离,交给扩展函数SbStatusExt,以分钟级慢节奏执行:
smartctl(diskSmart):读取本身免费,但"触达磁盘"会唤醒处于待机状态的盘。若按几秒一次的轮询频率去跑 smartctl,配置了停转的磁盘永远无法停转,每次唤醒还会消耗Start_Stop_Count/Load_Cycle_Count计数;- AMD GPU(
amd-smi/rocm-smi):每次调用要 fork 多个工具; ip地址查询:答案只在机器移动或租约变化时才改变,几秒一问纯属浪费进程 spawn。
App 以分钟级定时器刷新这些命令,monitor 则在扩展周期(extended cycle)执行。
monitor 独享的原生采样器
monitor 额外拥有crates/sbm_native,通过sysinfo(BSD/Windows)或直接读 procfs/sysfs(Linux)采集 CPU、内存、磁盘、网络与 uptime,全程不依赖 shell 命令——这是 App 没有的选项,因为 App 永远面对的是远程主机,只能通过 SSH 收集。
test-as-spec:用测试锁定迁移
crates/sbm_parser/tests/dart_compat.rs用原始 Dart 实现的 fixtures 锁定了行为。迁移规则是"测试即规范"(test-as-spec):把某个模块的 Dart fixture 测试先移植到 Rust,断言 FFI 结果与原始实现逐字节一致,最后才删除 Dart 实现。这样共享解析器的每一次演进都有回归防线。
存储:一个加密 SQLite 文件,两种表形态
所有数据收敛为单一文件store.db,通过package:sqlite3打开并启用sqlite3mc加密扩展。采用哪种形态是刻意决策:
形态一:kv 表(设置与历史)
kv(store, key, value, updated_at)- 容纳上百个互不相关的偏好设置与历史记录,新增一条只需一行改动;
value是 JSON,因此写入的任何值都需要toJson;SqliteStore.set在拿不到toJson时返回false而不是抛异常——意味着一个缺少.g.dart的模型会被静默丢弃(历史上PortForwardConfig正是因此在导入时整个丢失,其toJson必须手工维护并与fromJson保持同步)。
形态二:实体表(有关系的数据)
服务器、私钥、snippet、端口转发、连接统计、Agent 会话等各自拥有表结构,带外键与索引。
动手改 schema 前必须知道的约定
- 主键必须是 id,绝不能是用户输入的东西。私钥曾以名字为主键,导致重命名后所有指向它的服务器全部失联;现在两者都有生成的 id 与
UNIQUE名称列,重命名是一次UPDATE,冲突则以DuplicateNameException暴露。 - 列表/映射字段是子表,不是列里的 JSON 数组(
server_tag、server_env、server_jump、snippet_tag、container_host)。子表不带同步列,随父表整体移动;编辑子行会盖章父行(通过Stores.server.synced.stamp)。 INSERT OR REPLACE在带同步列或子表的行上是错的:它删除再插入,会把未命名的列重置为默认值(rev归零),并级联清掉子表。正确做法是EntityStore.upsert(ON CONFLICT DO UPDATE仅更新数据列)。- 枚举按名字存储,不按索引:索引会在插入新 case 时悄然改变含义,而这些值存活的时间超过写出它们的构建版本。迁移读取旧记录时要显式翻译(如
ConnectionStat的@JsonValue是 snake_case,而列存的是枚举name,五个中有三个不同)。 - Drift 只拥有 DDL:查询全部手写且同步,因为 UI 是在构建期间读取 store 的。
同步单元与墓碑
Tables.syncRoots指名同步以什么为单位移动:每个 root 携带updated_at与rev(同一毫秒内的两次编辑靠时钟无法区分,所以需要 rev),删除时写入一行tombstone(表定义见 lib/data/store/db.dart)——没有墓碑,对端会把"缺席"读成"新增"然后把记录放回来。conn_stat与agent_conversation刻意不是同步 root:连接不是编辑,会话则携带终端输出与推理内容。
迁移的完整纪律(含永久回归测试、fixtures 机制)见 docs/src/content/docs/development/testing.md,简短版本是:一次迁移只在真实用户数据上跑一遍,bug 在其中表现为"静默"而非崩溃,因此每个迁移都要配一个"由旧版本真实写出的字节喂出来的"常驻测试。
状态管理与一个导航器陷阱
状态栈为:Riverpod + 代码生成(providers)、Freezed(不可变模型)、GetIt(服务定位)。长文见 docs/src/content/docs/principles/state.md。
最容易出 bug 的陷阱是对话框导航:
showRoundDialog把对话框放在根导航器上,而页面的context找到的是持有该页面的导航器——在 pane 或 tab 内部两者不是同一个;- 因此从对话框按钮里
context.pop()关掉的是页面,对话框留在屏幕上,被 await 的 future 永不完成,调用方后续代码也不执行; - 正确做法:用
context.popDialog()显式关根导航器上的对话框;更好的做法是让对话框"回答"——await context.showRoundDialog<bool>(...)配合Btnx.cancelOk,由调用方在页面上完成后续动作并关闭页面; - 破坏点在于给
Btn.ok传onTap:没有onTap时Btn用按钮自己的 context(在对话框内)解析导航器,行为正确;传了onTap就替换掉这份正确的导航器解析,f必须自己弹对话框。对话框内Input的onSubmitted同理。仓库还给出了两条第一轮排查用的 grep 命令(rg -U 'showRoundDialog[\s\S]*?context\.pop\(' lib与rg -n 'Btn\.ok\(onTap:|Btnx?\.\w+\(onTap:' lib)。
跨平台:同一套代码五个平台,外加两块原生机制
iSH:iOS 本地运行 Linux 用户态
third_party/ish-arm64是 iSH 的一个 fork——iOS 构建可以在本地运行的 Linux 用户态。默认关闭(SBM_ISH=0),由scripts/build-ish-ios.sh在源码树外构建(输出到build/ish/build-<arch>/),这也解释了为什么flutter clean会破坏它:它不在 Flutter 的清理范围内,被清掉后 iOS 链接会报三个No such file or directory(lib{ish,ish_emu,fakefs}.a),且报错信息完全不指向原因。
CocoaPods 已从 iOS/macOS 移除
hook/build.dart与packages/flutter_ptyfork 用 Dart 构建钩子取代了各平台的原生构建集成,因此仓库里没有Podfile,也不应再加回一个。packages/flutter_pty与上游 0.4.2 的差异仅在于改用native_toolchain_c,其余完全一致。
本地化
本地化是lib/l10n/下的 ARB 文件:本项目字符串通过l10n访问,fl_lib已有的字符串通过libL10n访问。规则是优先复用已有的libL10n字符串,即使语义并非完全精确匹配。
文档导航:哪个问题对应哪份文档
principles.md 末尾提供了一张"问题 → 文档"映射表,是深入阅读的路线图:
| 想了解的问题 | 对应文件 |
|---|---|
| 分层、连接方式与核心系统如何组合 | docs/src/content/docs/principles/architecture.md |
| 连接流程、认证、主机密钥校验、会话生命周期 | docs/src/content/docs/principles/ssh.md |
| 文件操作、路径处理、传输、编辑 | docs/src/content/docs/principles/sftp.md |
| 终端字节流来源、标签页、虚拟键盘、选择 | docs/src/content/docs/principles/terminal.md |
| Provider 类型、更新模式、持久化、Riverpod 测试 | docs/src/content/docs/principles/state.md |
| 文件该放在树里哪个位置 | docs/src/content/docs/development/structure.md |
| 该跑哪个生成器、为何某个 adapter 被冻结 | docs/src/content/docs/development/codegen.md |
| 测试策略、fixtures、集成测试 | docs/src/content/docs/development/testing.md |
| Agent 的 API 表面、远程访问模型、控制面板 | monitor/CLAUDE.md |
| 改动这套代码的规则 | CLAUDE.md |
docs/src/content/docs/下的页面都有对应的zh/版本,scripts/check-locale-parity.mjs会在缺失翻译时让构建失败;而两份CLAUDE.md不是文档页面、没有翻译——它们是写给改动代码的人的指令。
小结:一张地图,而非文档的替代品
principles.md 的定位是"一张地图,不是文档的替代品"(A map, not a replacement for the documentation)。它浓缩的是解释仓库大多数行为的少数几个决策:Flutter 分层 + 共享 Rust 解析内核、双传输通道 + 能力协商、纯函数解析与 test-as-spec 迁移、单一加密 SQLite + 两种表形态、对话框导航陷阱、以及跨平台的两块原生机制。沿着本文给出的源码路径与文档路线图,你可以一步定位到任何一个具体实现,并在 CLAUDE.md 与 monitor/CLAUDE.md 中找到改动代码时必须遵守的规则。
【免费下载链接】flutter_server_boxServerBox - server status & toolbox项目地址: https://gitcode.com/GitHub_Trending/fl/flutter_server_box
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考