47 KiB
部署
节点(Agent、Probe)是单个 NativeAOT 可执行文件,没有运行时依赖,只需要系统的 OpenSSL 3(libssl.so.3)。启动时会自检,缺少时第一步就明确报错。
1. 构建与打包
IPv6 状态诊断
Agent 和 Probe 在 hello 初始化及心跳中上报 IPv6 检测结果,包括监听状态、全局地址、默认路由和网络命名空间。容器可提供只读宿主 /proc 挂载,附带宿主网络状态。只传布尔状态,不传 IP 或网卡名称。Docker 和 Podman 推荐宿主网络模式;自定义桥接网络时,需要启用 IPv6 并提供 /proc 宿主挂载,前端会提示宿主和容器网络状态是否不同。
启用 IPv6 测量
升级 Backend、Agent、Probe 后,在管理端的 Agent 编辑页保留 IPv4 地址,并填写 IPv6 地址或带 AAAA 记录的域名。未填写 IPv6 地址的 VPS 只采集 IPv4;Probe 没有 IPv6 路由或 IPv6 连接失败时,IPv4 会话继续工作。
Agent 的新默认监听地址为 ::(双栈);现有 agent.json 如果显式配置了 0.0.0.0,需要在宿主机改为 :: 并重启节点。系统不支持 IPv6 套接字时,新默认值退回 IPv4 监听。防火墙需允许 IPv6 的 7443/tcp、7443/udp、7444/tcp(自定义端口时用实际端口);容器推荐宿主网络。管理端的「IPv6 监听」显示节点实际是否启用。
原来的 SQLite 数据库自动迁移,旧原始块、指标和测试结果继续属于 IPv4。先升级 Backend,再升级节点,最后填写 IPv6 地址;旧 Agent 不会上报 IPv6 支持,Backend 不会给它安排第二条会话。
上线后,在管理端的 Probe / Agent 页面查看「IPv6 诊断」。它显示检测网络范围、全局地址和默认路由;机器有 IPv6 但容器没有时,检查是否使用宿主网络。没有填 Agent IPv6 测量地址时,诊断仍会上报,但面板不会建立该 Agent 的 IPv6 测量线路。
scripts/publish.sh <agent|probe|backend|fleet-enroll> <rid> → backend/artifacts/publish/<project>/<rid>/
scripts/package.sh <agent|probe> <rid> → backend/artifacts/packages/moshiro-<role>-<version>-<rid>.tar.gz(附 .sha256)
构建信息(程序里显示的 tag、提交、构建时间):publish.sh 会用 git describe --tags --always --dirty、git rev-parse 和当前时间自动设置环境变量 MOSHIRO_TAG、MOSHIRO_COMMIT、MOSHIRO_BUILT_AT,直接用 dotnet publish 或 CI 构建时自己设置它们(容器镜像的 *.NativeBuild.Containerfile 用同名的 --build-arg,CI 脚本会传);不设置就是空,本地构建照常可重复。版本号取自 backend/Directory.Build.props 的 Version。前端的信息由 web/build-info.mjs 在构建时写入(版本取自 web/package.json)。
| rid | 目标 | 构建方式 |
|---|---|---|
linux-x64、linux-arm64 |
Debian/Ubuntu glibc 服务器 | Windows CI 主机通过 AotAnywhere 交叉编译 |
linux-musl-x64、linux-musl-arm64 |
Alpine、amd64 OpenWrt、aarch64 OpenWrt 路由器 | Windows CI 主机通过 AotAnywhere 交叉编译 |
osx-x64、osx-arm64 |
Intel Mac、Apple Silicon | Windows CI 主机通过 AotAnywhere 交叉编译;发布包未做 Developer ID 签名或 Apple 公证 |
win-x64、win-arm64 |
Windows x64、ARM64 | Windows CI 主机上的 Visual Studio C++ 工具链 |
macOS 的 Apple Silicon 产物带临时 ad-hoc 签名,Intel 产物未签名。通过浏览器下载时,macOS Gatekeeper 可能阻止启动;面向普通用户分发前需要 Developer ID 签名和 Apple 公证。MIPS 不在当前目标列表中(见 project-layout.md)。
仓库内的 scripts/publish.sh 面向本机目标平台;Woodpecker 的 tag 流水线在 Windows runner 上构建 Agent、Probe 和 fleet-enroll 的所有发布目标,Backend OCI 镜像仍在 Linux runner 上构建。
Backend 容器
Backend 使用 OCI 标准镜像,通过 registry.oeo.one/moshiro/moshiro-backend:<version> 分发,支持 linux/amd64 和 linux/arm64。容器使用 glibc NativeAOT 单文件程序(动态链接,运行时要加载 libssl;musl 的静态产物做不到),基底为 mcr.microsoft.com/dotnet/runtime-deps:10.0(Debian),以非 root 用户运行,在 8080/tcp 提供 HTTP 服务;数据库和原始数据统一写入 /data。程序启用 InvariantGlobalization,无需 .NET 运行时或 ICU;时间查询使用 UTC。SQLite 从固定版本的官方源码编译并静态链接,不再随镜像携带 libe_sqlite3.so。将 /data 挂载为持久卷,并在反向代理处配置 HTTPS。
前端内嵌在 Backend 里:CI 构建 Backend 前先构建 web/display 和 web/admin,Backend.Api 把两个 dist 嵌进程序,展示端在 /,管理端在 /admin/,所以面板的域名(如 https://mo.oeo.one/admin/)打开就是管理端,passkey 才能绑定在这个域名上(浏览器只允许页面的来源与 RP 域名一致,本机 localhost 上的页面注册不了绑定 mo.oeo.one 的 passkey)。本地没有构建前端时不嵌入,Backend 只提供 API。文件是启动时读进内存的白名单,请求时不访问文件系统;带 ..、反斜杠、%、控制字符或空段的路径一律 404,缺失的文件(带扩展名)404,只有无扩展名的路径回退到入口页;/api、/openapi、/install、/healthz 下没有匹配到的路径仍是后端的 JSON 404。页面带 CSP(脚本只许同源)、nosniff、X-Frame-Options: DENY,入口页 no-cache,哈希文件名的资源 immutable,不嵌入 source map。部署时设置 MoShiro__PublicUrl=https://<面板域名>(deploy-node.sh backend --public-url …),WebAuthn 的域名和允许的来源由它派生。
用 compose 部署(Docker 和 Podman 通用,需要 docker compose 或 podman compose):compose 文件是静态的,在仓库的 packaging/compose/backend/compose.yaml,每个发布版本的 Gitea Release 里也有一份 moshiro-compose-backend.yaml(第一次部署时还没有面板,就从这里下载)。镜像用 :${MOSHIRO_VERSION:-latest}:
mkdir moshiro-backend && cd moshiro-backend
curl -fsSL https://git.oeo.one/justme/MoShiro/releases/download/<版本>/moshiro-compose-backend.yaml -o compose.yaml
cat > .env <<'EOF'
MoShiro__PublicUrl=https://panel.example.com # 面板对外的地址;passkey 绑定在这个域名上,改了会让已注册的 passkey 失效
# MoShiro__ClaimCode= # 认领码(可选),不写就随机生成并打印在日志里
EOF
podman compose up -d # Docker 用 docker compose
更新:先备份数据卷,再 podman compose pull && podman compose up -d。新版启动时自动迁移数据库,但旧版不认比自己新的数据库:想回滚要先恢复备份,只改 MOSHIRO_VERSION 不够。Backend 能指挥所有节点,更新它尽量用宿主机更新器(见下文「Backend 签名更新」,清单有发布签名,更新前自动备份,不健康自动回滚),或者在 .env 里把 MOSHIRO_VERSION 固定到经过核对的版本或 digest。
首次认领(没有默认管理员):面板里还没有任何用户时,打开 /admin/ 会进入认领向导:
- 填认领码(首次启动时随机生成并写进后端日志,
journalctl -u moshiro-backend或docker logs;也可以用MoShiro__ClaimCode/MOSHIRO_CLAIM_CODE指定)、管理员的用户名和密码; - 注册你的第一把 passkey;
- 用它给操作员集合签名,建立信任根。 谁先打开页面谁就能当管理员,所以认领码只有能看到后端日志的人拿得到;创建完第一个管理员,码作废、入口关闭(再提交返回 409)。认领码比较不分大小写、忽略分隔符;失败按来源地址限流。向导可以中途离开,passkey 和信任根之后随时在「用户 → 我的 Passkey」和「系统 → 信任」里补;没有 passkey 也能用密码登录管理。
Compose 将容器端口只绑定到宿主机 127.0.0.1:8080,适合由同机 HTTPS 反向代理转发;数据保存在命名卷 moshiro-backend-data(Docker 和 Podman 通用)。首次启动成功创建管理员后,改密应在管理端完成。
Backend 的 NativeAOT 发布默认启用 SqliteStaticLink,支持 Linux glibc/musl、macOS、Windows 的 x64/arm64 RID;跨平台构建使用 -p:UseAotAnywhere=true。SQLite 源码版本、下载年份和 SHA-256 统一配置在根目录 Directory.Packages.props,构建会校验下载文件并按 RID 缓存原生对象。普通 JIT 开发构建仍使用 NuGet 原生库。发布 Backend 时设置 -p:OpenApiGenerateDocuments=false,API 文档在普通开发构建中生成。
健康检查端点为 GET /healthz。容器定义见 ../packaging/container/Backend.Containerfile。
节点二进制发布
Woodpecker 在 vMAJOR.MINOR.PATCH tag 上,由 Windows runner 使用 AotAnywhere 并行构建 Agent/Probe 的 Linux glibc、Linux musl、macOS 和 Windows 产物。Agent 包还包含一次性注册工具 fleet-enroll。Linux 归档带有安装脚本和 systemd/OpenWrt 单元;Windows 产物为 .zip,Linux/macOS 产物为 .tar.gz。Linux musl 的 Agent 与 Probe 二进制还会打包为 OCI 镜像,基底为 mcr.microsoft.com/dotnet/runtime-deps:10.0-alpine,支持 linux/amd64 和 linux/arm64;所有平台仍提供对应归档。所有平台产物和 OCI 镜像发布成功后,CI 会创建对应的 Gitea Release,并附上压缩包和 SHA-256 校验文件。macOS 产物目前未签名/公证。安装说明见 ../backend/agent/README.md 与 ../backend/probe/README.md。
CI 工作流
Pull request 与 dev 验证
Pull request 和 dev push 会运行 Backend tests;通过后,独立的 web-build workflow 才会构建前端并检查 CSS。提交信息后缀 [SKIP TESTS] 只跳过 Backend tests,前端构建仍会运行。Gitea PR 事件的 CI_COMMIT_MESSAGE 是 PR 标题,因此在 PR 中跳过测试时,把后缀写入 PR 标题;普通分支 push 则写在 commit message 中。
main 开发镜像
main 上只有提交信息包含 [CI] 的 push 才构建镜像。合并 PR 时,请把 [CI] 放入 PR 标题或合并提交标题。Backend、Agent、Probe 的 Linux/amd64 镜像会并行构建,并推送到同一个 Zot Registry 下独立的 moshiro-dev/ 仓库:
| 镜像 | 滚动标签 | 固定提交标签 |
|---|---|---|
| Backend | registry.oeo.one/moshiro-dev/moshiro-backend:latest |
registry.oeo.one/moshiro-dev/moshiro-backend:dev-<12位SHA> |
| Agent | registry.oeo.one/moshiro-dev/moshiro-agent:latest |
registry.oeo.one/moshiro-dev/moshiro-agent:dev-<12位SHA> |
| Probe | registry.oeo.one/moshiro-dev/moshiro-probe:latest |
registry.oeo.one/moshiro-dev/moshiro-probe:dev-<12位SHA> |
Dev 镜像目前仅支持 amd64;Agent/Probe 在 sdk:10.0-alpine-aot 中构建 linux-musl-x64 产物,运行基底为 runtime-deps:10.0-alpine。它们不创建 Gitea Release,也不修改正式版 moshiro/ 仓库中的标签。普通 main push 不会构建镜像。
正式版发布
推送匹配项目版本的 vMAJOR.MINOR.PATCH tag 会触发完整跨平台构建、正式 OCI 镜像推送和 Gitea Release。正式镜像使用 registry.oeo.one/moshiro/ 路径;Agent、Probe 提供 amd64 与 arm64 多架构标签,稳定版本更新正式仓库自己的 latest。这与 moshiro-dev/ 的 dev latest 相互独立。
Agent 和 Probe 镜像分别发布为 registry.oeo.one/moshiro/moshiro-agent:<version> 与 registry.oeo.one/moshiro/moshiro-probe:<version>,包含 amd64、arm64 多架构索引;也可指定 :<version>-amd64 或 :<version>-arm64。稳定版本同时更新 latest。镜像以非 root 用户(UID/GID 1654)运行,默认读取 /etc/moshiro/agent.json 或 /etc/moshiro/probe.json。使用 packaging/compose/agent、probe 下的标准 Compose 模板,配置和身份放在命名 volume 中;首次注册/导入、宿主指标挂载与更新器见下文。
Agent 和 Probe 的默认发布配置:NativeAOT、完整裁剪、OptimizationPreference=Size、符号剥离、相同方法体折叠、InvariantGlobalization;关闭 Server/Concurrent GC、异常栈、EventSource、调试、Hot Reload、HTTP tracing、HTTP/3 和 .NET Metrics。JSON 全部使用源码生成上下文,禁用反射序列化回退;LINQ 使用体积优化实现。HTTP/3 裁剪不影响现有 HTTP/WebSocket/TLS 连接,也不影响程序自身上报的监控指标。
fleet-enroll 使用同一套体积优化开关。Fleet 共享库已声明 AOT 兼容,由 Agent、Probe、Backend 或注册工具的最终发布一起裁剪;GC 和运行时功能开关配置在各可执行项目中。
历史实测体积(后续以各 RID 的 CI 产物为准):
| 程序 | linux-musl-x64 | linux-x64 | win-x64 |
|---|---|---|---|
| moshiro-probe | 7.7 MB | 7.7 MB | 5.6 MB |
| moshiro-agent | 8.4 MB | 8.4 MB | 7.3 MB |
| fleet-enroll | 6.4 MB | 6.4 MB | — |
Linux 上比 Windows 大,是因为 .NET 的加密、压缩、网络本机库被静态链接进来。tar.gz 压缩后 Probe 约 3.6 MB。
2. 安装
解压 tar.gz,在解压目录里运行 install.sh(POSIX sh,root 运行;Debian、Ubuntu、Alpine、OpenWrt 都测试过):
# Agent(服务器):注册并启动
./install.sh agent --server https://panel.example.com --join <加入令牌> --name hk-1
# Probe(家宽网络里的机器或 OpenWrt 路由器):用面板里 probe 角色的加入令牌注册,密钥由它自己生成
./install.sh probe --server https://panel.example.com --join <加入令牌> --name home-1
./install.sh uninstall agent|probe [--purge] # --purge 同时删除身份(节点需要重新注册)
| 位置 | 内容 | 所有者 |
|---|---|---|
/usr/lib/moshiro/ |
二进制 | root,0755:采集进程不能改写自己的可执行文件 |
/etc/moshiro/ |
agent.json 或 probe.json、身份(node.json、node.key、tls.pfx)、agents.json |
moshiro 用户,0750 |
安装脚本会:检查 OpenSSL、创建 moshiro 用户(useradd、busybox adduser,OpenWrt 没有这些时直接写 /etc/passwd)、安装二进制和服务文件;缺少配置时只安装、不启动,并打印下一步。
systemd(packaging/systemd/*.service)
以 moshiro 用户运行,沙箱:NoNewPrivileges、ProtectSystem=strict、ProtectHome、PrivateTmp/Devices、ProtectKernel*、RestrictAddressFamilies=AF_INET AF_INET6、RestrictNamespaces、MemoryDenyWriteExecute(NativeAOT 不 JIT)、SystemCallArchitectures=native,MemoryMax(Agent 128 MB、Probe 64 MB)。
- Agent 只保留
CAP_NET_RAW(ICMP 目标任务用),/etc/moshiro只读;Probe 没有任何 capability,只对agents.json所在的目录可写。 - 监听 1024 以下的端口(例如把数据面放到 443)需要在 drop-in 里加
AmbientCapabilities=CAP_NET_BIND_SERVICE和对应的CapabilityBoundingSet。默认端口是 7443/tcp+udp(Probe 控制通道和 UDP 数据面)、7444/tcp。
OpenWrt(packaging/openwrt/)
只支持 Probe(Agent 面向服务器)。install.sh probe 安装 /etc/init.d/moshiro-probe(procd,崩溃自动重启,日志进 logd)并写入 /lib/upgrade/keep.d/moshiro,sysupgrade 时保留 /etc/moshiro/。
OpenWrt 默认不带 OpenSSL:opkg install libopenssl3(较新的快照和 24.10 之后用 apk add libopenssl3)。闪存很小时注意:二进制 7.7 MB,配置只在 Agent 列表变化时才写。路由器上建议把 probe.json 的 limits.max_test_mbps 调低。
3. 验证
scripts/smoke-linux.sh 在 Linux 容器里用发布的二进制跑完整链路(后端 → 注册 → Agent → Probe → 数据落库),MODE=install 用打包产物和 install.sh 安装、以 moshiro 用户运行。scripts/smoke-systemd.sh 在 systemd 容器里验证服务单元。已经验证过:
- Alpine 3.22(musl)、Debian 12(glibc):直接运行和
install.sh安装都通; - OpenWrt(官方 rootfs,busybox,无
install/useradd/su):安装、建用户、卸载、二进制启动和 OpenSSL 自检; - systemd(Debian 12):两个服务 active,Agent 只有
CAP_NET_RAW、Probe 没有 capability,NoNewPrivs、seccomp 生效,Agent 无法写/etc/moshiro,没有重启和错误日志。
没有验证的:arm64 构建、真正的 OpenWrt 设备(有 procd 和真实的内存/闪存限制)、跨公网的真实线路。
实测常驻内存(RSS,回环,默认模板,稳定后):Linux 上 Probe 约 21 MB、Agent 约 23 MB;Windows 上约 22 MB、25 MB。
跨机器的真实链路(Windows Probe → WSL2 里的 Agent 和 Backend)
scripts/smoke-wsl.py 用 Windows 上的 NativeAOT Probe 连 WSL2 里的 Agent(真实的虚拟网络,两端的单调时钟原点相差约 38 小时),并用 tc netem 在 WSL 里注入故障。结果(Probe 的 RTT 基线约 0.4 ms):
| 注入 | 期望 | 实测 |
|---|---|---|
| Agent→Probe 方向 10% 丢包、30 ms ± 5 ms 延迟(egress netem,只匹配 7443/7444) | 丢包在下行,下行抖动变大 | loss.down 8.2%、loss.up 0;jitter.down.p95 8.7 ms、jitter.up.p95 0.18 ms;RTT +31 ms;TCP:tcp.rtt.p99 1.2 s、tcp.slow 28.8%、tcp.retrans 5.9%(Agent 一侧内核计数) |
| Probe→Agent 方向 10% 丢包、20 ms ± 3 ms 延迟(ingress + ifb) | 丢包在上行 | loss.up 9.4%、loss.down 0;jitter.up.p95 5.2 ms、jitter.down.p95 0 |
| 8 秒黑洞(tc ingress drop 7443/7444) | UDP 连续丢包 ~80 个;TCP 不丢包,只有几秒的长尾 | UDP 最长连续丢包 80(10 Hz × 8 s);tcp.rtt.max 9.5 s、tcp.stall_max 9.6 s |
hy2_ladder(10/25/50/100 Mbit/s) |
无丢包 | train.loss 0.006%、train.jitter.p95 0.64 ms、可持续 100 Mbit/s |
| 杀掉 Agent / 杀掉 Probe 再启动 | 自动恢复 | Agent 重启后 2.1 s 恢复在线并重新连上 Probe;Probe 重启后 0.5 s 重新连上 |
验证中发现并修掉的两个问题:
- Probe 重启后,Agent 沿用了上一个会话的记录:Probe 重启之后序号从 0 开始,Agent 里同一个序号的旧 t2/t3 还在,于是真正的上行丢失被算成了下行丢失(黑洞测试里
loss.up0、loss.down7.4%)。现在新的 Probe 会话开始时 Agent 清空所有流的记录。 - TCP 回显的几秒中断被记成丢包:Probe 像 UDP 那样等 2 秒就把没有回复的行交出去,TCP 随后才到的回复没有行可以回填,最严重的长尾反而在 RTT 里看不到(黑洞期间
tcp.rtt.max只有 4 ms)。现在 TCP 的行等回复,30 秒仍然没有才当作连接已断(t4 缺失)。
没有覆盖的:Windows 上的 Agent(需要给 Windows 防火墙加入站规则)、NAT 重绑定。跨公网的线路见下一节。
跨公网的真实线路(广东移动家宽 → 日本 IIJ 的 VPS)
2026-10-03 在一条真实线路上试跑过:家宽机器(Debian 13,用户态运行,没有公网 IPv4 入站)跑 Probe 和 Backend,VPS(Debian 12,1 核 470 MB,上面本来就跑着 xray、hysteria2、caddy 等服务)跑 Agent。
浏览器 / 本机前端 ──► https://<公网域名> ──隧道(Cloudflare Tunnel 之类)──► 家宽机器: Backend (:29181)
家宽机器: Probe ──WSS 到 https://<公网域名>──► Backend(身份、Agent 列表)
家宽机器: Probe ──TCP/UDP 7443、TCP 7444──► VPS: Agent
VPS: Agent ──WSS 到 https://<公网域名>──► Backend
做法和要点:
- Backend 通过隧道暴露:隧道把一个公网域名转到家宽机器的
29181;Backend 监听http://0.0.0.0:29181(只写一个监听地址,同时写0.0.0.0和[::]会报地址已被占用),家宽路由器的防火墙不放行这个端口,公网只能经隧道进来。Agent 用fleet-enroll --server https://<公网域名>注册,之后用 WSS 连回 Backend。 - 对外地址自动识别:Agent 在 hello 里上报本机的公网地址(IPv4 公网地址、IPv6 全球单播地址,排除私网、CGNAT、临时地址),面板自动填
public_host和ipv6_host,不需要手填。经隧道注册时来源地址是隧道出口,面板不会用它。只有 Agent 在 NAT 后面、本机看不到公网地址时才需要在管理端手填;手填的优先,勾选“自动识别”可以撤销。 - Probe 直连面板:
probe.json里的fleet.server就是面板地址,Probe 用它连面板,Agent 列表由面板下发;密钥由 Probe 注册时自己生成,身份目录(node.json、node.key、tls.pfx)在fleet.identity_dir。 - 小 VPS 把
limits.max_total_mbps调低(试跑时用 100);Agent 常驻约 23 MB,VPS 上原有的服务不受影响,结束后删除/opt/…、停掉进程即可,没有留下任何东西。 - 家宽的 IPv6 前缀会在路由器重新拨号(比如停电)后变化,SSH 地址要跟着换;面板走隧道的域名,不受影响。
- 本地电脑没有直连 VPS 的路径时,用家宽机器当跳板(
SSH_ASKPASS+SSH_ASKPASS_REQUIRE=force,密码放环境变量,不写进文件)。
常驻(开机自启):
- 家宽机器(没有 root 时):
~/.config/systemd/user/moshiro-backend.service和moshiro-probe.service,EnvironmentFile指向放配置的backend.env,Restart=always,systemctl --user enable --now …,再用sudo loginctl enable-linger <用户>让它们不登录也能起来。Probe 的命令行是moshiro-probe --config <probe.json 路径>(默认路径是/etc/moshiro/probe.json)。 - VPS:
/etc/systemd/system/moshiro-agent.service,Restart=always,加NoNewPrivileges、ProtectSystem=strict、ReadWritePaths=/opt/moshiro、ProtectHome、PrivateTmp。这是不走安装脚本的手动方式;正式安装用上面的packaging/systemd。 - 日志进 journal:
journalctl -u moshiro-agent、journalctl --user -u moshiro-probe。重启 Backend 用systemctl而不是手动再起一个进程,否则会端口冲突。
国家和运营商:Backend 用 Probe、Agent 的来源地址向 IP 归属服务查一次,展示在国旗和运营商图标上(MoShiro__GeoLookupUrl,缺省 https://ip.007995.xyz;留空就不查;这个服务只供公益使用,署名和使用条款由部署的人自己遵守)。Probe、Agent 自己不访问外部服务,所以客户的防火墙审计里不会多出出站连接;Backend 发出去的只有公网 IP(内网地址不发)。
从别处看这个面板:本机跑前端,在页面里把数据源指向这个域名,用 API 令牌访问,Backend 设 MoShiro__CorsOrigins 放行前端的来源(见 web/README.md)。
实测(Probe 在广东移动家宽,Agent 在日本 IIJ):
| 项目 | 结果 |
|---|---|
| 延迟 p50 / p95 / p99 | 107 / 107 / 114 ms |
| 丢包 | 上行 0%,下行 8.1%(hy2_ladder 在 10、25、50、100 Mbit/s 四档下的丢包都是 8~10%,与速率无关:随机丢包,不是限速) |
| 抖动 p95(上 / 下) | 0.9 / 5.2 ms |
| TCP | 重传 1.9%(批量传输时 5.8%),RTT 最大 412 ms |
reality_session |
建连成功率 100%,TCP 建连 p95 115 ms,TLS 握手 p95 117 ms,30 秒长连接无重置;4 秒批量传输峰值 133 Mbit/s、中位数 90 Mbit/s(Agent 上限 100) |
| 评分 | REALITY 82(含按需测试)、Hy2 42 |
| Agent 主机状态 | CPU 1.5%,内存 274 / 493 MB,已运行约 20.5 天 |
这次试跑发现并修掉的问题:hy2_ladder 的阶梯按「块」而不是按「阶段」计算,真实的 Probe 把一个阶段切成几块,4 个阶段出了 7 步(模拟数据不切块,所以之前的测试没有发现);现在同一阶段的块先合并。
容器部署(Docker / Podman 共用命名 volume)
镜像和 Compose 配置共用。普通用户执行 Podman 即为 rootless,使用命名 volume 不需要 keep-id 或手动 chown。Agent 的配置、原有身份和缓存保存在 moshiro-agent-config,Probe 保存在 moshiro-probe-config,更新状态使用独立的 moshiro-<role>-status volume,在业务容器内只读。不会重新生成已有身份。更新/重建容器不删除 volume;不要使用 compose down -v,该参数会删除数据。
compose 安装(Agent 和 Probe,推荐):管理端「添加 Agent」「添加 Probe」生成加入令牌之后,给出一段命令(面板地址、一次性码、信任根指纹和属主都是公开的值,没有私钥):建一个目录,把参数写进 .env(权限 600),下载面板提供的静态 compose 文件、核对 SHA-256,一致才启动:
mkdir -p moshiro-probe && cd moshiro-probe && cat > .env <<'EOF'
MOSHIRO_SERVER=https://panel.example.com
MOSHIRO_JOIN=jt_…
MOSHIRO_GENESIS=…
MOSHIRO_CONTROLLERS=…
MOSHIRO_IMAGE=registry.oeo.one/moshiro/moshiro-probe
EOF
chmod 600 .env && curl -fsSL 'https://panel.example.com/install/compose/probe.yaml' -o compose.yaml && echo "<sha256> compose.yaml" | sha256sum -c - && podman compose up -d
- compose 文件是静态的(
packaging/compose/agent|probe|backend/compose.yaml,作为嵌入资源打进 Backend,由/install/compose/{agent,probe,backend}.yaml原样提供,不含任何一次性的值)。镜像是${MOSHIRO_IMAGE}:${MOSHIRO_VERSION:-latest},由 CI 渠道决定,不由面板决定;MOSHIRO_IMAGE取自面板的MoShiro__NodeImageRegistry和MoShiro__ImageNamespace(默认registry.oeo.one/moshiro,开发镜像moshiro-dev)。 - 只读根文件系统、丢掉全部能力、
no-new-privileges、命名卷;Linux 上用宿主网络(Agent 对外暴露端口,也靠它识别本机公网地址,并只读挂载/proc、/sys、/读主机指标)。Agent 的 compose 只适用于 Linux;Probe 在 macOS 和 Windows(Docker Desktop)上没有宿主网络,面板给的命令会在.env里写MOSHIRO_NETWORK_MODE=bridge。 - 身份和配置在命名卷
moshiro-<role>-config里:镜像入口脚本(packaging/container/entrypoint.sh)第一次启动用MOSHIRO_SERVER、MOSHIRO_JOIN(和信任根指纹、属主)注册并写配置,之后重启、换新镜像重建容器都不需要再给参数,所以注册成功之后可以把.env里的MOSHIRO_JOIN删掉。缺了信任根指纹或属主就不注册(只有还没有信任根的老面板才用MOSHIRO_ALLOW_NO_OWNER=1)。 - Podman 的开机自启要靠
podman-restart.service(systemctl enable --now podman-restart;rootless 用--user,并loginctl enable-linger);Docker 用 compose 里的restart: unless-stopped。 - 更新:
podman compose pull && podman compose up -d(Docker 用docker compose),只重建镜像变了的容器,身份和配置不动,节点几十秒内重连。想固定版本,在.env里写MOSHIRO_VERSION=0.3.3;想要更严,写成MOSHIRO_VERSION=0.3.3@sha256:<digest>,digest 取自面板信任页或签名清单(那里有发布签名)。回滚就是改回旧版本再up -d。 - 信任:compose 来自面板或 CI 渠道,面板被攻破也改不了镜像地址;但客户端不再验发布签名,拉取靠 TLS 加「注册表可信」这个假设(文档里本来就把 CI、Gitea、注册表放在可信范围内)。这条路没有管理端的「更新」列和确认更新按钮,那些依赖宿主机更新器;要有签名验证的更新,用下面的部署脚本。
一行安装脚本(备用,Docker 或 Podman,Agent 和 Probe 共用):管理端的弹窗里折叠着一条旧的一行命令,先核对脚本哈希再执行,脚本向面板取签名清单、用内置发布公钥验签、按 digest 拉镜像:
f=$(mktemp) && curl -fsSL 'https://panel.example.com/install' -o "$f" && echo "<sha256> $f" | sha256sum -c - >/dev/null \
&& MOSHIRO_SERVER=… MOSHIRO_ROLE=… MOSHIRO_CODE=jt_… MOSHIRO_GENESIS=… MOSHIRO_CONTROLLERS=… sh "$f" # Linux、macOS
# Windows(PowerShell):irm 'https://panel.example.com/install.ps1' 下载后用 Get-FileHash 核对,不一致就 throw,再带同样的环境变量运行
脚本是静态的(packaging/install/install.sh 和 install.ps1,嵌入 Backend,不替换任何值),所以它的 SHA-256 在一个版本内固定;角色、面板地址、代码和属主走环境变量。环境变量 MOSHIRO_NAME、MOSHIRO_ENGINE=docker|podman、MOSHIRO_DRY_RUN=1(只打印 run 命令);Podman 会顺便启用 podman-restart。
用脚本部署和更新(rootful Podman 或 Docker,推荐):packaging/container/deploy-node.sh 可以重复运行,每次都会拉取镜像并固定到 digest、把配置迁移到当前版本需要的样子(例如旧的 listen.bind 为 0.0.0.0 的 Agent 改成 ::,这样 IPv6 线路才能建立)、重装单元和更新器,然后重启节点;已有的身份不会动,更新策略沿用上次的。
# 首次部署(需要 root;脚本和 packaging/ 里的 systemd/、update/ 放在一起)
sudo sh deploy-node.sh agent --image-repo registry.oeo.one/moshiro/moshiro-agent --server https://<backend> --join <token> --name <name>
sudo sh deploy-node.sh probe --image-repo registry.oeo.one/moshiro/moshiro-probe --server https://<backend> --join <probe 令牌> --name <name>
# Backend(只支持 Podman,更新策略固定为管理端确认;--port 默认 29181,环境文件写在 /etc/moshiro/backend.env,已有的不覆盖)
sudo sh deploy-node.sh backend --channel dev --public-url https://<面板域名>
# 没有默认管理员:首次打开面板按页面引导认领(认领码写在后端日志里,或用 --env MoShiro__ClaimCode=… 自己指定)
# 其他环境变量用 --env 原样写入(只在首次创建 backend.env 时生效):本地开发前端要连这个面板时加
# --env MoShiro__CorsOrigins=http://localhost:5173,http://localhost:5174 --env MoShiro__SecureCookies=false
# 面板前面有反向代理(Caddy、frp、Cloudflare)时,登录限流和审计看到的是代理的地址:把代理的地址写进可信代理,才会采信转发头里的客户端地址
# --env MoShiro__TrustedProxies=127.0.0.1,172.16.0.0/12 (头默认 X-Forwarded-For,可用 MoShiro__TrustedProxyHeader 改成 CF-Connecting-IP)
# 之后更新:不带参数(旧版 Probe 的 probe.json 没有 fleet 段,加上 --server --join --name 重新运行即可,旧文件备份为 probe.json.old)
sudo sh deploy-node.sh agent
Agent 的 public_host 和 ipv6_host 由 Agent 上报自动识别(需要宿主网络,脚本和 Compose 默认如此,监听 ::),IPv6 不用额外配置。
纯 Compose 使用 compose pull && compose up -d 更新镜像;面板自动更新使用下述 systemd 单元和更新器。Rootless Podman 使用 moshiro-<role>-rootless.service,安装到 ~/.config/systemd/user/,镜像文件为 ~/.config/moshiro/<role>.image;rootful Podman 使用 moshiro-<role>-container.service,Docker 使用 moshiro-<role>-docker.service,安装到 /etc/systemd/system/,镜像文件为 /etc/moshiro-update/<role>.image。所有单元均使用相同的配置和状态 volume 名称。自动启动分别使用 systemctl --user enable --now <unit> 或 sudo systemctl enable --now <unit>。Rootless 在退出登录后继续运行需管理员执行一次 loginctl enable-linger <用户名>。
Backend 用 packaging/compose/backend/compose.yaml(见上文「Backend 容器」),数据库和原始数据在命名卷 moshiro-backend-data 里。首次安装 Podman 与用户 subuid/subgid 映射需要管理员权限,之后普通用户运行无需 sudo。
已在 Fedora CoreOS 的 SELinux Enforcing 环境、Podman 6.0.2 rootless 下用同一份 Compose 实测:Agent 注册、宿主指标、Probe 登录和 TLS 连接正常,IPv4/IPv6 ICMP 目标任务收到有效回包,容器保持 cap_drop: ALL,未添加 NET_RAW。Linux ICMP 优先使用 datagram ping socket,不依赖外部 ping 程序;需要 net.ipv4.ping_group_range 包含容器映射后的 GID(测试环境为 0 2147483647)。宿主禁止普通用户 ping 时,需管理员配置该范围;例如 rootful Docker 的用户 GID 1654 可设置 sysctl -w "net.ipv4.ping_group_range=1654 1654"。rootless 应使用映射后的宿主 GID,不能直接套用容器 GID。单加 cap_add: NET_RAW 不保证非 root 用户获得有效 capability。有原始套接字权限的部署仍保留原有 Ping 回退路径。其他操作系统继续使用 System.Net.Ping。ICMP 目标任务当前由 Agent 执行,Probe 没有独立目标任务入口。
定时重启
Agent 和 Probe 每天固定时刻有序退出一次,由服务管理器拉起(所有 systemd 单元和 OpenWrt 的 procd 脚本都是「总是重启」,正常退出也会被拉起)。节点只是自己退出,没有任何执行命令的能力;重启期间测量会中断几十秒。
- 在管理端控制:系统 → 设置 →「定时重启」,开关、时刻和时区(默认开,北京时间 04:30)。保存后马上通知所有在线节点,新连上的节点会补发。
- 节点本地覆盖:节点本地写了就以本地为准(面板的设置对它不生效)。
agent.json或probe.json里加"restart_at": "04:30+08:00",或者设置环境变量MOSHIRO_RESTART_AT(优先)。写法HH:mm(按 UTC)、HH:mmZ或HH:mm±hh:mm;写错了启动时就会报错。Probe 的容器同样可以用环境变量。 - 启动后不满 10 分钟不会触发,所以重启之后不会立刻又重启一次。日志里有
restart scheduled at=… source=panel|local和stopping result=scheduled_restart。 - Windows 上没有自带的服务单元,需要服务管理器配置为退出后重启。
Agent 的系统指标挂载
Agent 的 Compose 模板已经包含以下只读挂载和环境变量,systemd 示例也采用相同配置:
| 宿主来源 | 容器路径 | 配置 | 指标 |
|---|---|---|---|
/proc |
/host/proc |
HOST_PROC=/host/proc |
CPU、内存、累计网络流量、瞬时收发速率、运行时长 |
/sys |
/host/sys |
HOST_SYS=/host/sys |
网卡驱动报告的链路速率 |
/ |
/host/root |
HOST_ROOT=/host/root |
宿主根文件系统容量和已用空间 |
/etc/os-release |
/etc/os-release |
无 | 宿主发行版 |
系统目录不加 :z 或 :Z,容器统一设置 --security-opt label=disable。读取失败的指标返回空值;配置了 HOST_ROOT 但没有挂载时不会改读容器 overlay 根分区。未设置这些环境变量时,原生进程继续读取本机默认路径。
磁盘指标是根文件系统容量,不是所有硬盘合计,也不采集磁盘 I/O。链路速率是参与流量统计的接口速率合计,单位 Mbit/s,不是互联网测速结果;任一接口不支持读取速率时显示空值。接口仍按名称排除回环、容器、网桥和部分隧道,复杂 bonding/VLAN 配置可能存在重复计数。主机名、硬件型号、MAC/IP 不作为资源指标上报。
Probe 也上报同样的汇总数字(probe.json 的 reporting.host_stats 默认开,设为 false 就一个字也不上报,同时可以去掉 /proc、/sys、/ 这三个挂载),所以 Probe 的 compose 和 Agent 一样挂载它们并设置 HOST_* 环境变量。Docker Desktop(macOS、Windows)上读到的是 Docker 的虚拟机,不是宿主系统本身;Windows 上直接运行的 Probe 和 Agent 用系统调用读 CPU、内存和物理网卡的流量(不读网卡名和地址);macOS 目前只有磁盘和运行时长。
容器节点的更新(Agent、Probe)
更新器支持 rootless Podman 用户服务、rootful Podman 和 Docker 系统服务。配置、身份和更新请求通过命名 volume 传递,不需要指定宿主数据目录。短时辅助容器读取请求、写入独立状态 volume;辅助镜像必须已在本机拉取,更新器不会自动拉取辅助镜像。配置、公钥和已安装版本记录留在容器外。
Rootless Podman 普通用户安装示例(先将对应 rootless 服务单元复制到用户 systemd 目录):
sh packaging/update/install-updater.sh agent --user \
--volume moshiro-agent-config --status-volume moshiro-agent-status \
--helper-image registry.oeo.one/moshiro/moshiro-agent:<version> \
--image-repo registry.oeo.one/moshiro/moshiro-agent \
--unit moshiro-agent-rootless.service --policy auto
使用 Docker 时以管理员运行相同安装命令,去掉 --user,加 --engine docker,单元选择 moshiro-agent-docker.service。rootful Podman 去掉 --user,单元选择 moshiro-agent-container.service。Probe 替换角色名。初始化对应 <role>.image 为 IMAGE=<当前镜像引用> 后启动容器单元。辅助镜像可用本机已有的 digest 引用,避免标签变化。Docker rootless 更新尚未适配。
命名 volume 的更新服务由 systemd timer 每 30 秒检查一次。--policy auto 验签后自动应用,默认 manual 等待面板写入应用请求。清单必须通过 ECDSA P-256 签名校验,组件、镜像仓库和递增 seq 都必须匹配本机配置;镜像按签名清单中的 digest 拉取。更新后观察服务 20 秒,启动失败或崩溃循环会恢复旧镜像引用并重启。无效签名、重放清单、拉取失败不会切换当前镜像。同一组件的更新通过 flock 串行处理。
更新器依赖 Linux、systemd、OpenSSL、util-linux/flock 和所选引擎。用户部署把配置、公钥和镜像引用放在 ~/.config/moshiro/、版本记录放在 ~/.local/state/moshiro-update/;系统部署使用 /etc/moshiro-update/ 和 /var/lib/moshiro-update/。私有仓库的 --authfile 对 Podman 使用其认证文件,对 Docker 使用标准 config.json,不改写全局 Docker 配置。旧 --data-dir 安装方式保留兼容,但新部署统一使用 --volume。
发布私钥只放在 Woodpecker 的 release_signing_key secret;发布公钥位于 packaging/update/release-key.pub.pem。RELEASE_API_URL 指向面板,CI 把签名清单 POST 到 /api/release/offer:这个入口不需要令牌,面板用内嵌的发布公钥验签,签名无效就拒绝,所以 CI 不需要在面板里持有任何凭证(清库或重装后照常登记,也没有长期令牌可偷)。面板保存并转发签名清单,Agent/Probe 将它与更新请求写进配置 volume;更新器验签后才调用容器引擎和 systemd。管理端仍能看到可更新、更新中、已回滚和无效清单状态。纯二进制更新器尚未实现。
自有发布密钥(自己构建、自己发布的部署;默认关闭):管理端「系统 → 信任 → 可信资产来源」。官方 CI 发布密钥始终信任,不能关;另外导入自己的发布公钥(P-256 的 PEM,面板拒绝私钥,私钥留在你自己的机器或 CI 密钥库)之后,用你的私钥签的清单也会被面板登记和分发。签名方法:openssl dgst -sha256 -sign release.key -out manifest.sig.bin manifest.json,再 base64 -w0 得到签名;清单是一行扁平的 JSON(字段见 ci/release-manifest.sh),交给面板用 POST /api/release/offer,或者像官方那样放进你镜像仓库里的清单镜像。节点宿主机的更新器只认本机的公钥文件,所以用你自己的密钥发布时,还要把你的公钥放进这些节点的 release-key.pub.pem,并且清单里的 image 必须等于它本机配置的镜像仓库。停用自有密钥时,只有它签过的已登记清单会被丢掉。
换发布密钥(泄露或定期轮换,不用重装节点):
- 生成新密钥对;CI 给发布流水线加
RELEASE_NEXT_KEY(新公钥 PEM),仍用旧私钥签名。清单里会带next_key(SPKI DER 的 base64)。 - 宿主机更新器验过这份清单后,把新公钥存到自己的状态目录(
<组件>.key.pem),之后只认新密钥,旧密钥签的清单一律拒绝。清单里的next_key不是合法公钥就整份拒绝。 - 下一个版本起把
release_signing_key换成新私钥、去掉RELEASE_NEXT_KEY,并把packaging/update/release-key.pub.pem、install.sh、install.ps1里的公钥换成新的(安装脚本哈希随清单变化)。 - 还没收到过渡清单的节点(离线很久)会因为签名对不上而停在旧版本,需要手动换
release-key.pub.pem;旧密钥泄露时,这些节点是唯一的暴露面。
一行安装:页面给的命令先下载静态安装脚本(/install,所有人拿到的字节都一样)并核对它的 SHA-256,一致才执行;面板地址、角色、一次性码、信任根指纹(MOSHIRO_GENESIS)和属主(MOSHIRO_CONTROLLERS)走环境变量,都是公开值,没有私钥。脚本向面板取 CI 签名的发布清单(/install/manifest、/install/manifest.sig),用脚本里内置的发布公钥(packaging/update/release-key.pub.pem)验签,再按清单里的 digest 拉镜像(镜像仓库@sha256:…);签名对不上、组件不对或 digest 格式不对就拒绝安装;需要本机有 openssl(Windows 用 .NET 自带的)。面板还没有这个组件的已签名清单时,设 MOSHIRO_ALLOW_UNVERIFIED=1 和 MOSHIRO_IMAGE=镜像 才会安装。CI 把脚本的 SHA-256 写进签名清单和发布页,面板的「系统 → 信任」页显示它和清单是否一致。详见 access.md 第 1 节。
签名清单放在镜像仓库里(更新和首次部署都不依赖面板)
CI 给每个镜像的 digest 签名之后,除了(面板在线时)登记给面板,一定会把签名清单推到镜像仓库:<镜像仓库>-manifest:<通道>,例如 registry.oeo.one/moshiro-dev/moshiro-backend-manifest:dev、registry.oeo.one/moshiro/moshiro-backend-manifest:release。它是一个没有任何文件层的小镜像,清单和签名放在标签 org.moshiro.manifest(base64)和 org.moshiro.signature 里。dev 通道每次 [CI] 构建都会更新,release 通道只在打 tag 时更新,所以不发 Release 的构建照样能被更新到。
信任锚点:仓库里的发布公钥 packaging/update/release-key.pub.pem(随安装脚本和 deploy-node.sh 分发)
取清单的人:宿主机更新器(定时)、deploy-node.sh(首次部署):pull 清单镜像 → 读标签 → 本机公钥验签 → 按清单里的 digest 拉真正的镜像
仓库、网络、面板都不需要可信:被改只会让验签失败;面板不在线、清库、没有 CI 都不影响
- 更新器:配置里
FEED=auto(默认)时先看仓库里的清单,验签通过才用;取不到或验不过就用面板放进信箱的那份。MANIFEST_REF可以指定清单镜像,FEED=off关掉;通道取配置里的CHANNEL,没有就按仓库路径推断(moshiro-dev→ dev,moshiro→ release)。仓库里的清单验签失败而信箱里又没有东西时,状态会报「无效清单」而不是悄悄当作没有更新。 - 面板自己取:安装脚本向面板要某个组件的签名清单时,面板里没有(清过库、CI 登记那一步网络失败)或者已经放了十分钟以上,面板就按 OCI 分发协议匿名读一次仓库里的清单镜像(只读清单和它的配置两个小 JSON,不拉镜像层),用内嵌的 CI 发布公钥验过签才登记;验不过(仓库或路上被改)就当没有。所以清库之后不用手工补登记,也不会被一份伪造的清单骗到。后台还每 5 分钟替 Agent、Probe 和 Backend 自己各取一次:CI 把清单登记给面板那一步是尽力而为的(网络不通只警告,流水线仍是绿的),没有这个后台轮询的话,面板收不到登记就看不到 Backend 的新版,宿主机上旧版的更新器也就一直报「最新」。仓库要匿名可读(
moshiro/**、moshiro-dev/**)。 - 发版:打
vMAJOR.MINOR.PATCHtag 时,节点镜像(Agent、Probe 的多架构镜像)也和 Backend 一样签清单,放到moshiro/…-manifest:release。 - 压缩包的校验和也签名:Release 里除了每个压缩包的
.sha256,还有汇总的SHA256SUMS和SHA256SUMS.sig(用发布私钥签,base64 的 DER ECDSA P-256,和清单同一把密钥)。验证:base64 -d SHA256SUMS.sig > s.bin && openssl dgst -sha256 -verify packaging/update/release-key.pub.pem -signature s.bin SHA256SUMS,通过之后再sha256sum -c SHA256SUMS。macOS 的 Developer ID 签名和公证、Windows 的 Authenticode 需要苹果和微软的证书,目前没有做。 - 首次部署:
deploy-node.sh默认就走这条路,不需要面板,也不用手动指定镜像(Backend 第一次部署时本来就没有面板)。--tag指定标签时不验签并有警告;--image直接用你给的引用。 - 别人自己部署后端,什么都不用改:仓库对匿名读开放(
moshiro/*和各*-manifest仓库),然后sudo sh deploy-node.sh backend --channel release --public-url https://<自己的域名>,只是配置,不改代码、不需要自己的 CI;passkey 绑定在自己的域名上。自己 fork 并自建镜像的人要换自己的发布公钥和仓库地址(--image-repo)并重新构建,这是 fork 的正常代价。 - 测试:
bash packaging/update/tests/feed-test.sh(假的 podman + 真的 openssl,覆盖篡改、别的密钥、旧 seq、换钥、仓库不可用回退、首次部署的验签)。
Backend 签名更新
Backend 也会收到 CI 发布的签名清单,但只提示管理端,不会自动重启。管理员确认精确的清单序号后,宿主更新器才会备份 Backend 数据 volume、按清单 digest 拉取镜像、重启服务并检查 /healthz;失败时恢复旧镜像和数据库备份。确认和发布通过同一个锁串行处理,避免确认过期版本。
MOSHIRO_UPDATE_CHANNEL=auto 时,MOSHIRO_IMAGE_NAMESPACE=moshiro-dev 选择 dev,moshiro 选择 release。CI 发布流水线使用对应 channel 签名并登记到 Backend webhook。旧版 Backend 不识别 Backend 清单,因此首次升级需按常规部署流程引导一次;该期间 CI 对这类旧版面板的明确“不认识 backend 组件”响应会提示引导信息,其他 webhook 错误仍会令发布失败。
更新器以手动策略运行,并通过独立状态 volume 将状态只读挂载回 Backend:
sudo packaging/update/install-updater.sh backend --engine podman \
--volume moshiro-backend-data --status-volume moshiro-backend-status \
--helper-image registry.oeo.one/moshiro-dev/moshiro-backend:<version> \
--image-repo registry.oeo.one/moshiro-dev/moshiro-backend \
--unit moshiro-backend.service --policy manual --channel dev \
--health-url http://127.0.0.1:29181/healthz
Backend 服务需要将 moshiro-backend-status 挂载到 /data/update-status:ro,并将镜像引用文件 /etc/moshiro-update/backend.image 作为 IMAGE_FILE 提供给 systemd 单元。release 实例使用 --channel release 及 registry.oeo.one/moshiro 仓库。更新请求由管理端确认后写入状态接口,不要启用全局 Podman 自动更新 timer。