39 Commits
Author SHA1 Message Date
justme 879d1c8593 fix: installer checks containers only; the panel reads signed manifests from the registry when it has none; unsigned-updater nodes are labeled from the panel's manifests; sign node release images; release 0.3.0 [CI]
ci/woodpecker/push/main-amd64-build Pipeline was canceled
ci/woodpecker/tag/linux-release Pipeline failed
ci/woodpecker/tag/windows-release Pipeline was successful
ci/woodpecker/tag/gitea-release unknown status
2026-10-08 15:59:28 +08:00
justme 4a1e48db20 test: share one operator and trust root across the trust tests; make the setup test cleanup best-effort [CI SKIP] 2026-10-08 15:30:12 +08:00
justme f22fdf8b68 feat: first-run claim wizard (no default admin, claim code from the backend log); owner list shows account and passkey; container install first [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
2026-10-08 06:47:10 +08:00
justme 2ae63653c4 feat: the node itself requires an owner passkey (shared by agent and probe) and reports it; the panel derives the account and passkey it is bound to [CI] 2026-10-08 06:15:07 +08:00
justme 8c37db1577 feat: every agent and probe must have an owner passkey, enforced by the panel, the installers and fleet-enroll [CI] 2026-10-08 06:04:04 +08:00
justme 0b384a58f5 fix: hash the node binary without the registry auth file that does not exist yet [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
2026-10-08 05:43:51 +08:00
justme 4841f7b15b feat: bind each node to the binary it runs at its first handshake; refuse changes that are not in a CI-signed manifest [CI]
ci/woodpecker/push/main-amd64-build Pipeline failed
2026-10-08 05:39:13 +08:00
justme 117d34a2af feat: signed manifests live in the image registry; updaters and first deploys fetch and verify them without the panel [CI] 2026-10-08 05:05:13 +08:00
justme 4106f831dc feat!: drop the backend-side elevation windows; password and passkey sessions share capabilities, passkeys guard what nodes verify; the trust page lists added and removed passkeys [CI] 2026-10-08 04:57:28 +08:00
justme f704c66dd9 feat: CI registers release manifests without a token; the panel verifies the CI signature instead [CI]
ci/woodpecker/push/main-amd64-build Pipeline failed
2026-10-08 04:22:01 +08:00
justme fbda9487d5 feat: serve the web apps from the Backend (embedded, allow-listed, no filesystem access) so passkeys bind to the panel's own domain [CI]
ci/woodpecker/push/main-amd64-build Pipeline failed
2026-10-08 04:09:40 +08:00
justme 4a3631f82b fix: gate password sessions on the login method so data-source sessions also get the passkey-only page [CI SKIP] 2026-10-08 04:09:39 +08:00
justme 3b88a3419d ci: build the dev images for the genesis-pinned trust model [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
2026-10-08 03:28:03 +08:00
justme a2f88a344b feat!: genesis-pinned operator set, server identity with an encrypted control link, key-less join codes; release key continuity
ci/woodpecker/tag/gitea-release Pipeline is pending
ci/woodpecker/tag/linux-release Pipeline failed
ci/woodpecker/tag/windows-release Pipeline was canceled
Join codes need no passkey touch and carry only public values (genesis
fingerprint, owner credentials, installer hashes). Nodes pin the genesis and
owners at enrollment and only follow passkey-signed operator set versions.
The panel's server key is listed in the set; pinned nodes require its
signature and run the control link under AES-256-GCM. Password sessions carry
no capabilities. The updater adopts a next_key from a signed manifest.

Bump version to 0.2.0. [CI SKIP]
2026-10-08 03:21:44 +08:00
justme 9bb6841dce feat: pinned agents and probes only accept peers carrying the operator's proof; the panel ships the signed operator chain with the rosters [CI SKIP] 2026-10-07 23:24:33 +08:00
justme fc2bbdcc6d feat: operator trust root: passkey-signed operator set, signed join codes, nodes verify and pin the chain at enrollment [CI SKIP] 2026-10-07 23:19:59 +08:00
justme 321cacac32 feat: operator trust chain library (passkey-signed operator set, grants and code-key bindings) [CI SKIP] 2026-10-07 23:09:09 +08:00
justme 36f9816f40 test: allow the installer manifest paths in the OpenAPI layout check [CI SKIP] 2026-10-07 23:02:50 +08:00
justme 310b2b5b19 feat: the one-line installer verifies the CI-signed release manifest and pulls the image by digest [CI] 2026-10-07 23:01:45 +08:00
justme a19e6548d3 feat: trust forwarded client addresses only from configured proxies (rate limit, audit, SSE) [CI]
Docs: access.md covers the missing node-side Backend identity and the hash-first install flow.
2026-10-07 22:51:44 +08:00
justme ff45ad086e feat!: sensitive actions need a fresh passkey touch (elevation windows), plus login page hint cleanup [CI] 2026-10-07 22:29:24 +08:00
justme 1379b8c7cb feat: audit log of admin writes, sign-ins and key creation, with an audit tab [CI] 2026-10-07 22:17:52 +08:00
justme 359313a57b feat: record who created join codes and who added each node [CI] 2026-10-07 22:12:15 +08:00
justme 6385c1b9a3 feat: sign in with a passkey (WebAuthn, ES256, discoverable credentials) and manage your passkeys [CI] 2026-10-07 21:57:19 +08:00
justme ddb6608e32 feat!: capabilities replace API token scopes; every admin endpoint declares one, with a picker UI [CI] 2026-10-07 20:56:36 +08:00
justme 55b80f0a97 docs: access control and enrollment trust chain design [CI SKIP] 2026-10-07 20:45:23 +08:00
justme af2962fc58 feat: one-line install for agents and probes from one shared script (curl | bash, zsh, PowerShell) [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
2026-10-07 20:02:38 +08:00
justme 5528753d45 feat: deploy-node.sh backend takes extra env settings with --env [CI SKIP] 2026-10-07 19:40:13 +08:00
justme b99b95c738 feat!: pre-handshake knock on the agent TCP ports; probes get the agent certificate public key [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
2026-10-07 18:56:40 +08:00
justme f03988d68b feat: detect an agent's public IPv4 and IPv6 addresses automatically, manual values still win [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
2026-10-07 18:12:17 +08:00
justme 9966ea5d63 fix: run the enrollment container on the host network [CI SKIP] 2026-10-07 17:20:08 +08:00
justme b4b50c636f feat: deploy-node.sh also deploys the Backend and migrates old probe.json [CI SKIP] 2026-10-07 17:12:00 +08:00
justme a9f562441f feat!: probes generate their own keys and enroll with a join token; node and peer keys are separate [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
2026-10-07 16:57:34 +08:00
justme 1ff847e45a feat: declare every protocol method's direction and enforce it in the router; add the zero-trust design [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
Methods, roles and binary channels are declared in one table per plane (node and peer). RpcRouter checks direction, kind and role when handlers are registered, when messages arrive (fleet_protocol_violation, disconnect after repeated violations) and before sending. NodeConnection is split by responsibility.
2026-10-07 16:17:02 +08:00
justme ee22309e65 fix: keep the Backend backup out of the journal [CI SKIP] 2026-10-07 15:07:39 +08:00
justme 9bcef56497 feat: daily node restart controlled from the admin, connect timings as a default line task [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
Agents and Probes exit in order at a configured time (default 04:30 UTC+8, switch and time zone in the admin settings) and the service manager starts them again; a local restart_at or MOSHIRO_RESTART_AT wins. The connect kind (TCP connect and TLS handshake timings) is now a default continuous line template, with a section on the line page.
2026-10-07 14:56:50 +08:00
justme 388619b8b6 feat: add a re-runnable deploy script for container nodes and migrate listen.bind to :: [CI SKIP] 2026-10-07 14:17:50 +08:00
justme 3b73fd7474 fix: pass Backend listener port into container (#18) [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
Pass the host listener URL to the Backend container process.
2026-10-07 05:17:13 +00:00
justme e423d9b506 fix: pass Backend listener port into container [CI]
ci/woodpecker/pr/verify Pipeline was successful
ci/woodpecker/pr/web-build Pipeline was successful
2026-10-07 13:13:21 +08:00
243 changed files with 16496 additions and 1999 deletions

No files matched your search

+4
View File
@@ -3,3 +3,7 @@ ci/index-backend-image.sh text eol=lf
ci/publish-release-offer.sh text eol=lf
ci/release-linux.sh text eol=lf
packaging/update/*.sh text eol=lf
packaging/container/*.sh text eol=lf
ci/release-node-images.sh text eol=lf
packaging/install/*.sh text eol=lf
packaging/install/*.ps1 text eol=lf
+3
View File
@@ -27,6 +27,9 @@ steps:
ZOT_USERNAME: justme
ZOT_SECRET:
from_secret: zot_secret
RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
commands:
- bash ci/release-node-images.sh
depends_on: []
-2
View File
@@ -50,8 +50,6 @@ steps:
RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
RELEASE_API_TOKEN:
from_secret: release_api_token
commands:
- bash ci/index-backend-image.sh
depends_on:
-6
View File
@@ -27,8 +27,6 @@ steps:
RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
RELEASE_API_TOKEN:
from_secret: release_api_token
commands:
- bash ci/build-main-amd64.sh backend
depends_on: []
@@ -44,8 +42,6 @@ steps:
RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
RELEASE_API_TOKEN:
from_secret: release_api_token
commands:
- bash ci/build-main-amd64.sh agent
depends_on: []
@@ -61,8 +57,6 @@ steps:
RELEASE_API_URL: https://mo.oeo.one
RELEASE_SIGNING_KEY:
from_secret: release_signing_key
RELEASE_API_TOKEN:
from_secret: release_api_token
commands:
- bash ci/build-main-amd64.sh probe
depends_on: []
+1 -1
View File
@@ -9,7 +9,7 @@ MoShiro is a self-hosted network path quality monitor. Backend coordinates Agent
- Treat `main` as the stable release branch and `dev` as the integration branch. Keep ordinary implementation work on `dev` or a feature branch; integrate into `main` through a pull request.
- A push to `main` builds images only if its commit message contains `[CI]` (for a merged PR, put it in the PR or merge commit title). Without `[CI]` nothing is built; use `[CI SKIP]` to skip the pipeline entirely. When squashing or pushing to `main`, add `[CI]` unless a build is explicitly not wanted.
- Before editing, inspect `git status`. Preserve pre-existing user changes: do not reset, clean, stash, or include unrelated files in a commit.
- Do not commit credentials, API keys, registry passwords, or generated runtime data. CI credentials belong in Woodpecker secrets (`zot_secret`, `gitea_token`, `release_signing_key` for signing release manifests, `release_api_token` with the `release` scope). The release signing private key never goes into the repository or the panel; only the public key in `packaging/update/` does.
- Do not commit credentials, API keys, registry passwords, or generated runtime data. CI credentials belong in Woodpecker secrets (`zot_secret`, `gitea_token`, `release_signing_key` for signing release manifests). CI registers the signed manifests at `/api/release/offer`, which needs no token because the panel verifies the signature. The release signing private key never goes into the repository or the panel; only the public key in `packaging/update/` does.
- Keep development OCI images in the `moshiro-dev/` repository path and release images in `moshiro/`. Do not let a development build overwrite release tags such as `latest`.
## Project layout
+3 -1
View File
@@ -20,7 +20,7 @@ MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 P
## 信任关系
```text
管理员 ── 账号密码(Cookie 会话 + CSRF)或 API 令牌(read · admin · release)──▶ Backend
管理员 ── 账号密码(Cookie 会话 + CSRF)或 API 令牌(按能力授权)──▶ Backend
访客 ── 匿名只读,站点设为公开时才可用(没有 IP、城市、ASN)──────────────▶ Backend 公开接口
节点 ── 加入令牌一次性注册,之后用自己的 ECDSA P-256 密钥签名认证 ───────▶ Backend(只存公钥)
@@ -97,6 +97,8 @@ Backend、Agent、Probe 以 OCI 镜像发布(Linux amd64、arm64),Agent
| 文档 | 内容 |
| --- | --- |
| [`docs/architecture.md`](docs/architecture.md) | 架构、数据链路、安全边界、设计决策 |
| [`docs/security.md`](docs/security.md) | 零信任设计稿(部分已实现):威胁模型、入网信任链、监听隐身、extension 授权 |
| [`docs/access.md`](docs/access.md) | 访问控制与入网信任链设计(尚未实现):账户、角色、能力、API key、提权窗口、passkey 到节点的签名链 |
| [`docs/protocol.md`](docs/protocol.md)、[`backend/fleet/README.md`](backend/fleet/README.md) | 线协议、Fleet 节点框架(身份、注册、连接) |
| [`docs/enrollment.md`](docs/enrollment.md) | 注册、身份、Probe 与 Agent 的接入 |
| [`docs/measurement.md`](docs/measurement.md)、[`docs/analysis.md`](docs/analysis.md) | 采集引擎与任务模型、分析层与指标 |
+4 -5
View File
@@ -5,20 +5,19 @@
## 测量
- 路由跳数、路径漂移和 trace
- 握手耗时:TCP 连接耗时,TLS / QUIC 握手耗时(QUIC 目前没有真实握手积木)
- QUIC 握手耗时:TCP 建连和 TLS 握手已经有了(`connect` 任务)。.NET 的 QUIC 依赖系统里的 msquic 原生库,和节点「只依赖 OpenSSL、NativeAOT 单文件」冲突,要做只能自己写最小的 QUIC Initial 握手探测
- 网页长截图
- pcap / eBPF 采集
## 任务与面板
- 单个 Agent 的任务(目前是线路模板加按 Agent 的覆盖,没有只属于某一个 Agent 的独立任务)
- 原生 webhook 投递;告警(基于 webhook)
- Agent 和 Probe 的定时重启(可选,例如早上四点)
- 原生 webhook 投递;告警(基于 webhook)。实现之前先定约束(只允许 HTTPS、禁止私网和回环、不跟随重定向、限制超时和响应大小、解析后再检查地址),见 [`docs/security.md`](docs/security.md) 第 9.2 节
## 部署
- 后端自动下载前端资源:用 Podman、Docker 部署时,默认拉取最新的前端资源(现在前端要自己构建,镜像里没有)。
[security.md](docs/security.md)
## 访客页面
- 人机验证:访客页面要通过验证才能拉数据,前端访问时静默触发。候选 tiago2/cap 和 Cloudflare Turnstile。
@@ -39,7 +38,7 @@
## 安全
- **Agent、Backend、Probe 的零信任设计**:目前 Backend 是三者之间的信任中心,需要梳理哪些信任可以去掉。
- **零信任**:设计稿在 [`docs/security.md`](docs/security.md),按其中第 12 节的顺序实现(阶段零「协议方向声明表」已完成):Probe 自己生成密钥、Agent 预握手敲门、操作员凭证和名册、测量数据的签名和归属校验、入库配额和管理端 CSP、Backend 应用层身份、extension 框架(敏感能力都放进可选安装的独立 extension,核心保持 NativeAOT);可选的 UDP 单包授权、UDP 会话迁移的路径验证。
- **extension**(Web SSH、TTY、更新遥测,敏感 operator):设计时必须保住「节点是纯采集器」这条边界,见 [`README.md`](README.md) 的项目边界。
- 对 UDP 包、控制帧、RawChunk、JSON-RPC 做模糊测试;伪造签名和重放的专项测试。
+1 -1
View File
@@ -13,7 +13,7 @@
<UseArtifactsOutput>true</UseArtifactsOutput>
<ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath>
<Deterministic>true</Deterministic>
<Version>0.1.1</Version>
<Version>0.3.0</Version>
<NuGetAudit>false</NuGetAudit>
</PropertyGroup>
<ItemGroup>
+1 -1
View File
@@ -49,6 +49,6 @@ cd backend
dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000
```
首次启动时可用 `MoShiro__InitialAdminUser` 和 `MoShiro__InitialAdminPassword` 设置初始管理员。数据目录、公开地址、Cookie 和 CORS 等设置通过 `MoShiro` 配置节传入;完整配置说明见 [`../docs/deployment.md`](../docs/deployment.md)。站点默认需要登录,可在管理端修改公开设置。
没有默认管理员:第一次打开面板时按页面引导自己创建管理员、注册 passkey、建立信任根,页面要的认领码写在后端日志里(或用 `MoShiro__ClaimCode` 指定)。`MoShiro__InitialAdminUser` / `MoShiro__InitialAdminPassword` 只给自动化用。数据目录、公开地址、Cookie 和 CORS 等设置通过 `MoShiro` 配置节传入;完整配置说明见 [`../docs/deployment.md`](../docs/deployment.md)。站点默认需要登录,可在管理端修改公开设置。
OpenAPI 文档由构建生成到 `openapi/v1.json`。API 设计和端点说明见 [`../docs/api.md`](../docs/api.md);项目总览见仓库根目录的 [`README.md`](../README.md)。
+48 -6
View File
@@ -1,3 +1,4 @@
using Fleet.Identity;
using System.Net;
using System.Text.Json;
using Fleet.Core;
@@ -16,6 +17,10 @@ public sealed class AgentApp : IDisposable
private readonly AgentConfig _config;
private readonly bool _ipv6Supported;
private readonly KnockGate _knock;
private readonly TrustAnchor _trust;
private readonly RestartController _restart;
private readonly RestartController? _ownedRestart;
private readonly LoadedNodeIdentity _identity;
private readonly TimeProvider _time;
private readonly INodeLog _log;
@@ -37,9 +42,12 @@ public sealed class AgentApp : IDisposable
private readonly FleetLink _link;
/// <param name="dataDirectory">配置目录(容器里的 <c>/etc/moshiro</c>):宿主机更新器和节点在这里交换清单、请求和状态。</param>
public AgentApp(AgentConfig config, LoadedNodeIdentity identity, string dataDirectory, TimeProvider time, INodeLog log)
public AgentApp(AgentConfig config, LoadedNodeIdentity identity, string dataDirectory, TimeProvider time, INodeLog log, RestartController? restart = null)
{
_ownedRestart = restart is null ? new RestartController() : null;
_restart = restart ?? _ownedRestart!;
_update = new UpdateBridge(dataDirectory, new ConfigStore(dataDirectory).Write, log);
NodeBinary.Prime(); // 正在运行的二进制的哈希:先在后台算好,握手时用
_config = config;
_identity = identity;
_time = time;
@@ -56,7 +64,10 @@ public sealed class AgentApp : IDisposable
_ipv6Supported = bind.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6;
_admission = new AgentAdmission(_streams);
_udp = new UdpMux(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.UdpPorts);
_tcp = new TcpAcceptor(bind, config.Listen.TcpPorts, identity.Certificate, _sessions, _counters.Data);
// 预握手敲门:控制端口和数据端口共用一个关卡(限速和去重一起算);名册就是期望状态里的 peers
_trust = TrustAnchor.Load(config.Fleet.IdentityDir, log);
_knock = new KnockGate(identity.PeerIdentity, RosterKeys, time);
_tcp = new TcpAcceptor(bind, config.Listen.TcpPorts, identity.Certificate, _sessions, _counters.Data, _knock);
// 监听方积木的环境:和 Probe 的拨号方共用同一套积木,区别只在这里装配的部分(准入策略、TCP 监听)
var env = new BlockEnv
@@ -73,7 +84,7 @@ public sealed class AgentApp : IDisposable
};
var chunks = new ChunkAssembler(_streams, _clock, _counters, _outbox, identity.NodeId, time, log);
var targets = new TargetRunners(chunks, identity.NodeId, _clock, time, log);
_tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _udp.BoundPorts, () => _tcp.BoundPorts);
_tasks = new TaskManager(CreateGuard, _streams, env, targets, () => _udp.BoundPorts, () => _tcp.BoundPorts, _trust);
_hub = new PeerHub(_sessions, _clock, chunks, _tasks, _uplink, time, log);
_tests = new TestRunner(_tasks, _sessions, _streams, env, _uplink, config.Limits.MaxConcurrentTests, transport => transport == "udp" ? _udp.BoundPorts : _tcp.BoundPorts, time, log);
@@ -88,6 +99,7 @@ public sealed class AgentApp : IDisposable
Listen = new IPEndPoint(bind, config.Listen.ControlPort),
Certificate = identity.Certificate,
LookupPeerKey = _tasks.LookupPeerKey,
Knock = _knock,
},
_hub.OnSessionAsync,
log,
@@ -100,7 +112,11 @@ public sealed class AgentApp : IDisposable
/// <summary>数据面和控制端口都开始监听之后完成(配置里的端口为 0 时,这里能读到实际端口)。</summary>
public Task Ready => _ready.Task;
public void Dispose() => _udp.Dispose();
public void Dispose()
{
_udp.Dispose();
_ownedRestart?.Dispose();
}
internal StreamRegistry Streams => _streams;
@@ -110,10 +126,16 @@ public sealed class AgentApp : IDisposable
public int ControlPort => _peers.Port;
/// <summary>节点当前采用的操作员集合版本(没有固定信任根时是 0)。</summary>
public int TrustVersion => _trust.Version;
public AgentCounters Counters => _counters;
public FleetLink Link => _link;
/// <summary>对等面(Probe 连进来的会话)的路由器,测试用。</summary>
public RpcRouter<PeerContext> PeerRouter => _hub.Router;
/// <summary>期望状态里是否已经有这个 Probe 的公钥(测试用:握手会按它校验)。</summary>
public bool IsPeerKnown(string probeId, string publicKeyBase64) =>
_tasks.LookupPeerKey(probeId) is { } key && Convert.ToBase64String(key) == publicKeyBase64;
@@ -188,7 +210,7 @@ public sealed class AgentApp : IDisposable
private FleetLink BuildLink()
{
var router = new RpcRouter<LinkContext>();
var router = new RpcRouter<LinkContext>(RpcSide.Node, MoShiroMethodTable.Table, NodeRoles.Agent);
HashSet<string>? allowed = _config.Policy.AllowMethods is { } list ? [.. list] : null;
bool Allow(string method) => allowed is null || allowed.Contains(method);
@@ -219,6 +241,19 @@ public sealed class AgentApp : IDisposable
});
}
if (Allow(MoShiroMethods.Schedule))
{
router.OnNotify(MoShiroMethods.Schedule, MoShiroJsonContext.Default.ScheduleParams, (_, schedule, _) =>
{
if (!_restart.SetRemote(schedule.RestartAt))
{
_log.Warn("ignoring an invalid restart schedule from the panel");
}
return ValueTask.CompletedTask;
});
}
if (Allow(MoShiroMethods.UpdateApply))
{
router.OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) =>
@@ -241,6 +276,8 @@ public sealed class AgentApp : IDisposable
{
Server = new Uri(_config.Fleet.Server),
Identity = _identity.Identity,
ServerVerifier = _trust,
Attestation = _update.Attest,
NodeId = _identity.NodeId,
NodeVersion = Version,
Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', BlockCatalog.AgentKinds)],
@@ -260,14 +297,19 @@ public sealed class AgentApp : IDisposable
return link;
}
/// <summary>敲门关卡的名册:期望状态里的 peers(任务管理器在构造之后才有)。</summary>
private IReadOnlyList<byte[]> RosterKeys() => _tasks.PeerKeys();
private JsonElement? HelloMeta()
{
var meta = new AgentHelloMeta
{
Owners = [.. _trust.Controllers],
Ipv6 = Ipv6Diagnostics.Sample(_ipv6Supported, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")),
Ipv6Supported = _ipv6Supported,
Addresses = [.. LocalAddresses.Sample()],
Platform = MoShiro.Measurement.PlatformDetector.Detect(),
TlsSpki = _identity.File.TlsSpki,
TlsPublicKey = Convert.ToBase64String(_identity.Certificate.PublicKey.ExportSubjectPublicKeyInfo()),
ProbeTcpPort = ControlPort,
ProbeUdpPorts = [.. UdpPorts],
LocalLimits = new LocalLimits
@@ -22,6 +22,10 @@ public sealed class AgentConfig
[JsonPropertyName("reporting")]
public ReportingSection Reporting { get; set; } = new();
/// <summary>每天定时重启一次,例如 <c>04:00+08:00</c>(不写偏移按 UTC);不写就不重启。环境变量 <c>MOSHIRO_RESTART_AT</c> 优先。</summary>
[JsonPropertyName("restart_at")]
public string? RestartAt { get; set; }
}
public sealed class ReportingSection
@@ -41,6 +45,10 @@ public sealed class FleetSection
[JsonPropertyName("identity_dir")]
public string IdentityDir { get; set; } = "/etc/moshiro";
/// <summary>节点自己要求有属主(passkey):没有就不运行。见 <c>OwnerGuard</c>。</summary>
[JsonPropertyName("require_owner")]
public bool RequireOwner { get; set; } = true;
}
public sealed class ListenSection
@@ -90,6 +90,9 @@ public sealed class PeerHub
private readonly TimeProvider _time;
private readonly INodeLog _log;
/// <summary>对等面入站方法的路由器(只读,测试用)。</summary>
public RpcRouter<PeerContext> Router => _router;
public PeerHub(SessionRegistry sessions, MonoClock clock, ChunkAssembler chunks, TaskManager tasks, BackendUplink uplink, TimeProvider time, INodeLog log)
{
_router = BuildRouter();
@@ -272,7 +275,7 @@ public sealed class PeerHub
}
private static RpcRouter<PeerContext> BuildRouter() =>
new RpcRouter<PeerContext>()
new RpcRouter<PeerContext>(RpcSide.Listener, MoShiroMethodTable.Table)
.On(
MoShiroMethods.Clock,
MoShiroJsonContext.Default.ClockParams,
+10 -2
View File
@@ -22,10 +22,17 @@ for (int i = 0; i < args.Length - 1; i++)
AgentConfig config;
LoadedNodeIdentity identity;
RestartController restart;
try
{
config = NodeConfig.Load(configPath, AgentConfigContext.Default.AgentConfig);
restart = new RestartController(RestartSchedule.Resolve(config.RestartAt));
identity = LoadedNodeIdentity.Load(config.Fleet.IdentityDir);
if (!OwnerGuard.Check(config.Fleet.IdentityDir, config.Fleet.RequireOwner, log))
{
identity.Dispose();
return 1;
}
}
catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or System.Text.Json.JsonException or System.Security.Cryptography.CryptographicException)
{
@@ -33,10 +40,11 @@ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidO
return 1;
}
using var restartScope = restart;
using (identity)
{
using var app = new AgentApp(config, identity, Path.GetDirectoryName(Path.GetFullPath(configPath))!, time, log);
await ServiceShell.RunAsync(app.RunAsync, log);
using var app = new AgentApp(config, identity, Path.GetDirectoryName(Path.GetFullPath(configPath))!, time, log, restart);
await ServiceShell.RunAsync(app.RunAsync, log, restart, time);
}
return 0;
@@ -1,4 +1,5 @@
using System.Text.Json;
using Fleet.Hosting;
using Fleet.Core;
using Fleet.Rpc;
using MoShiro.Measurement;
@@ -25,14 +26,16 @@ public sealed class TaskManager
private readonly TargetRunners _targets;
private readonly Func<IReadOnlyList<int>> _udpPorts;
private readonly Func<IReadOnlyList<int>> _tcpPorts;
private readonly TrustAnchor _trust;
private readonly Dictionary<ProbeSession, Dictionary<string, LineEntry>> _sessionLines = [];
private volatile Dictionary<string, byte[]> _peers = new(StringComparer.Ordinal);
private Dictionary<string, (TaskSpec Task, TaskDecision Decision)> _lineSpecs = [];
private long _version;
private BudgetGuard? _guard;
public TaskManager(Func<BudgetGuard> guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func<IReadOnlyList<int>> udpPorts, Func<IReadOnlyList<int>> tcpPorts)
public TaskManager(Func<BudgetGuard> guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func<IReadOnlyList<int>> udpPorts, Func<IReadOnlyList<int>> tcpPorts, TrustAnchor? trust = null)
{
_trust = trust ?? TrustAnchor.None;
_guardFactory = guardFactory;
_streams = streams;
_env = env;
@@ -52,6 +55,9 @@ public sealed class TaskManager
/// <summary>点对点握手时查找 Probe 的公钥(SPKI)。</summary>
public byte[]? LookupPeerKey(string peerId) => _peers.TryGetValue(peerId, out byte[]? key) ? key : null;
/// <summary>名册里全部 Probe 的公钥:敲门关卡用它们派生配对密钥。</summary>
public IReadOnlyList<byte[]> PeerKeys() => [.. _peers.Values];
/// <summary>一个新的 Probe 会话开始:为它登记全部线路任务的流并启动监听方积木,返回要下发给它的线路任务集。</summary>
public LineTaskSet OpenSession(ProbeSession probe)
{
@@ -95,6 +101,8 @@ public sealed class TaskManager
_ = ct;
var result = new StateSyncResult { AppliedVersion = version };
// 固定了操作员创世的节点:面板带来的新版本操作员集合,从固定的创世验过才采用(名册本身由面板决定)
_trust.Update(set.TrustChain);
var peers = new Dictionary<string, byte[]>(StringComparer.Ordinal);
foreach (PeerSpec peer in set.Peers)
{
+5 -2
View File
@@ -56,6 +56,7 @@
- JSON-RPC 2.0;方法名 `领域.动作`,`fleet.*` 保留给框架,产品用自己的前缀(MoShiro 是 `moshiro.*`)。
- 二进制帧 `u8 channel | 正文`:`0`–`15` 框架保留,产品从 `16` 开始。
- 错误:`error.data.code` 为 snake_case,框架的以 `fleet_` 开头,产品可以扩展。
- **方法必须声明**:每个方法在声明表(`MethodTable`)里写明发起方、请求还是通知、哪些角色可以参与;不存在双向方法,同一个方法名在一个平面里只有一个发起方。`RpcRouter` 在注册、分发和发送三处核对,违规回 `fleet_protocol_violation`,超过上限断开连接。详见 [`docs/security.md`](../../docs/security.md) 第 8 节。
- 与传输无关:同一套路由跑在 wss 上,也跑在 TLS 帧(`u32 长度 | u8 类型 | 正文`)上。
| 保留方法 | 方向 | 说明 |
@@ -85,11 +86,13 @@ connect(wss) → fleet.challenge → fleet.auth → fleet.hello → 状态同步
```text
节点 A(发起方,如 Probe)── TLS(按 SPKI 校验证书)──> 节点 B(监听方,如 Agent)
<── fleet.challenge ──
── fleet.auth(peer 用途,A 的私钥签名)──>
<── fleet.peer.challenge ──
── fleet.peer.auth(peer 用途,A 的私钥签名)──>
B 用服务端下发的对端公钥列表验签(经 state.sync 下发),之后复用同一套 Rpc:路由、二进制通道、发件箱
```
握手用两个方法:`fleet.peer.challenge`(监听方→拨号方,通知)和 `fleet.peer.auth`(拨号方→监听方,请求);握手完成后,两侧各自只能发起声明表里属于自己这一侧的方法。
对端公钥和允许连接的节点由产品通过期望状态下发给监听方,Fleet 只管认证和会话,拓扑由产品决定;UDP、原始 TCP 数据面由产品自己实现,Fleet 只提供会话密钥派生和首帧 MAC 的辅助函数。
## 宿主与服务端抽象
@@ -0,0 +1,43 @@
using System.Security.Cryptography;
namespace Fleet.Core;
/// <summary>
/// 节点握手时自述的「我跑的是哪个二进制」:正在运行的可执行文件的 SHA-256,以及(容器节点)宿主机更新器验过的 CI 签名清单和签名。
/// 服务端在节点第一次握手成功的那一刻把哈希绑定到这个节点,之后哈希对不上就不让它继续运行。
/// </summary>
public sealed record BinaryAttestation(string Sha256, string? Manifest, string? Signature);
/// <summary>
/// 正在运行的二进制的哈希。Linux 上读 <c>/proc/self/exe</c>:它指向进程启动时映射的那个映像,磁盘上的文件之后被换掉也不影响,
/// 所以「运行期间被替换、重启后换了别的」的情况下一次握手就会对不上。算一次,之后缓存。
/// </summary>
public static class NodeBinary
{
private static readonly Lazy<string?> s_hash = new(Compute);
/// <summary>小写十六进制;读不到可执行文件时为 null(这样的节点不做绑定)。</summary>
public static string? Sha256Hex => s_hash.Value;
/// <summary>在后台先算好,免得第一次握手等它。</summary>
public static void Prime() => _ = Task.Run(() => s_hash.Value);
private static string? Compute()
{
try
{
string? path = OperatingSystem.IsLinux() && File.Exists("/proc/self/exe") ? "/proc/self/exe" : Environment.ProcessPath;
if (path is null)
{
return null;
}
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, 1 << 16);
return Convert.ToHexStringLower(SHA256.HashData(stream));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return null;
}
}
}
@@ -13,6 +13,8 @@ public static class FleetErrors
public static readonly ErrorCode Unauthorized = new("fleet_unauthorized");
public static readonly ErrorCode Forbidden = new("fleet_forbidden");
public static readonly ErrorCode Unsupported = new("fleet_unsupported");
/// <summary>方法没有声明、方向不对、类型不对或角色不允许:对方违反了协议。</summary>
public static readonly ErrorCode ProtocolViolation = new("fleet_protocol_violation");
public static readonly ErrorCode InvalidParams = new("fleet_invalid_params");
public static readonly ErrorCode NotFound = new("fleet_not_found");
public static readonly ErrorCode Internal = new("fleet_internal");
@@ -25,4 +27,6 @@ public static class FleetErrors
public static readonly ErrorCode JoinTokenExpired = new("fleet_join_token_expired");
public static readonly ErrorCode ProofInvalid = new("fleet_proof_invalid");
public static readonly ErrorCode FrameTooLarge = new("fleet_frame_too_large");
/// <summary>节点现在跑的二进制和它第一次握手时绑定的不一致,也不在 CI 签名的更新清单里。</summary>
public static readonly ErrorCode BinaryMismatch = new("fleet_binary_mismatch");
}
+29
View File
@@ -0,0 +1,29 @@
using System.Text.Json.Serialization;
namespace Fleet.Core;
/// <summary>passkey 对一段语句签出的断言(字段都是 base64url)。信任根的格式和验证在 Fleet.Identity 的 <c>OperatorTrust</c>。</summary>
public sealed class SignedStatementDto
{
[JsonPropertyName("statement")]
public string Statement { get; set; } = "";
[JsonPropertyName("credential_id")]
public string CredentialId { get; set; } = "";
[JsonPropertyName("authenticator_data")]
public string AuthenticatorData { get; set; } = "";
[JsonPropertyName("client_data_json")]
public string ClientDataJson { get; set; } = "";
[JsonPropertyName("signature")]
public string Signature { get; set; } = "";
}
/// <summary>操作员集合的链(创世在前):节点注册时取来,从固定的创世指纹验过才采用。</summary>
public sealed class TrustBundle
{
[JsonPropertyName("chain")]
public List<SignedStatementDto> Chain { get; set; } = [];
}
@@ -2,13 +2,17 @@ using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using Fleet.Core;
using Fleet.Hosting;
using Fleet.Identity;
namespace Fleet.Enrollment.Client;
public sealed record EnrollmentResult(string NodeId, string Status, string IdentityDirectory);
[JsonSerializable(typeof(TrustBundle))]
internal sealed partial class TrustBundleContext : JsonSerializerContext;
public sealed record EnrollmentResult(string NodeId, string Status, string IdentityDirectory, bool Pinned = false);
/// <summary>
/// 用加入令牌注册(供 <c>fleet-enroll</c> 使用,也可以嵌入产品自己的安装工具):
@@ -18,7 +22,8 @@ public sealed record EnrollmentResult(string NodeId, string Status, string Ident
public static class EnrollmentClient
{
public static async Task<Result<EnrollmentResult>> EnrollAsync(
HttpClient http, Uri server, string joinToken, string name, string identityDirectory, string nodeVersion, JsonNode? productMeta, TimeProvider time, CancellationToken ct)
HttpClient http, Uri server, string joinToken, string name, string identityDirectory, string nodeVersion, JsonNode? productMeta, TimeProvider time, CancellationToken ct,
string? genesis = null, IReadOnlyList<string>? controllers = null)
{
string nodeFile = Path.Combine(identityDirectory, LoadedNodeIdentity.NodeFileName);
if (File.Exists(nodeFile))
@@ -29,9 +34,29 @@ public static class EnrollmentClient
using NodeIdentity identity = NodeIdentity.Generate();
byte[] pfx = NodeCertificate.CreateSelfSignedPfx(name, TimeSpan.FromDays(3650), time);
byte[] spki;
byte[] tlsPublicKey;
using (var certificate = NodeCertificate.LoadPfx(pfx))
{
spki = NodeCertificate.SpkiFingerprint(certificate);
tlsPublicKey = certificate.PublicKey.ExportSubjectPublicKeyInfo();
}
// 安装命令里带了创世指纹(--genesis)和属主(--controllers):先取操作员集合链,从固定的创世验起,
// 属主必须是集合里的凭证;对不上就不注册,也不把自己的密钥交出去。通过之后固定到本地 trust.json,Backend 之后改不了。
TrustBundle? bundle = null;
if (genesis is not null)
{
if (controllers is not { Count: > 0 })
{
// 每个节点都必须有属主:没有属主的节点没有人能授权它的敏感操作
return Result<EnrollmentResult>.Fail(FleetErrors.InvalidParams, "an owner is required: pass the passkey credential ids from the panel's command (--controllers)");
}
Result<TrustBundle> trusted = await FetchTrustAsync(http, server, joinToken, genesis, controllers ?? [], ct);
if (!trusted.TryGet(out bundle, out FleetError? trustError))
{
return Result<EnrollmentResult>.Fail(trustError);
}
}
string pubkey = Convert.ToBase64String(identity.PublicKeySpki);
@@ -42,6 +67,7 @@ public static class EnrollmentClient
["name"] = name,
["pubkey"] = pubkey,
["tls_spki"] = Convert.ToBase64String(spki),
["tls_pubkey"] = Convert.ToBase64String(tlsPublicKey),
["proof"] = Convert.ToBase64String(identity.Sign(SignaturePurpose.Enroll, tokenHash, pubkey)),
["node_version"] = nodeVersion,
["meta"] = productMeta,
@@ -73,10 +99,46 @@ public static class EnrollmentClient
var store = new ConfigStore(identityDirectory);
store.Write(LoadedNodeIdentity.KeyFileName, identity.ExportPkcs8());
store.Write(LoadedNodeIdentity.TlsFileName, pfx);
if (bundle is not null)
{
TrustAnchor.Pin(identityDirectory, genesis!, bundle.Chain, controllers ?? []);
}
// node.json 最后写:它存在就表示注册完整完成
store.Write(
LoadedNodeIdentity.NodeFileName,
JsonSerializer.SerializeToUtf8Bytes(new NodeFile { NodeId = nodeId, Server = server.ToString().TrimEnd('/'), TlsSpki = Convert.ToBase64String(spki) }, NodeFileContext.Default.NodeFile));
return Result<EnrollmentResult>.Ok(new EnrollmentResult(nodeId, result["status"]!.GetValue<string>(), identityDirectory));
return Result<EnrollmentResult>.Ok(new EnrollmentResult(nodeId, result["status"]!.GetValue<string>(), identityDirectory, bundle is not null));
}
private static async Task<Result<TrustBundle>> FetchTrustAsync(HttpClient http, Uri server, string joinToken, string genesis, IReadOnlyList<string> controllers, CancellationToken ct)
{
static Result<TrustBundle> Refuse(string reason) => Result<TrustBundle>.Fail(FleetErrors.ProofInvalid, "refusing to enroll: " + reason);
using var request = new StringContent(new JsonObject { ["join_token"] = joinToken }.ToJsonString(), Encoding.UTF8, "application/json");
using HttpResponseMessage response = await http.PostAsync(new Uri(server, "/api/agent/v1/enroll/trust"), request, ct);
if (!response.IsSuccessStatusCode)
{
return Result<TrustBundle>.Fail(FleetErrors.JoinTokenInvalid, $"{(int)response.StatusCode}: the panel would not give the trust chain");
}
TrustBundle? bundle = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(ct), TrustBundleContext.Default.TrustBundle);
if (bundle is null || bundle.Chain.Count == 0)
{
return Refuse("the panel has no operator trust root set up");
}
OperatorSet? current = OperatorTrust.VerifyChain(genesis, [.. bundle.Chain.Select(c => c.ToSigned())]);
if (current is null)
{
return Refuse("the panel's operator set does not match the pinned genesis fingerprint (wrong panel, or the panel was tampered with)");
}
if (controllers.Any(c => current.Keys.All(k => k.CredentialId != c)))
{
return Refuse("an owner passkey in the install command is not in the operator set");
}
return Result<TrustBundle>.Ok(bundle);
}
}
+45 -1
View File
@@ -126,7 +126,8 @@ public static class PlatformCheck
/// <summary>处理 Ctrl+C 和 SIGTERM:取消令牌,让各个模块有序退出。</summary>
public static class ServiceShell
{
public static async Task RunAsync(Func<CancellationToken, Task> run, INodeLog log)
/// <param name="restart">每天定时有序退出一次(见 <see cref="RestartController"/>),重新拉起由服务管理器负责。</param>
public static async Task RunAsync(Func<CancellationToken, Task> run, INodeLog log, RestartController? restart = null, TimeProvider? time = null)
{
using var cts = new CancellationTokenSource();
using PosixSignalRegistration term = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context =>
@@ -156,6 +157,7 @@ public static class ServiceShell
cts.Cancel();
});
Task trimmer = TrimHeapAsync(cts.Token);
Task scheduled = restart is null ? Task.CompletedTask : WaitForRestartAsync(restart, time ?? TimeProvider.System, log, cts);
try
{
await run(cts.Token);
@@ -168,6 +170,38 @@ public static class ServiceShell
{
await cts.CancelAsync();
await trimmer;
await scheduled;
}
}
private static async Task WaitForRestartAsync(RestartController restart, TimeProvider time, INodeLog log, CancellationTokenSource cts)
{
while (!cts.IsCancellationRequested)
{
// 先取令牌、再取设置:中间被改动的话令牌会被取消,下一轮重新排
CancellationToken changed = restart.ChangeToken;
RestartSchedule? schedule = restart.Current;
using CancellationTokenSource wait = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, changed);
try
{
if (schedule is null)
{
await Task.Delay(Timeout.Infinite, wait.Token);
continue;
}
DateTimeOffset next = schedule.NextAfter(time.GetUtcNow() + RestartSchedule.MinimumUptime);
log.Info("restart scheduled", ("at", next.ToString("O", System.Globalization.CultureInfo.InvariantCulture)), ("source", restart.IsLocal ? "local" : "panel"));
await Task.Delay(next - time.GetUtcNow(), time, wait.Token);
}
catch (OperationCanceledException)
{
continue; // 进程在退出,或者设置被改了:循环条件和下一轮会处理
}
log.Info("stopping", ("result", "scheduled_restart"));
await cts.CancelAsync();
return;
}
}
@@ -297,6 +331,15 @@ public sealed class LoadedNodeIdentity : IDisposable
public X509Certificate2 Certificate { get; }
/// <summary>
/// 对等面的签名身份:TLS 证书的那把密钥,和连服务端用的 <see cref="Identity"/> 是两把独立的密钥,
/// 各自只用于自己的用途(一把泄露不影响另一把)。
/// </summary>
public NodeIdentity PeerIdentity => _peerIdentity ??= NodeIdentity.FromPkcs8(
(Certificate.GetECDsaPrivateKey() ?? throw new InvalidDataException("the node certificate has no ECDSA private key")).ExportPkcs8PrivateKey());
private NodeIdentity? _peerIdentity;
public static LoadedNodeIdentity Load(string directory)
{
if (!System.IO.File.Exists(Path.Combine(directory, KeyFileName)) || !System.IO.File.Exists(Path.Combine(directory, TlsFileName)))
@@ -313,6 +356,7 @@ public sealed class LoadedNodeIdentity : IDisposable
public void Dispose()
{
Identity.Dispose();
_peerIdentity?.Dispose();
Certificate.Dispose();
}
}
@@ -0,0 +1,48 @@
using Fleet.Core;
namespace Fleet.Hosting;
/// <summary>
/// 节点自己要求有属主(passkey):Agent 和 Probe 共用这一份,启动时检查。
/// <para>
/// 属主是安装命令带来、注册时固定在 <c>trust.json</c> 里的 passkey 凭证 id(必须是操作员集合里的凭证)。节点据此知道自己绑定在哪个 passkey 上,
/// 才敢把更多的接口和操作交给它(extension 会话、加密内容都只认属主的 passkey);没有属主的节点没有人能授权它的敏感能力,所以不运行。
/// 这是节点本地的强制,不依赖面板:面板被攻破也改不了节点已经固定的属主。
/// </para>
/// </summary>
public static class OwnerGuard
{
/// <summary>设为 <c>1</c> 放行没有属主的节点(只给没有信任根的老面板)。</summary>
public const string AllowEnvironmentVariable = "MOSHIRO_ALLOW_NO_OWNER";
/// <summary>没问题返回 null;否则是要告诉运维的原因。</summary>
public static string? Problem(TrustAnchor trust, bool require)
{
if (!require || Environment.GetEnvironmentVariable(AllowEnvironmentVariable) == "1")
{
return null;
}
if (!trust.Pinned)
{
return "this node has no owner: it was not enrolled with a trust root and owner passkeys; enroll it again with the command from the panel (or set " + AllowEnvironmentVariable + "=1 for a panel without a trust root)";
}
return trust.Controllers.Count == 0
? "none of this node's owner passkeys is in the operator set any more; enroll it again with a command from the panel"
: null;
}
/// <summary>读身份目录里固定的信任根,检查属主;不行就记一条错误并返回 false(调用方应当退出)。</summary>
public static bool Check(string identityDirectory, bool require, INodeLog log)
{
TrustAnchor trust = TrustAnchor.Load(identityDirectory, log);
if (Problem(trust, require) is { } problem)
{
log.Error("startup failed", ("err", problem));
return false;
}
return true;
}
}
@@ -0,0 +1,73 @@
namespace Fleet.Hosting;
/// <summary>
/// 当前生效的定时重启:节点本地的设置优先;本地没有写时用面板下发的,面板改了之后立即重新排下一次,不需要重启进程。
/// </summary>
public sealed class RestartController : IDisposable
{
private readonly RestartSchedule? _local;
private readonly object _gate = new();
private RestartSchedule? _remote;
private CancellationTokenSource _changed = new();
public RestartController(RestartSchedule? local = null)
{
_local = local;
}
/// <summary>本地有设置时为 true:面板下发的不生效。</summary>
public bool IsLocal => _local is not null;
public RestartSchedule? Current
{
get
{
lock (_gate)
{
return _local ?? _remote;
}
}
}
/// <summary>当前设置被改动时会被取消的令牌;先取令牌、再取 <see cref="Current"/>,就不会漏掉中间的改动。</summary>
public CancellationToken ChangeToken
{
get
{
lock (_gate)
{
return _changed.Token;
}
}
}
/// <summary>面板下发的设置;为空表示面板关闭了定时重启。返回 false 表示文本不合法(节点忽略它)。</summary>
public bool SetRemote(string? text)
{
RestartSchedule? schedule = null;
if (!string.IsNullOrWhiteSpace(text) && !RestartSchedule.TryParse(text, out schedule))
{
return false;
}
CancellationTokenSource previous;
lock (_gate)
{
_remote = schedule;
previous = _changed;
_changed = new CancellationTokenSource();
}
previous.Cancel();
previous.Dispose();
return true;
}
public void Dispose()
{
lock (_gate)
{
_changed.Dispose();
}
}
}
@@ -0,0 +1,105 @@
using System.Globalization;
namespace Fleet.Hosting;
/// <summary>
/// 每天固定时刻有序退出一次,由 systemd、容器引擎或 procd 的「总是重启」拉起。写法 <c>HH:mm</c>(按 UTC)、
/// <c>HH:mm±hh:mm</c> 或 <c>HH:mm</c> 加 <c>Z</c>:容器里没有时区数据库,所以偏移要写明,例如 <c>04:00+08:00</c>。
/// 进程自己退出,不需要任何执行外部命令的能力。
/// </summary>
public sealed class RestartSchedule
{
/// <summary>环境变量,设置了就覆盖配置文件里的值。</summary>
public const string EnvironmentVariable = "MOSHIRO_RESTART_AT";
/// <summary>刚启动的一段时间内不会触发:重启之后不会因为时刻还没过去而再重启一次,也避免重启风暴。</summary>
public static readonly TimeSpan MinimumUptime = TimeSpan.FromMinutes(10);
private readonly TimeSpan _time;
private readonly TimeSpan _offset;
private RestartSchedule(TimeSpan time, TimeSpan offset)
{
_time = time;
_offset = offset;
}
public static bool TryParse(string? text, out RestartSchedule? schedule)
{
schedule = null;
string value = text?.Trim() ?? "";
// HH:mm,后面可以跟 Z 或 ±hh:mm
if (value.Length < 5 || value[2] != ':' ||
!TryTwoDigits(value, 0, out int hour) || !TryTwoDigits(value, 3, out int minute) || hour > 23 || minute > 59)
{
return false;
}
TimeSpan offset = TimeSpan.Zero;
string rest = value[5..];
if (rest.Length > 0 && rest != "Z")
{
if (rest.Length != 6 || rest[0] is not ('+' or '-') || rest[3] != ':' ||
!TryTwoDigits(rest, 1, out int offsetHours) || !TryTwoDigits(rest, 4, out int offsetMinutes) || offsetMinutes > 59)
{
return false;
}
offset = new TimeSpan(offsetHours, offsetMinutes, 0);
if (offset > TimeSpan.FromHours(14))
{
return false;
}
if (rest[0] == '-')
{
offset = -offset;
}
}
schedule = new RestartSchedule(new TimeSpan(hour, minute, 0), offset);
return true;
}
private static bool TryTwoDigits(string text, int start, out int value)
{
value = 0;
if (!char.IsAsciiDigit(text[start]) || !char.IsAsciiDigit(text[start + 1]))
{
return false;
}
value = (text[start] - '0') * 10 + (text[start + 1] - '0');
return true;
}
/// <summary>
/// 环境变量优先,其次配置文件的值;都没有就不定时重启。写错了抛 <see cref="InvalidOperationException"/>,
/// 启动时就能看到,而不是悄悄不生效。
/// </summary>
public static RestartSchedule? Resolve(string? configured)
{
string? text = Environment.GetEnvironmentVariable(EnvironmentVariable);
if (string.IsNullOrWhiteSpace(text))
{
text = configured;
}
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
return TryParse(text, out RestartSchedule? schedule)
? schedule
: throw new InvalidOperationException($"restart_at must look like 04:00, 04:00Z or 04:00+08:00 (got '{text}')");
}
/// <summary>严格晚于 <paramref name="now"/> 的下一个触发时刻。</summary>
public DateTimeOffset NextAfter(DateTimeOffset now)
{
DateTimeOffset local = now.ToOffset(_offset);
DateTimeOffset candidate = new DateTimeOffset(local.Year, local.Month, local.Day, 0, 0, 0, _offset) + _time;
return candidate <= local ? candidate.AddDays(1) : candidate;
}
}
@@ -0,0 +1,170 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Fleet.Core;
using Fleet.Identity;
namespace Fleet.Hosting;
/// <summary><c>trust.json</c>:安装时固定的创世指纹和属主,加见过的最新操作员集合链。</summary>
public sealed class TrustFile
{
[JsonPropertyName("genesis")]
public string Genesis { get; set; } = "";
[JsonPropertyName("chain")]
public List<SignedStatementDto> Chain { get; set; } = [];
/// <summary>属主:允许授权本节点 extension 会话(web ssh 等)的 passkey 凭证 id,安装时固定,必须是操作员集合里的凭证。</summary>
[JsonPropertyName("controllers")]
public List<string> Controllers { get; set; } = [];
}
[JsonSerializable(typeof(TrustFile))]
public sealed partial class TrustFileContext : JsonSerializerContext;
/// <summary>
/// 节点的信任根:安装命令里带来的创世指纹和属主,在注册时固定到本地,之后 Backend 改不了。
/// 操作员集合的新版本随期望状态下发,节点只接受从固定的创世出发、签名完整、版本更新的链。
/// 属主只是被固定和保存:节点现在没有 extension,等做 extension 时由节点本地强制(见 docs/access.md 第 8 节)。
/// </summary>
public sealed class TrustAnchor : IServerVerifier
{
public const string FileName = "trust.json";
private readonly ConfigStore? _store;
private readonly INodeLog _log;
private readonly object _gate = new();
private readonly TrustFile? _file;
private OperatorSet? _current;
private TrustAnchor(ConfigStore? store, TrustFile? file, OperatorSet? current, INodeLog log)
{
_store = store;
_file = file;
_current = current;
_log = log;
}
/// <summary>没有固定的创世(旧节点,或没有指纹的安装)。</summary>
public static TrustAnchor None { get; } = new(null, null, null, NullNodeLog.Instance);
public bool Pinned => _file is not null;
public string? Genesis => _file?.Genesis;
/// <summary>当前生效的操作员集合版本;没有固定时为 0。</summary>
public int Version
{
get
{
lock (_gate)
{
return _current?.Version ?? 0;
}
}
}
/// <summary>当前操作员集合里登记了服务器密钥:服务器必须在认证挑战里证明自己,链路用应用层加密。</summary>
public bool Required
{
get
{
lock (_gate)
{
return _current?.Servers is { Count: > 0 };
}
}
}
/// <summary>服务器的签名是不是当前操作员集合里登记的某把服务器密钥签的。</summary>
public bool Verify(string nonce, ReadOnlySpan<byte> serverEphemeralSpki, ReadOnlySpan<byte> signature)
{
IReadOnlyList<byte[]> servers;
lock (_gate)
{
servers = _current?.Servers ?? [];
}
foreach (byte[] server in servers)
{
if (ServerHello.Verify(server, nonce, serverEphemeralSpki, signature))
{
return true;
}
}
return false;
}
/// <summary>安装时固定的属主里,仍然在当前操作员集合里的那些(从集合里去掉的凭证不再有效)。</summary>
public IReadOnlyList<string> Controllers
{
get
{
lock (_gate)
{
return _file is null || _current is null ? [] : [.. _file.Controllers.Where(c => _current.Keys.Any(k => k.CredentialId == c))];
}
}
}
public static TrustAnchor Load(string identityDirectory, INodeLog log)
{
string path = Path.Combine(identityDirectory, FileName);
if (!File.Exists(path))
{
return new TrustAnchor(new ConfigStore(identityDirectory), null, null, log);
}
TrustFile file = NodeConfig.Load(path, TrustFileContext.Default.TrustFile);
OperatorSet? current = OperatorTrust.VerifyChain(file.Genesis, [.. file.Chain.Select(c => c.ToSigned())]);
if (current is null)
{
// 固定的创世和存下来的链对不上:不能当成「没有固定」放过,直接让节点起不来比悄悄失去保护好
throw new InvalidDataException($"{path} does not verify against its pinned genesis; remove the node's identity and enroll again");
}
return new TrustAnchor(new ConfigStore(identityDirectory), file, current, log);
}
/// <summary>注册时写下:固定的创世、验证过的链、属主。</summary>
public static void Pin(string identityDirectory, string genesis, IReadOnlyList<SignedStatementDto> chain, IReadOnlyList<string> controllers) =>
new ConfigStore(identityDirectory).Write(FileName, JsonSerializer.SerializeToUtf8Bytes(new TrustFile { Genesis = genesis, Chain = [.. chain], Controllers = [.. controllers] }, TrustFileContext.Default.TrustFile));
/// <summary>期望状态里带来的链:从固定的创世验起,比现有的新才采用并保存。返回当前链的版本(没有固定时为 0)。</summary>
public int Update(IReadOnlyList<SignedStatementDto>? chain)
{
lock (_gate)
{
if (_file is null || chain is not { Count: > 0 })
{
return _current?.Version ?? 0;
}
OperatorSet? candidate = OperatorTrust.VerifyChain(_file.Genesis, [.. chain.Select(c => c.ToSigned())]);
if (candidate is null)
{
_log.Warn("the operator set from the panel does not verify against the pinned genesis; ignoring it");
return _current!.Version;
}
if (candidate.Version > _current!.Version)
{
_file.Chain = [.. chain];
_current = candidate;
try
{
_store?.Write(FileName, JsonSerializer.SerializeToUtf8Bytes(_file, TrustFileContext.Default.TrustFile));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_log.Warn("could not save trust.json", ("err", ex.Message));
}
_log.Info("operator set updated", ("version", candidate.Version));
}
return _current.Version;
}
}
}
+80
View File
@@ -0,0 +1,80 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
namespace Fleet.Identity;
/// <summary>
/// 预握手敲门:TCP 连接建立之后、TLS 握手之前,拨号方先发 32 字节 <c>nonce(16) | tag(16)</c>,
/// <c>tag = HMAC-SHA256(K_pair, 时间窗 | nonce)</c> 截断到 16 字节。监听方核对不过就什么都不写、直接断开,
/// 所以扫描器拿不到证书、TLS 参数和 JARM 指纹。<c>K_pair</c> 只有名册里的成员算得出:双方身份密钥做静态 ECDH,再经 HKDF 加用途标签派生。
/// TCP 的 SYN-ACK 由内核发出,端口在扫描器眼里仍是「开着但不说话」,不是「没监听」。
/// </summary>
public static class Knock
{
public const int PacketBytes = 32;
public const int NonceBytes = 16;
public const int TagBytes = 16;
/// <summary>时间窗长度;接受前后各一个窗,容忍时钟偏差。</summary>
public const int WindowSeconds = 30;
private const string Label = "moshiro-knock-v1";
/// <summary>两把身份密钥的配对密钥。info 里的两个公钥按字节序排列,所以两边算出的是同一个值。</summary>
public static byte[] DeriveKey(NodeIdentity own, ReadOnlySpan<byte> peerSpki)
{
byte[] secret = own.AgreeWith(peerSpki);
try
{
ReadOnlySpan<byte> mine = own.PublicKeySpki;
bool mineFirst = mine.SequenceCompareTo(peerSpki) <= 0;
byte[] info = [.. Encoding.UTF8.GetBytes(Label), .. (mineFirst ? mine : peerSpki), .. (mineFirst ? peerSpki : mine)];
return HKDF.DeriveKey(HashAlgorithmName.SHA256, secret, 32, null, info);
}
finally
{
CryptographicOperations.ZeroMemory(secret);
}
}
public static byte[] Build(ReadOnlySpan<byte> key, long nowMs)
{
byte[] packet = new byte[PacketBytes];
RandomNumberGenerator.Fill(packet.AsSpan(0, NonceBytes));
Tag(key, WindowOf(nowMs), packet.AsSpan(0, NonceBytes), packet.AsSpan(NonceBytes));
return packet;
}
/// <summary>tag 在前一个、当前、后一个时间窗里有一个对得上就通过。不管重放(重放由 <see cref="KnockGate"/> 按 nonce 去重)。</summary>
public static bool Check(ReadOnlySpan<byte> key, ReadOnlySpan<byte> packet, long nowMs)
{
if (packet.Length != PacketBytes)
{
return false;
}
long window = WindowOf(nowMs);
Span<byte> expected = stackalloc byte[TagBytes];
bool ok = false;
for (long w = window - 1; w <= window + 1; w++)
{
Tag(key, w, packet[..NonceBytes], expected);
ok |= CryptographicOperations.FixedTimeEquals(expected, packet[NonceBytes..]);
}
return ok;
}
private static long WindowOf(long nowMs) => nowMs / 1000 / WindowSeconds;
private static void Tag(ReadOnlySpan<byte> key, long window, ReadOnlySpan<byte> nonce, Span<byte> destination)
{
Span<byte> data = stackalloc byte[8 + NonceBytes];
BinaryPrimitives.WriteInt64BigEndian(data, window);
nonce.CopyTo(data[8..]);
Span<byte> full = stackalloc byte[32];
HMACSHA256.HashData(key, data, full);
full[..TagBytes].CopyTo(destination);
}
}
@@ -0,0 +1,113 @@
using System.Collections.Concurrent;
using System.Net;
using System.Security.Cryptography;
namespace Fleet.Identity;
/// <summary>
/// 监听方的敲门关卡:新连接的前 32 字节必须是名册里某个成员的敲门包,否则不写任何字节、直接断开。
/// 每个来源地址限速(陌生人用随机数据也要消耗 HMAC 计算),nonce 在时间窗内去重(防重放)。
/// 名册由调用方提供(对端公钥列表);配对密钥按公钥缓存,名册变了自然只算新的。
/// </summary>
public sealed class KnockGate
{
private static readonly TimeSpan s_readTimeout = TimeSpan.FromSeconds(3);
private readonly NodeIdentity _own;
private readonly Func<IReadOnlyList<byte[]>> _peers;
private readonly TimeProvider _time;
private readonly int _attemptsPerPeriod;
private readonly long _periodMs;
private readonly ConcurrentDictionary<string, byte[]> _keys = new();
private readonly object _gate = new();
private readonly Dictionary<string, long> _seenNonces = [];
private readonly Dictionary<string, (long WindowStartMs, int Count)> _sources = [];
private long _lastSweepMs;
public KnockGate(NodeIdentity own, Func<IReadOnlyList<byte[]>> peers, TimeProvider time, int attemptsPerPeriod = 300, TimeSpan? period = null)
{
_own = own;
_peers = peers;
_time = time;
_attemptsPerPeriod = attemptsPerPeriod;
_periodMs = (long)(period ?? TimeSpan.FromSeconds(10)).TotalMilliseconds;
}
/// <summary>读 32 字节并核对。通过返回 true,流从敲门包之后继续(TLS 或明文首帧);不通过返回 false,调用方直接关闭连接,不要写任何东西。</summary>
public async ValueTask<bool> AdmitAsync(Stream stream, IPAddress? source, CancellationToken ct)
{
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
if (!TryCountSource(source, now))
{
return false;
}
byte[] packet = new byte[Knock.PacketBytes];
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(s_readTimeout);
try
{
await stream.ReadExactlyAsync(packet, timeout.Token);
}
catch (Exception ex) when (ex is EndOfStreamException or OperationCanceledException or IOException or ObjectDisposedException)
{
return false;
}
return Accept(packet, now);
}
/// <summary>核对一个敲门包(不读流):名册里有成员的密钥对得上,并且 nonce 没用过。</summary>
public bool Accept(ReadOnlySpan<byte> packet, long nowMs)
{
bool matched = false;
foreach (byte[] peer in _peers())
{
byte[] key = _keys.GetOrAdd(Convert.ToBase64String(peer), _ => Knock.DeriveKey(_own, peer));
matched |= Knock.Check(key, packet, nowMs);
}
if (!matched)
{
return false;
}
string nonce = Convert.ToHexString(packet[..Knock.NonceBytes]);
lock (_gate)
{
Sweep(nowMs);
return _seenNonces.TryAdd(nonce, nowMs + (Knock.WindowSeconds * 3 * 1000L));
}
}
private bool TryCountSource(IPAddress? source, long nowMs)
{
string key = source is null ? "" : source.IsIPv4MappedToIPv6 ? source.MapToIPv4().ToString() : source.ToString();
lock (_gate)
{
Sweep(nowMs);
(long start, int count) = _sources.TryGetValue(key, out var entry) && nowMs - entry.WindowStartMs < _periodMs ? entry : (nowMs, 0);
_sources[key] = (start, count + 1);
return count < _attemptsPerPeriod;
}
}
private void Sweep(long nowMs)
{
if (nowMs - _lastSweepMs < 5000 && _seenNonces.Count < 4096 && _sources.Count < 4096)
{
return;
}
_lastSweepMs = nowMs;
foreach (string nonce in _seenNonces.Where(p => p.Value <= nowMs).Select(p => p.Key).ToList())
{
_seenNonces.Remove(nonce);
}
foreach (string source in _sources.Where(p => nowMs - p.Value.WindowStartMs >= _periodMs).Select(p => p.Key).ToList())
{
_sources.Remove(source);
}
}
}
@@ -22,7 +22,7 @@ public static class NodeCertificate
return certificate.Export(X509ContentType.Pfx);
}
public static X509Certificate2 LoadPfx(byte[] pfx) => X509CertificateLoader.LoadPkcs12(pfx, null);
public static X509Certificate2 LoadPfx(byte[] pfx) => X509CertificateLoader.LoadPkcs12(pfx, null, X509KeyStorageFlags.Exportable);
/// <summary>证书公钥(SPKI)的 SHA-256:对端用它来固定这张证书。</summary>
public static byte[] SpkiFingerprint(X509Certificate2 certificate) =>
@@ -7,6 +7,9 @@ namespace Fleet.Identity;
public static class SignaturePurpose
{
public const string Auth = "auth";
/// <summary>安全链路的认证:签名同时覆盖服务器和节点的临时公钥,把这次认证和这条加密链路绑在一起。</summary>
public const string AuthSecure = "auth2";
public const string Peer = "peer";
public const string Enroll = "enroll";
}
@@ -41,6 +44,16 @@ public sealed class NodeIdentity : IDisposable
public byte[] ExportPkcs8() => _key.ExportPkcs8PrivateKey();
/// <summary>与对端公钥(SPKI)做静态 ECDH,返回原始共享秘密。只用于派生对称密钥(敲门),必须再经 KDF,不能直接当密钥用。</summary>
public byte[] AgreeWith(ReadOnlySpan<byte> peerSpki)
{
using ECDiffieHellman own = ECDiffieHellman.Create();
own.ImportPkcs8PrivateKey(_key.ExportPkcs8PrivateKey(), out _);
using ECDiffieHellman peer = ECDiffieHellman.Create();
peer.ImportSubjectPublicKeyInfo(peerSpki, out _);
return own.DeriveRawSecretAgreement(peer.PublicKey);
}
public byte[] Sign(string purpose, params string[] fields) =>
_key.SignData(Encoding.UTF8.GetBytes(SignatureString.Build(purpose, fields)), HashAlgorithmName.SHA256,
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
@@ -0,0 +1,171 @@
using System.Buffers.Text;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace Fleet.Identity;
/// <summary>passkey(WebAuthn)对一段文本签出的断言:<c>challenge = SHA-256(Statement 的 UTF-8)</c>。四个字段都是 base64url。</summary>
public sealed record SignedStatement(string Statement, string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature);
/// <summary>一个操作员凭证:passkey 的凭证 id 和公钥(SPKI)。</summary>
public sealed record OperatorKey(string CredentialId, byte[] PublicKeySpki);
/// <summary>
/// 操作员集合的一个版本。<c>RpId</c> 和 <c>Origins</c> 也在里面:节点验 WebAuthn 断言要用,而它们被操作员的签名覆盖,Backend 改不了。
/// 创世版本(1)由它自己的某把凭证签名,指纹(<see cref="OperatorTrust.Fingerprint"/>)在安装时固定到节点;之后每个版本由上一版里的凭证签名。
/// </summary>
/// <param name="Servers">服务器密钥(SPKI):操作员授权的 Backend 签名密钥。集合里登记了服务器密钥之后,节点要求服务器在认证挑战里用它签名,
/// 并在这条链路上用应用层加密,所以 DNS 或证书层面的冒充拿不出这把密钥就骗不了节点。空 = 还没有启用。</param>
public sealed record OperatorSet(int Version, string Previous, string RpId, IReadOnlyList<string> Origins, IReadOnlyList<OperatorKey> Keys, IReadOnlyList<byte[]>? Servers = null);
/// <summary>
/// 信任根:操作员(passkey)→ 操作员集合 → 节点本地固定的属主。设计见 docs/access.md 第 7 节。
/// 安装命令里带着创世指纹和属主(passkey 的凭证 id):节点把它们固定在本地,之后只认从创世出发、由操作员 passkey 签过名的集合版本,
/// 属主必须是集合里的凭证。Backend 能转发集合,但签不出新的版本,也改不了节点已经固定的东西。
/// 全是纯函数,只用 BCL,节点和 Backend 共用。语句是 <c>key=value</c> 的文本,第一行是带版本的标题;签名覆盖整段文本。
/// </summary>
public static class OperatorTrust
{
public const string SetHeader = "moshiro-opset-v1";
public const string Genesis = "0";
public static string Encode(OperatorSet set)
{
var text = new StringBuilder();
text.Append(SetHeader).Append('\n')
.Append("version=").Append(set.Version.ToString(CultureInfo.InvariantCulture)).Append('\n')
.Append("prev=").Append(set.Previous).Append('\n')
.Append("rp=").Append(set.RpId).Append('\n')
.Append("origins=").Append(string.Join(',', set.Origins));
foreach (OperatorKey key in set.Keys)
{
text.Append("\nkey=").Append(key.CredentialId).Append(':').Append(Convert.ToBase64String(key.PublicKeySpki));
}
foreach (byte[] server in set.Servers ?? [])
{
text.Append("\nserver=").Append(Convert.ToBase64String(server));
}
return text.ToString();
}
/// <summary>严格解析:多一行、少一行、顺序不对都算坏。</summary>
public static OperatorSet? ParseSet(string text)
{
string[] lines = text.Split('\n');
if (lines.Length < 6 || lines[0] != SetHeader || !TryValue(lines[1], "version", out string version) || !int.TryParse(version, NumberStyles.None, CultureInfo.InvariantCulture, out int number) || number < 1
|| !TryValue(lines[2], "prev", out string previous) || !TryValue(lines[3], "rp", out string rp) || rp.Length == 0 || !TryValue(lines[4], "origins", out string origins) || origins.Length == 0)
{
return null;
}
var keys = new List<OperatorKey>();
var servers = new List<byte[]>();
foreach (string line in lines.Skip(5))
{
if (servers.Count == 0 && TryValue(line, "key", out string value) && value.IndexOf(':', StringComparison.Ordinal) is > 0 and int split && TryBase64(value[(split + 1)..], out byte[] spki))
{
keys.Add(new OperatorKey(value[..split], spki));
}
else if (TryValue(line, "server", out string serverText) && TryBase64(serverText, out byte[] serverKey))
{
servers.Add(serverKey);
}
else
{
return null;
}
}
return keys.Count == 0 || (number == 1) != (previous == Genesis) ? null : new OperatorSet(number, previous, rp, origins.Split(','), keys, servers);
}
/// <summary>一段语句的指纹(SHA-256,小写十六进制)。创世版本的指纹就是安装命令里固定的那个。</summary>
public static string Fingerprint(string statement) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(statement)));
/// <summary>
/// 验证操作员集合的链:第一项是创世版本(指纹等于 <paramref name="pinnedGenesis"/>,自签),其后每一项由上一版里的凭证签名、版本加一、<c>prev</c> 指向上一版的指纹、域名不变。
/// 返回最后一版;不对返回 null。
/// </summary>
public static OperatorSet? VerifyChain(string pinnedGenesis, IReadOnlyList<SignedStatement> chain)
{
if (chain.Count == 0 || !string.Equals(Fingerprint(chain[0].Statement), pinnedGenesis, StringComparison.OrdinalIgnoreCase))
{
return null;
}
OperatorSet? current = null;
string? previousFingerprint = null;
foreach (SignedStatement link in chain)
{
OperatorSet? next = ParseSet(link.Statement);
if (next is null)
{
return null;
}
if (current is null)
{
if (next.Version != 1 || !VerifyOperator(link, next))
{
return null;
}
}
else if (next.Version != current.Version + 1 || next.Previous != previousFingerprint || next.RpId != current.RpId || !VerifyOperator(link, current))
{
return null;
}
current = next;
previousFingerprint = Fingerprint(link.Statement);
}
return current;
}
/// <summary>断言是不是这个集合里某把凭证对 <c>Statement</c> 签的(要求用户验证;挑战是语句的 SHA-256)。</summary>
public static bool VerifyOperator(SignedStatement signed, OperatorSet set)
{
OperatorKey? key = set.Keys.FirstOrDefault(k => k.CredentialId == signed.CredentialId);
if (key is null || !TryDecode(signed.AuthenticatorData, out byte[] authData) || !TryDecode(signed.ClientDataJson, out byte[] clientData) || !TryDecode(signed.Signature, out byte[] signature))
{
return false;
}
return WebAuthnAssertion.Verify(key.PublicKeySpki, authData, clientData, signature, set.RpId, SHA256.HashData(Encoding.UTF8.GetBytes(signed.Statement)), set.Origins.Contains, requireUserVerification: true, out _);
}
private static bool TryValue(string line, string key, out string value)
{
value = "";
if (!line.StartsWith(key + "=", StringComparison.Ordinal))
{
return false;
}
value = line[(key.Length + 1)..];
return true;
}
private static bool TryBase64(string text, out byte[] data)
{
try
{
data = Convert.FromBase64String(text);
return data.Length > 0;
}
catch (FormatException)
{
data = [];
return false;
}
}
private static bool TryDecode(string text, out byte[] data)
{
data = [];
return Base64Url.IsValid(text) && (data = Base64Url.DecodeFromChars(text)).Length > 0;
}
}
@@ -0,0 +1,173 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
namespace Fleet.Identity;
/// <summary>
/// 节点这一侧用来确认「对面确实是我的操作员授权过的 Backend」的校验器:<c>Required</c> 为真(当前操作员集合里登记了服务器密钥)
/// 时,服务器的挑战必须带着对得上的签名,否则节点不继续认证、也不交出任何东西。
/// </summary>
public interface IServerVerifier
{
bool Required { get; }
/// <summary>服务器用它的服务器密钥对「挑战随机数加它这次连接的临时公钥」的签名。</summary>
bool Verify(string nonce, ReadOnlySpan<byte> serverEphemeralSpki, ReadOnlySpan<byte> signature);
}
/// <summary>服务器在挑战里给出的身份证明:服务器密钥(操作员签进集合)对「随机数加临时公钥」签名。纯函数。</summary>
public static class ServerHello
{
/// <summary>被签名的内容;服务器那一侧只拿到这个、用自己的密钥签(服务器密钥不一定是 BCL 的 ECDsa,可以在别处)。</summary>
public static byte[] Payload(string nonce, ReadOnlySpan<byte> serverEphemeralSpki) =>
Encoding.UTF8.GetBytes("fleet|v1|server|" + nonce + "|" + Convert.ToBase64String(serverEphemeralSpki));
public static byte[] Sign(ECDsa serverKey, string nonce, ReadOnlySpan<byte> serverEphemeralSpki) =>
serverKey.SignData(Payload(nonce, serverEphemeralSpki), HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
public static bool Verify(ReadOnlySpan<byte> serverKeySpki, string nonce, ReadOnlySpan<byte> serverEphemeralSpki, ReadOnlySpan<byte> signature)
{
try
{
using ECDsa key = ECDsa.Create();
key.ImportSubjectPublicKeyInfo(serverKeySpki, out _);
return key.VerifyData(Payload(nonce, serverEphemeralSpki), signature, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
}
catch (CryptographicException)
{
return false;
}
}
}
/// <summary>
/// 节点和 Backend 之间控制链路的应用层加密:两边的临时 ECDH 密钥协商出会话密钥,每个方向一把,AES-256-GCM,每帧带计数器。
/// 所以即使 TLS 在中间被终止(Cloudflare、被冒充的证书),中间人没有服务器密钥,就拿不到明文,也改不了任何一帧。
/// 帧格式:<c>计数器(8,大端) ‖ 密文 ‖ 标签(16)</c>;明文是 <c>帧类型(1) ‖ 内容</c>;计数器必须严格递增,重放、乱序、丢帧、篡改都会解密失败。
/// </summary>
public sealed class LinkCipher : IDisposable
{
public const int Overhead = 8 + 16;
private const byte NodeToServer = 0;
private const byte ServerToNode = 1;
private readonly AesGcm _send;
private readonly AesGcm _receive;
private readonly byte _sendDirection;
private readonly byte _receiveDirection;
private readonly object _gate = new();
private long _sendCounter;
private long _receiveCounter;
private LinkCipher(byte[] sendKey, byte[] receiveKey, byte sendDirection, byte receiveDirection)
{
_send = new AesGcm(sendKey, 16);
_receive = new AesGcm(receiveKey, 16);
_sendDirection = sendDirection;
_receiveDirection = receiveDirection;
}
public static LinkCipher ForNode(EphemeralKey own, ReadOnlySpan<byte> serverEphemeralSpki, string nonce)
{
(byte[] n2s, byte[] s2n) = Derive(own, serverEphemeralSpki, own.PublicKeySpki, serverEphemeralSpki, nonce);
return new LinkCipher(n2s, s2n, NodeToServer, ServerToNode);
}
public static LinkCipher ForServer(EphemeralKey own, ReadOnlySpan<byte> nodeEphemeralSpki, string nonce)
{
(byte[] n2s, byte[] s2n) = Derive(own, nodeEphemeralSpki, nodeEphemeralSpki, own.PublicKeySpki, nonce);
return new LinkCipher(s2n, n2s, ServerToNode, NodeToServer);
}
private static (byte[] NodeToServerKey, byte[] ServerToNodeKey) Derive(EphemeralKey own, ReadOnlySpan<byte> remote, ReadOnlySpan<byte> nodeEphemeral, ReadOnlySpan<byte> serverEphemeral, string nonce)
{
byte[] secret = own.AgreeRaw(remote);
try
{
byte[] info = [.. Encoding.UTF8.GetBytes("fleet|v1|link|"), .. SHA256.HashData(serverEphemeral), .. SHA256.HashData(nodeEphemeral)];
byte[] material = HKDF.DeriveKey(HashAlgorithmName.SHA256, secret, 64, Encoding.UTF8.GetBytes(nonce), info);
return (material[..32], material[32..]);
}
finally
{
CryptographicOperations.ZeroMemory(secret);
}
}
/// <summary>加密一帧。</summary>
public byte[] Seal(byte kind, ReadOnlySpan<byte> data)
{
long counter;
lock (_gate)
{
counter = _sendCounter++;
}
byte[] output = new byte[Overhead + 1 + data.Length];
BinaryPrimitives.WriteInt64BigEndian(output, counter);
byte[] plaintext = new byte[1 + data.Length];
plaintext[0] = kind;
data.CopyTo(plaintext.AsSpan(1));
_send.Encrypt(Nonce(counter), plaintext, output.AsSpan(8, plaintext.Length), output.AsSpan(8 + plaintext.Length, 16), Aad(_sendDirection, counter));
return output;
}
/// <summary>解密一帧;计数器不是期望的下一个、或者标签不对,都返回 false。</summary>
public bool TryOpen(ReadOnlySpan<byte> frame, out byte kind, out byte[] data)
{
kind = 0;
data = [];
if (frame.Length < Overhead + 1)
{
return false;
}
long counter = BinaryPrimitives.ReadInt64BigEndian(frame);
lock (_gate)
{
if (counter != _receiveCounter)
{
return false;
}
int length = frame.Length - Overhead;
byte[] plaintext = new byte[length];
try
{
_receive.Decrypt(Nonce(counter), frame.Slice(8, length), frame.Slice(8 + length, 16), plaintext, Aad(_receiveDirection, counter));
}
catch (CryptographicException)
{
return false;
}
_receiveCounter++;
kind = plaintext[0];
data = plaintext[1..];
return true;
}
}
private static byte[] Nonce(long counter)
{
byte[] nonce = new byte[12];
BinaryPrimitives.WriteInt64BigEndian(nonce.AsSpan(4), counter);
return nonce;
}
private static byte[] Aad(byte direction, long counter)
{
byte[] aad = new byte[9];
aad[0] = direction;
BinaryPrimitives.WriteInt64BigEndian(aad.AsSpan(1), counter);
return aad;
}
public void Dispose()
{
_send.Dispose();
_receive.Dispose();
}
}
@@ -16,6 +16,14 @@ public sealed class EphemeralKey : IDisposable
public byte[] PublicKeySpki => _key.ExportSubjectPublicKeyInfo();
/// <summary>与对端的临时公钥做 ECDH,返回原始共享秘密;必须再经 KDF 才能当密钥用。</summary>
public byte[] AgreeRaw(ReadOnlySpan<byte> remotePublicKeySpki)
{
using ECDiffieHellman remote = ECDiffieHellman.Create();
remote.ImportSubjectPublicKeyInfo(remotePublicKeySpki, out _);
return _key.DeriveRawSecretAgreement(remote.PublicKey);
}
/// <summary>
/// 与对端的临时公钥协商,再经 HKDF-SHA256 派生会话凭据。
/// 盐是服务端的随机数,info 绑定双方的临时公钥,防止凭据被挪用到别的握手上。
@@ -0,0 +1,11 @@
using Fleet.Core;
namespace Fleet.Identity;
public static class TrustMapping
{
public static SignedStatement ToSigned(this SignedStatementDto dto) => new(dto.Statement, dto.CredentialId, dto.AuthenticatorData, dto.ClientDataJson, dto.Signature);
public static SignedStatementDto ToDto(this SignedStatement signed) =>
new() { Statement = signed.Statement, CredentialId = signed.CredentialId, AuthenticatorData = signed.AuthenticatorData, ClientDataJson = signed.ClientDataJson, Signature = signed.Signature };
}
@@ -0,0 +1,85 @@
using System.Buffers.Text;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace Fleet.Identity;
/// <summary>
/// 验证一次 WebAuthn 断言(<c>navigator.credentials.get</c> 的结果)。只支持 ES256(P-256 的 ECDSA),纯函数,只用 BCL:
/// 面板用它验证 passkey 登录,节点以后用它验证 passkey 签出的委托(docs/access.md 第 7 节)。
/// 不解析 CBOR——注册时的 attestation 解析只在 Backend 里。
/// </summary>
public static class WebAuthnAssertion
{
public const byte FlagUserPresent = 0x01;
public const byte FlagUserVerified = 0x04;
/// <param name="publicKeySpki">注册时保存的凭证公钥(SPKI)。</param>
/// <param name="expectedChallenge">这次请求里服务端或签名人规定的挑战原文(不是 base64url)。</param>
/// <param name="originAllowed">校验 <c>clientDataJSON.origin</c>。</param>
/// <param name="signCount">authenticatorData 里的签名计数器。</param>
public static bool Verify(
ReadOnlySpan<byte> publicKeySpki, ReadOnlySpan<byte> authenticatorData, ReadOnlySpan<byte> clientDataJson, ReadOnlySpan<byte> signature,
string rpId, ReadOnlySpan<byte> expectedChallenge, Func<string, bool> originAllowed, bool requireUserVerification, out uint signCount)
{
signCount = 0;
if (authenticatorData.Length < 37 || !CryptographicOperations.FixedTimeEquals(authenticatorData[..32], SHA256.HashData(Encoding.UTF8.GetBytes(rpId))))
{
return false;
}
byte flags = authenticatorData[32];
if ((flags & FlagUserPresent) == 0 || (requireUserVerification && (flags & FlagUserVerified) == 0))
{
return false;
}
if (!ClientDataValid(clientDataJson, "webauthn.get", expectedChallenge, originAllowed))
{
return false;
}
try
{
using ECDsa key = ECDsa.Create();
key.ImportSubjectPublicKeyInfo(publicKeySpki, out _);
byte[] signed = [.. authenticatorData, .. SHA256.HashData(clientDataJson)];
if (!key.VerifyData(signed, signature, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence))
{
return false;
}
}
catch (CryptographicException)
{
return false;
}
signCount = (uint)((authenticatorData[33] << 24) | (authenticatorData[34] << 16) | (authenticatorData[35] << 8) | authenticatorData[36]);
return true;
}
/// <summary><c>clientDataJSON</c> 的 type、challenge、origin 都要对得上。注册和登录共用。</summary>
public static bool ClientDataValid(ReadOnlySpan<byte> clientDataJson, string expectedType, ReadOnlySpan<byte> expectedChallenge, Func<string, bool> originAllowed)
{
try
{
using JsonDocument document = JsonDocument.Parse(clientDataJson.ToArray());
JsonElement root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object
|| root.GetProperty("type").GetString() != expectedType
|| root.GetProperty("origin").GetString() is not { } origin || !originAllowed(origin)
|| root.GetProperty("challenge").GetString() is not { } challenge)
{
return false;
}
byte[] decoded = Base64Url.DecodeFromChars(challenge);
return CryptographicOperations.FixedTimeEquals(decoded, expectedChallenge);
}
catch (Exception ex) when (ex is JsonException or KeyNotFoundException or FormatException or InvalidOperationException)
{
return false;
}
}
}
+90 -6
View File
@@ -66,6 +66,15 @@ public sealed class FleetLinkOptions
public long MaxInflightBytes { get; init; } = 4 << 20;
public Backoff Backoff { get; init; } = new();
/// <summary>
/// 校验服务器身份:<see cref="IServerVerifier.Required"/> 为真(节点固定的操作员集合里登记了服务器密钥)时,
/// 服务器必须在挑战里证明自己,之后这条链路用应用层加密;否则按旧的方式(没有固定信任根的节点)。
/// </summary>
public IServerVerifier? ServerVerifier { get; init; }
/// <summary>握手时自述正在运行的二进制(哈希和 CI 签名的清单);没有就不上报(旧行为)。</summary>
public Func<BinaryAttestation?>? Attestation { get; init; }
}
/// <summary>
@@ -77,6 +86,7 @@ public sealed class FleetLink
private const string RpcPath = "/api/agent/v1/rpc";
private readonly FleetLinkOptions _options;
private volatile bool _secure;
private readonly RpcRouter<LinkContext> _router;
private readonly Dictionary<string, IStateHandler> _handlers;
private readonly TimeProvider _time;
@@ -105,6 +115,9 @@ public sealed class FleetLink
public RpcSession<LinkContext>? CurrentSession => _session;
/// <summary>入站方法的路由器(只读,测试用它核对声明表和实际注册的处理函数)。</summary>
public RpcRouter<LinkContext> Router => _router;
/// <summary>被服务端撤销或禁用:不会再重连。</summary>
public bool Revoked => _stopped;
@@ -150,6 +163,9 @@ public sealed class FleetLink
}
}
/// <summary>当前这条链路是不是应用层加密的(服务器证明了身份)。</summary>
public bool IsSecure => _secure && IsConnected;
private async Task<string> RunOnceAsync(CancellationToken ct)
{
using var socket = new ClientWebSocket();
@@ -157,7 +173,7 @@ public sealed class FleetLink
socket.Options.KeepAliveTimeout = TimeSpan.FromSeconds(20); // 对端 20 秒没有回 Pong 就判定连接已死
var builder = new UriBuilder(_options.Server) { Scheme = _options.Server.Scheme == "https" ? "wss" : "ws", Path = RpcPath };
await socket.ConnectAsync(builder.Uri, ct);
await using var transport = new WsTransport(socket, _options.MaxFrameBytes);
await using var transport = new SecureTransport(new WsTransport(socket, _options.MaxFrameBytes));
string? failure = await AuthenticateAsync(transport, ct);
if (failure is not null)
@@ -165,6 +181,8 @@ public sealed class FleetLink
return failure;
}
_secure = transport.Secure;
var session = new RpcSession<LinkContext>(transport, _router, new LinkContext(this), _time);
using CancellationTokenSource sessionCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
Task run = session.RunAsync(sessionCts.Token);
@@ -208,7 +226,17 @@ public sealed class FleetLink
}
/// <summary>挑战 → 签名 → 认证结果。失败返回原因;服务端禁用该节点时不再重试。</summary>
private async Task<string?> AuthenticateAsync(WsTransport transport, CancellationToken ct)
private void Attest(AuthParams auth)
{
if (_options.Attestation?.Invoke() is { } attestation)
{
auth.Binary = attestation.Sha256;
auth.Release = attestation.Manifest;
auth.ReleaseSignature = attestation.Signature;
}
}
private async Task<string?> AuthenticateAsync(SecureTransport transport, CancellationToken ct)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15), _time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
@@ -219,9 +247,46 @@ public sealed class FleetLink
}
ChallengeParams nonce = challenge.Params.Value.Deserialize(FleetRpcContext.Default.ChallengeParams)!;
string signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.Auth, _options.NodeId.Value, nonce.Nonce));
RpcMessage request = RpcMessage.Request(1, FleetMethods.Auth, new AuthParams { NodeId = _options.NodeId.Value, Signature = signature }, FleetRpcContext.Default.AuthParams);
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token);
// 固定了操作员集合、而集合里登记了服务器密钥:服务器必须证明自己,否则不继续,也不交出任何东西
EphemeralKey? ephemeral = null;
AuthParams authParams;
try
{
if (_options.ServerVerifier is { Required: true } verifier)
{
if (!TryDecodeBase64(nonce.ServerEphemeral, out byte[] serverEphemeral) || !TryDecodeBase64(nonce.ServerSignature, out byte[] serverSignature)
|| !verifier.Verify(nonce.Nonce, serverEphemeral, serverSignature))
{
return "the server could not prove its identity (it is not the server your operator set authorizes)";
}
ephemeral = new EphemeralKey();
string serverEphemeralText = Convert.ToBase64String(serverEphemeral);
string nodeEphemeralText = Convert.ToBase64String(ephemeral.PublicKeySpki);
authParams = new AuthParams
{
NodeId = _options.NodeId.Value,
Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.AuthSecure, _options.NodeId.Value, nonce.Nonce, serverEphemeralText, nodeEphemeralText)),
NodeEphemeral = nodeEphemeralText,
};
Attest(authParams);
RpcMessage secureRequest = RpcMessage.Request(1, FleetMethods.Auth, authParams, FleetRpcContext.Default.AuthParams);
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(secureRequest, FleetRpcContext.Default.RpcMessage)), linked.Token);
transport.Enable(LinkCipher.ForNode(ephemeral, serverEphemeral, nonce.Nonce));
}
else
{
authParams = new AuthParams { NodeId = _options.NodeId.Value, Signature = Convert.ToBase64String(_options.Identity.Sign(SignaturePurpose.Auth, _options.NodeId.Value, nonce.Nonce)) };
Attest(authParams);
RpcMessage request = RpcMessage.Request(1, FleetMethods.Auth, authParams, FleetRpcContext.Default.AuthParams);
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token);
}
}
finally
{
ephemeral?.Dispose();
}
while (true)
{
@@ -256,7 +321,26 @@ public sealed class FleetLink
}
}
private static async Task<RpcMessage?> ReceiveMessageAsync(WsTransport transport, CancellationToken ct)
private static bool TryDecodeBase64(string? text, out byte[] data)
{
data = [];
if (string.IsNullOrEmpty(text))
{
return false;
}
try
{
data = Convert.FromBase64String(text);
return true;
}
catch (FormatException)
{
return false;
}
}
private static async Task<RpcMessage?> ReceiveMessageAsync(SecureTransport transport, CancellationToken ct)
{
RpcFrame? frame = await transport.ReceiveAsync(ct);
return frame is { Kind: FrameKind.Text } f ? JsonSerializer.Deserialize(f.Data.Span, FleetRpcContext.Default.RpcMessage) : null;
+16 -3
View File
@@ -22,6 +22,9 @@ public sealed class PeerListenerOptions
/// <summary>按对端 ID 查找允许连接的公钥(SPKI)。不在列表里的对端一律拒绝(期望状态里的 peers)。</summary>
public required Func<string, byte[]?> LookupPeerKey { get; init; }
/// <summary>预握手敲门:每个连接先过这一关(名册里的 Probe 才算得出敲门包),不通过就直接断开,什么都不写。</summary>
public required KnockGate Knock { get; init; }
public int MaxFrameBytes { get; init; } = 8 << 20;
public int MaxSessions { get; init; } = 8;
@@ -98,7 +101,13 @@ public sealed class PeerListener
PeerSockets.EnableKeepAlive(client.Client);
using var timeout = new CancellationTokenSource(_options.HandshakeTimeout, _time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
var tls = new SslStream(client.GetStream(), leaveInnerStreamOpen: false);
Stream stream = client.GetStream();
if (!await _options.Knock.AdmitAsync(stream, (client.Client.RemoteEndPoint as IPEndPoint)?.Address, linked.Token))
{
return;
}
var tls = new SslStream(stream, leaveInnerStreamOpen: false);
await tls.AuthenticateAsServerAsync(
new SslServerAuthenticationOptions { ServerCertificate = _options.Certificate, ClientCertificateRequired = false, EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13 },
linked.Token);
@@ -222,8 +231,10 @@ internal static class PeerSockets
public static class PeerConnector
{
public static async Task<(PeerSessionInfo Info, TlsFrameTransport Transport)> ConnectAsync(
string host, int port, byte[] expectedSpkiSha256, NodeIdentity identity, string peerId, string? version, int maxFrameBytes, TimeProvider time, CancellationToken ct)
string host, int port, byte[] agentPublicKey, NodeIdentity identity, string peerId, string? version, int maxFrameBytes, TimeProvider time, CancellationToken ct)
{
// 证书按公钥的指纹固定;敲门密钥由双方身份密钥派生
byte[] expectedSpkiSha256 = NodeIdentity.Fingerprint(agentPublicKey);
TcpClient client = IPAddress.TryParse(host, out IPAddress? address) ? new TcpClient(address.AddressFamily) : new TcpClient();
client.NoDelay = true;
PeerSockets.EnableKeepAlive(client.Client);
@@ -232,8 +243,10 @@ public static class PeerConnector
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10), time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
await client.ConnectAsync(host, port, linked.Token);
Stream stream = client.GetStream();
await stream.WriteAsync(Knock.Build(Knock.DeriveKey(identity, agentPublicKey), time.GetUtcNow().ToUnixTimeMilliseconds()), linked.Token);
var tls = new SslStream(
client.GetStream(), leaveInnerStreamOpen: false,
stream, leaveInnerStreamOpen: false,
(_, certificate, _, _) => certificate is not null && SpkiMatches(certificate, expectedSpkiSha256));
await tls.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = host }, linked.Token);
var transport = new TlsFrameTransport(tls, maxFrameBytes);
@@ -0,0 +1,57 @@
using Fleet.Identity;
using Fleet.Rpc;
namespace Fleet.Link;
/// <summary>
/// 认证阶段是明文,认证通过之后换成应用层加密(<see cref="LinkCipher"/>):此后每帧都是加密的二进制帧,明文帧一律拒绝。
/// 认证阶段服务器的拒绝回复可能是明文(还没有协商好密钥),所以第一帧加密的回复到达之前允许一次明文文本帧。
/// </summary>
internal sealed class SecureTransport : IRpcTransport
{
private readonly IRpcTransport _inner;
private LinkCipher? _cipher;
private bool _allowPlaintext = true;
public SecureTransport(IRpcTransport inner)
{
_inner = inner;
}
public bool Secure => _cipher is not null;
public void Enable(LinkCipher cipher) => _cipher = cipher;
public ValueTask SendAsync(RpcFrame frame, CancellationToken ct) =>
_cipher is null ? _inner.SendAsync(frame, ct) : _inner.SendAsync(new RpcFrame(FrameKind.Binary, _cipher.Seal((byte)frame.Kind, frame.Data.Span)), ct);
public async ValueTask<RpcFrame?> ReceiveAsync(CancellationToken ct)
{
RpcFrame? frame = await _inner.ReceiveAsync(ct);
if (frame is not { } received || _cipher is null)
{
return frame;
}
if (received.Kind == FrameKind.Binary && _cipher.TryOpen(received.Data.Span, out byte kind, out byte[] data))
{
_allowPlaintext = false;
return new RpcFrame((FrameKind)kind, data);
}
if (_allowPlaintext && received.Kind == FrameKind.Text)
{
return received; // 认证被拒绝时服务器还没有协商好密钥,回复是明文
}
throw new IOException("a frame on the secure link failed to authenticate");
}
public ValueTask CloseAsync(string reason, CancellationToken ct) => _inner.CloseAsync(reason, ct);
public async ValueTask DisposeAsync()
{
_cipher?.Dispose();
await _inner.DisposeAsync();
}
}
@@ -15,6 +15,31 @@ public static class FleetMethods
public const string Revoked = "fleet.revoked";
}
/// <summary>
/// 框架自己的方法声明,两个平面各一份。认证和握手阶段由连接代码直接处理的(<c>Inline</c>)不经过路由器。
/// 同一个方法名在两个平面里可以有不同的发起方:<c>fleet.state.sync</c> 在节点面是服务端发给节点,在对等面是 Agent 发给 Probe。
/// </summary>
public static class FleetMethodTable
{
public static readonly MethodTable Table = new(
[
// 节点面
MethodSpec.Notification(FleetMethods.Challenge, RpcSide.Server).AsInline(),
MethodSpec.Request(FleetMethods.Auth, RpcSide.Node).AsInline(),
MethodSpec.Request(FleetMethods.Hello, RpcSide.Node).AsInline(),
MethodSpec.Notification(FleetMethods.Heartbeat, RpcSide.Node).AsInline(),
MethodSpec.Request(FleetMethods.StateSync, RpcSide.Server),
MethodSpec.Notification(FleetMethods.OutboxAck, RpcSide.Server),
MethodSpec.Notification(FleetMethods.Revoked, RpcSide.Server),
// 对等面
MethodSpec.Notification(PeerMethods.Challenge, RpcSide.Listener).AsInline(),
MethodSpec.Request(PeerMethods.Auth, RpcSide.Dialer).AsInline(),
MethodSpec.Request(FleetMethods.StateSync, RpcSide.Listener),
MethodSpec.Notification(FleetMethods.OutboxAck, RpcSide.Listener),
]);
}
/// <summary>二进制通道:0–15 保留给框架,产品从 16 开始。</summary>
public static class FleetChannels
{
@@ -28,6 +53,14 @@ public sealed class ChallengeParams
[JsonPropertyName("expires_at_ms")]
public long ExpiresAtMs { get; set; }
/// <summary>服务器这次连接的临时 ECDH 公钥(SPKI,base64);服务器有身份密钥时才有。</summary>
[JsonPropertyName("server_eph")]
public string? ServerEphemeral { get; set; }
/// <summary>服务器密钥对「随机数加临时公钥」的签名(base64):节点据此确认对面是操作员授权的服务器。</summary>
[JsonPropertyName("server_sig")]
public string? ServerSignature { get; set; }
}
public sealed class AuthParams
@@ -38,6 +71,21 @@ public sealed class AuthParams
/// <summary>对 <c>fleet|v1|auth|{node_id}|{nonce}</c> 的签名(base64)。</summary>
[JsonPropertyName("signature")]
public string Signature { get; set; } = "";
/// <summary>节点的临时 ECDH 公钥(SPKI,base64):有它就是安全链路,签名覆盖双方的临时公钥,认证结果起所有帧都是加密的。</summary>
[JsonPropertyName("node_eph")]
public string? NodeEphemeral { get; set; }
/// <summary>正在运行的二进制的 SHA-256(小写十六进制):服务端在第一次握手时绑定,之后核对。旧节点没有这个字段。</summary>
[JsonPropertyName("binary")]
public string? Binary { get; set; }
/// <summary>安装这个二进制时宿主机更新器验过的 CI 签名清单(原文)和签名(base64):服务端用 CI 公钥验过,才相信它列出的官方哈希。</summary>
[JsonPropertyName("release")]
public string? Release { get; set; }
[JsonPropertyName("release_sig")]
public string? ReleaseSignature { get; set; }
}
public sealed class AuthResult
+120
View File
@@ -0,0 +1,120 @@
namespace Fleet.Rpc;
/// <summary>连接的哪一侧。节点面:服务端和节点;对等面:拨号方(Probe)和监听方(Agent)。</summary>
public enum RpcSide
{
Server,
Node,
Dialer,
Listener,
}
public enum RpcPlane
{
Node,
Peer,
}
public enum MethodKind
{
Request,
Notification,
}
public static class RpcSides
{
public static RpcPlane PlaneOf(RpcSide side) => side is RpcSide.Server or RpcSide.Node ? RpcPlane.Node : RpcPlane.Peer;
public static RpcSide Opposite(RpcSide side) => side switch
{
RpcSide.Server => RpcSide.Node,
RpcSide.Node => RpcSide.Server,
RpcSide.Dialer => RpcSide.Listener,
_ => RpcSide.Dialer,
};
}
/// <summary>
/// 一个方法的声明:<b>只有 <see cref="From"/> 能发起它</b>(不存在双向方法),类型是请求还是通知,哪些节点角色可以参与。
/// <see cref="Inline"/> 表示它在认证或握手阶段由连接代码直接处理,不经过路由器。
/// </summary>
public sealed record MethodSpec(string Name, RpcSide From, MethodKind Kind, IReadOnlyList<string>? Roles = null, bool Inline = false)
{
public RpcPlane Plane => RpcSides.PlaneOf(From);
/// <summary>接收方。</summary>
public RpcSide To => RpcSides.Opposite(From);
public bool AllowsRole(string? role) => Roles is null || (role is not null && Roles.Contains(role));
public static MethodSpec Request(string name, RpcSide from, params string[] roles) => new(name, from, MethodKind.Request, roles.Length == 0 ? null : roles);
public static MethodSpec Notification(string name, RpcSide from, params string[] roles) => new(name, from, MethodKind.Notification, roles.Length == 0 ? null : roles);
public MethodSpec AsInline() => this with { Inline = true };
}
/// <summary>二进制通道的声明:谁能发、哪些角色可以发。通道 0–15 保留给框架,产品从 16 开始。</summary>
public sealed record ChannelSpec(byte Channel, RpcSide From, IReadOnlyList<string>? Roles = null)
{
public RpcPlane Plane => RpcSides.PlaneOf(From);
public bool AllowsRole(string? role) => Roles is null || (role is not null && Roles.Contains(role));
}
/// <summary>
/// 协议方法和二进制通道的声明表,单一真源。路由器用它在注册时拒绝方向、类型、角色不符的处理函数,
/// 在收发时拒绝没有声明或不该由对方发起的方法。同一个方法名在不同平面里可以有不同的发起方,所以按(平面,名字)查找。
/// </summary>
public sealed class MethodTable
{
private readonly Dictionary<(RpcPlane, string), MethodSpec> _methods = [];
private readonly Dictionary<(RpcPlane, byte), ChannelSpec> _channels = [];
public MethodTable(IEnumerable<MethodSpec> methods, IEnumerable<ChannelSpec>? channels = null)
{
foreach (MethodSpec spec in methods)
{
if (!_methods.TryAdd((spec.Plane, spec.Name), spec))
{
throw new ArgumentException($"method {spec.Name} is declared twice in the {spec.Plane} plane");
}
}
foreach (ChannelSpec spec in channels ?? [])
{
if (!_channels.TryAdd((spec.Plane, spec.Channel), spec))
{
throw new ArgumentException($"channel {spec.Channel} is declared twice in the {spec.Plane} plane");
}
}
}
public IEnumerable<MethodSpec> Methods => _methods.Values;
public IEnumerable<ChannelSpec> Channels => _channels.Values;
public MethodSpec? Find(RpcPlane plane, string method) => _methods.GetValueOrDefault((plane, method));
public ChannelSpec? FindChannel(RpcPlane plane, byte channel) => _channels.GetValueOrDefault((plane, channel));
/// <summary>合并多张表(框架的加产品的)。</summary>
public static MethodTable Combine(params MethodTable[] tables) =>
new(tables.SelectMany(t => t.Methods), tables.SelectMany(t => t.Channels));
/// <summary>
/// 对账:<paramref name="receiver"/> 这一侧、<paramref name="role"/> 这个角色应该处理、但没有注册处理函数的方法
/// (认证阶段直接处理的 <see cref="MethodSpec.Inline"/> 方法除外)。<paramref name="role"/> 为空表示不限角色(服务端要处理所有角色发来的)。
/// </summary>
public IReadOnlyList<string> MissingHandlers(RpcSide receiver, string? role, IEnumerable<string> registered)
{
HashSet<string> have = [.. registered];
return
[
.. Methods
.Where(m => m.To == receiver && !m.Inline && (role is null || m.AllowsRole(role)) && !have.Contains(m.Name))
.Select(m => m.Name)
.Order(StringComparer.Ordinal),
];
}
}
+128 -8
View File
@@ -4,7 +4,17 @@ using Fleet.Core;
namespace Fleet.Rpc;
/// <summary>方法路由,与传输方式无关。<typeparamref name="TContext"/> 由传输层提供(例如调用方节点)。</summary>
/// <summary>对端违反了协议:方法没有声明、方向不对、请求和通知用反了、或者角色不允许。</summary>
public readonly record struct RpcViolation(string Method, string Reason);
/// <summary>一次分发的结果:要回的响应(通知为空),以及对端有没有违规。</summary>
public readonly record struct RpcDispatch(RpcMessage? Response, RpcViolation? Violation);
/// <summary>
/// 方法路由,与传输方式无关,也是协议方向的<b>唯一卡口</b>:节点和服务端、Probe 和 Agent 的入站分发都经过这里。
/// <typeparamref name="TContext"/> 由传输层提供(例如调用方节点)。路由器知道自己在连接的哪一侧(<see cref="Side"/>)和声明表,
/// 注册处理函数时核对方向、类型和角色,分发时拒绝没有声明或不该由对方发起的方法,发送时核对自己有没有资格发起。
/// </summary>
public sealed class RpcRouter<TContext>
{
private delegate ValueTask<Result<JsonElement?>> Route(TContext context, JsonElement? parameters, CancellationToken ct);
@@ -13,12 +23,28 @@ public sealed class RpcRouter<TContext>
private readonly Dictionary<string, Route> _routes = new(StringComparer.Ordinal);
private readonly Action<string, Exception>? _onUnhandled;
private readonly string? _localRole;
private readonly Func<TContext, string?>? _callerRole;
public RpcRouter(Action<string, Exception>? onUnhandled = null)
/// <param name="side">本路由器在连接的哪一侧。</param>
/// <param name="table">方法声明表。</param>
/// <param name="localRole">本节点的角色(节点侧和对等面用):声明限定了角色的方法,角色不符就不能注册。</param>
/// <param name="callerRole">从调用上下文取出对端的角色(服务端用):声明限定了角色的方法,角色不符就拒绝。</param>
public RpcRouter(RpcSide side, MethodTable table, string? localRole = null, Func<TContext, string?>? callerRole = null, Action<string, Exception>? onUnhandled = null)
{
Side = side;
Table = table;
_localRole = localRole;
_callerRole = callerRole;
_onUnhandled = onUnhandled;
}
public RpcSide Side { get; }
public MethodTable Table { get; }
private RpcPlane Plane => RpcSides.PlaneOf(Side);
public bool Has(string method) => _routes.ContainsKey(method);
public IReadOnlyCollection<string> Methods => _routes.Keys;
@@ -29,6 +55,7 @@ public sealed class RpcRouter<TContext>
JsonTypeInfo<TResult> resultInfo,
Func<TContext, TParams, CancellationToken, ValueTask<Result<TResult>>> handler)
{
Declare(method, MethodKind.Request);
_routes[method] = async (context, raw, ct) =>
{
if (!TryDeserialize(raw, paramsInfo, out TParams? p))
@@ -49,6 +76,7 @@ public sealed class RpcRouter<TContext>
JsonTypeInfo<TParams> paramsInfo,
Func<TContext, TParams, CancellationToken, ValueTask> handler)
{
Declare(method, MethodKind.Notification);
_routes[method] = async (context, raw, ct) =>
{
if (!TryDeserialize(raw, paramsInfo, out TParams? p))
@@ -66,12 +94,26 @@ public sealed class RpcRouter<TContext>
/// 处理一条请求或通知。请求返回响应;通知返回 null(失败时只丢弃)。
/// 处理函数里的异常属于程序缺陷:在这个边界捕获,回复 internal 错误,并交给 <c>onUnhandled</c> 记录。
/// </summary>
public async ValueTask<RpcMessage?> DispatchAsync(TContext context, RpcMessage message, CancellationToken ct)
public async ValueTask<RpcMessage?> DispatchAsync(TContext context, RpcMessage message, CancellationToken ct) =>
(await DispatchCheckedAsync(context, message, ct)).Response;
/// <summary>
/// 先核对声明(方向、类型、角色),再分发。违规的消息不会到达处理函数,返回的结果里带 <see cref="RpcViolation"/>,
/// 由会话决定怎么办(计数、超过上限就断开连接)。声明过、方向对、但这一侧没有处理函数的方法不算违规,只是不支持。
/// </summary>
public async ValueTask<RpcDispatch> DispatchCheckedAsync(TContext context, RpcMessage message, CancellationToken ct)
{
string method = message.Method ?? "";
if (Check(context, message) is { } violation)
{
return new RpcDispatch(
message.Id is null ? null : RpcMessage.Failure(message.Id, new FleetError(FleetErrors.ProtocolViolation, violation.Reason)),
violation);
}
if (!_routes.TryGetValue(method, out Route? route))
{
return message.Id is null ? null : RpcMessage.Failure(message.Id, new FleetError(FleetErrors.Unsupported, $"unknown method {method}"));
return new RpcDispatch(message.Id is null ? null : RpcMessage.Failure(message.Id, new FleetError(FleetErrors.Unsupported, $"unknown method {method}")), null);
}
Result<JsonElement?> result;
@@ -91,12 +133,90 @@ public sealed class RpcRouter<TContext>
if (message.Id is null)
{
return null;
return new RpcDispatch(null, null);
}
return result.TryGet(out JsonElement? value, out FleetError? error)
? new RpcMessage { Id = message.Id, Result = value ?? s_emptyObject }
: RpcMessage.Failure(message.Id, error);
return new RpcDispatch(
result.TryGet(out JsonElement? value, out FleetError? error)
? new RpcMessage { Id = message.Id, Result = value ?? s_emptyObject }
: RpcMessage.Failure(message.Id, error),
null);
}
/// <summary>入站检查:这条消息是不是对方有资格发起的、类型对不对、对方的角色允不允许。</summary>
public RpcViolation? Check(TContext context, RpcMessage message)
{
string method = message.Method ?? "";
MethodSpec? spec = Table.Find(Plane, method);
if (spec is null)
{
return new RpcViolation(method, $"method {method} is not declared");
}
if (spec.To != Side)
{
return new RpcViolation(method, $"{method} cannot be initiated by the {RpcSides.Opposite(Side)} side");
}
MethodKind kind = message.Id is null ? MethodKind.Notification : MethodKind.Request;
if (spec.Kind != kind)
{
return new RpcViolation(method, $"{method} is a {spec.Kind.ToString().ToLowerInvariant()}");
}
if (_callerRole is not null && !spec.AllowsRole(_callerRole(context)))
{
return new RpcViolation(method, $"{method} is not allowed for this node role");
}
return null;
}
/// <summary>
/// 发送前检查:本侧有没有资格发起这个方法、类型对不对、对端的角色允不允许(服务端发给节点时传入节点的角色)。
/// 违反是程序缺陷,抛 <see cref="InvalidOperationException"/>。
/// </summary>
public void CheckSend(string method, MethodKind kind, string? peerRole = null)
{
MethodSpec? spec = Table.Find(Plane, method);
if (spec is null)
{
throw new InvalidOperationException($"method {method} is not declared");
}
if (spec.From != Side)
{
throw new InvalidOperationException($"{method} cannot be initiated by the {Side} side");
}
if (spec.Kind != kind)
{
throw new InvalidOperationException($"{method} is a {spec.Kind.ToString().ToLowerInvariant()}");
}
if (peerRole is not null && !spec.AllowsRole(peerRole))
{
throw new InvalidOperationException($"{method} cannot be sent to a {peerRole}");
}
}
private void Declare(string method, MethodKind kind)
{
MethodSpec spec = Table.Find(Plane, method) ?? throw new InvalidOperationException($"method {method} is not declared in the method table");
if (spec.To != Side)
{
throw new InvalidOperationException($"{method} is initiated by the {spec.From} side and cannot be handled on the {Side} side");
}
if (spec.Kind != kind)
{
throw new InvalidOperationException($"{method} is declared as a {spec.Kind.ToString().ToLowerInvariant()}");
}
if (_localRole is not null && !spec.AllowsRole(_localRole))
{
throw new InvalidOperationException($"{method} is not for the {_localRole} role");
}
}
private static bool TryDeserialize<T>(JsonElement? raw, JsonTypeInfo<T> info, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out T? value)
+27 -6
View File
@@ -34,6 +34,9 @@ public sealed class RpcSessionOptions
/// <summary>待处理的入站请求上限;超过时接收循环等待(背压)。</summary>
public int InboundQueue { get; set; } = 256;
/// <summary>对端违反协议(方法没有声明、方向、类型或角色不对)的次数上限;超过就断开会话。</summary>
public int MaxViolations { get; set; } = 20;
}
/// <summary>
@@ -53,6 +56,8 @@ public sealed class RpcSession<TContext> : IAsyncDisposable
private readonly CancellationTokenSource _stop = new();
private long _nextId;
private long _lastReceive;
private int _violations;
private bool _violated;
public RpcSession(IRpcTransport transport, RpcRouter<TContext> router, TContext context, TimeProvider time, RpcSessionOptions? options = null)
{
@@ -81,7 +86,7 @@ public sealed class RpcSession<TContext> : IAsyncDisposable
while (!linked.IsCancellationRequested)
{
RpcFrame? frame = await _transport.ReceiveAsync(linked.Token);
if (frame is null)
if (frame is null || Volatile.Read(ref _violated))
{
return;
}
@@ -184,8 +189,16 @@ public sealed class RpcSession<TContext> : IAsyncDisposable
public ValueTask NotifyAsync<TParams>(string method, TParams parameters, JsonTypeInfo<TParams> paramsInfo, CancellationToken ct) =>
SendAsync(RpcMessage.Notification(method, parameters, paramsInfo), ct);
public ValueTask SendAsync(RpcMessage message, CancellationToken ct) =>
_transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage)), ct);
public ValueTask SendAsync(RpcMessage message, CancellationToken ct)
{
if (message.Method is not null)
{
// 请求和通知只能发本侧有资格发起的方法(响应没有方法名,不检查)
_router.CheckSend(message.Method, message.Id is null ? MethodKind.Notification : MethodKind.Request);
}
return _transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage)), ct);
}
public ValueTask SendBinaryAsync(ReadOnlyMemory<byte> frame, CancellationToken ct) =>
_transport.SendAsync(new RpcFrame(FrameKind.Binary, frame), ct);
@@ -205,10 +218,18 @@ public sealed class RpcSession<TContext> : IAsyncDisposable
{
await foreach (RpcMessage message in _inbound.Reader.ReadAllAsync(ct))
{
RpcMessage? response = await _router.DispatchAsync(_context, message, ct);
if (response is not null)
RpcDispatch outcome = await _router.DispatchCheckedAsync(_context, message, ct);
if (outcome.Response is not null)
{
await SendAsync(response, ct);
await SendAsync(outcome.Response, ct);
}
if (outcome.Violation is not null && ++_violations > _options.MaxViolations)
{
// 只关闭传输,不取消接收:接收循环在下一次读到帧或对端确认关闭时正常结束
Volatile.Write(ref _violated, true);
await _transport.CloseAsync("protocol violations", CancellationToken.None);
return;
}
}
}
@@ -5,6 +5,9 @@ public sealed class FleetServerOptions
/// <summary>节点 ID 的类型前缀,MoShiro 使用 <c>agt</c>。</summary>
public string NodeIdPrefix { get; set; } = "node";
/// <summary>加入令牌的「种类」对应的节点 ID 前缀(例如 <c>probe</c> → <c>prb</c>);没有列出的种类用 <see cref="NodeIdPrefix"/>。</summary>
public Dictionary<string, string> NodeIdPrefixes { get; } = new(StringComparer.Ordinal);
public int MaxFrameBytes { get; set; } = 16 << 20;
public TimeSpan ChallengeTtl { get; set; } = TimeSpan.FromSeconds(30);
@@ -12,4 +15,7 @@ public sealed class FleetServerOptions
public TimeSpan HeartbeatTimeout { get; set; } = TimeSpan.FromSeconds(45);
public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(15);
/// <summary>节点违反协议(方法没有声明、方向、类型或角色不对)的次数上限;超过就断开这条连接。</summary>
public int MaxViolations { get; set; } = 20;
}
@@ -31,7 +31,10 @@ public sealed record NewNode(
IReadOnlyList<string> Labels,
string? MetaJson,
string? NodeVersion,
long NowMs);
long NowMs,
string Kind = "",
byte[]? TlsPublicKey = null,
string? AddedBy = null);
public sealed record JoinTokenRecord(
string Id,
@@ -43,6 +46,8 @@ public sealed record JoinTokenRecord(
int Uses,
long ExpiresAtMs,
long? RevokedAtMs,
long CreatedAtMs);
long CreatedAtMs,
string Kind = "",
string? CreatedBy = null);
public sealed record Presence(bool Online, long AtMs, string? NodeVersion, IReadOnlyList<string> Capabilities, string? HelloMetaJson);
@@ -6,6 +6,39 @@ namespace Fleet.Server;
public sealed record EnrollmentContext(string? RemoteIp, JsonElement? ProductMeta);
/// <summary>
/// 服务器的身份密钥:操作员用 passkey 把它的公钥签进操作员集合。有它的时候,认证挑战会带上服务器的签名,节点据此确认对面是真的服务器,
/// 并在这条链路上用应用层加密。没有注册就是旧的行为(没有服务器身份)。
/// </summary>
public interface IServerIdentity
{
byte[] PublicKeySpki { get; }
/// <summary>对一段内容签名(<c>Fleet.Identity.ServerHello.Payload</c> 给出的)。</summary>
byte[] Sign(ReadOnlySpan<byte> payload);
}
/// <summary>操作员集合的链(passkey 签的,创世在前)。没有初始化信任根时是空的。节点注册时用它确认「这个面板确实是我的操作员的」。</summary>
/// <summary>节点握手时自述的二进制(旧节点没有)。</summary>
public sealed record BinaryEvidence(string? Sha256, string? Manifest, string? Signature);
/// <summary>是否让这个节点继续:不放行时 <c>Reason</c> 会原样告诉节点(这是它自己的信息,不是秘密)。</summary>
public sealed record BinaryDecision(bool Accept, string Reason = "");
/// <summary>
/// 服务端对节点二进制的把关:节点第一次握手成功的那一刻绑定它的二进制哈希,之后哈希对不上、又不在 CI 签名的更新清单里,就不让它继续运行。
/// 没有注册实现时不做任何检查。
/// </summary>
public interface IBinaryAttestor
{
ValueTask<BinaryDecision> CheckAsync(NodeRecord node, BinaryEvidence evidence, CancellationToken ct);
}
public interface IOperatorTrustSource
{
ValueTask<IReadOnlyList<SignedStatementDto>> ChainAsync(CancellationToken ct);
}
/// <summary>产品挂钩:节点注册成功之后调用。</summary>
public interface IEnrollmentHook
{
@@ -19,6 +19,13 @@ public sealed class EnrollRequest
[JsonPropertyName("tls_spki")]
public string TlsSpki { get; set; } = "";
/// <summary>
/// 同一把 TLS 密钥的完整公钥(SPKI,base64),必须和 <see cref="TlsSpki"/> 对得上。节点用这把密钥做对等面的签名认证
/// (和连服务端用的身份密钥是两把独立的密钥),对端要验签就需要完整的公钥,而不只是指纹。
/// </summary>
[JsonPropertyName("tls_pubkey")]
public string TlsPublicKey { get; set; } = "";
/// <summary>持有证明:对 <c>fleet|v1|enroll|{sha256_hex(join_token)}|{pubkey}</c> 的签名(base64)。</summary>
[JsonPropertyName("proof")]
public string Proof { get; set; } = "";
@@ -30,6 +37,12 @@ public sealed class EnrollRequest
public JsonElement? Meta { get; set; }
}
public sealed class TrustRequest
{
[JsonPropertyName("join_token")]
public string JoinToken { get; set; } = "";
}
public sealed class EnrollResponse
{
[JsonPropertyName("node_id")]
@@ -62,5 +75,7 @@ public sealed class FleetProblem
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(EnrollRequest))]
[JsonSerializable(typeof(EnrollResponse))]
[JsonSerializable(typeof(TrustRequest))]
[JsonSerializable(typeof(Fleet.Core.TrustBundle))]
[JsonSerializable(typeof(FleetProblem))]
internal sealed partial class EnrollmentJsonContext : JsonSerializerContext;
@@ -20,9 +20,42 @@ public static class FleetEnrollmentEndpoints
.WithTags("agent")
.WithSummary("Enroll a node with a join token")
.DisableAntiforgery();
// 注册之前取信任链:节点先确认这个面板的操作员集合和委托确实是它固定的创世签出来的,再把自己的密钥交出去
app.MapPost(path + "/trust", HandleTrustAsync)
.WithName("fleet_enroll_trust")
.WithTags("agent")
.WithSummary("The operator set chain and the grant of a join token, for the node to verify before it enrolls")
.DisableAntiforgery();
return app;
}
private static async Task<IResult> HandleTrustAsync(HttpContext http, FleetEnrollmentService service)
{
TrustRequest? request;
try
{
request = await JsonSerializer.DeserializeAsync(http.Request.Body, EnrollmentJsonContext.Default.TrustRequest, http.RequestAborted);
}
catch (JsonException)
{
request = null;
}
if (request is null || string.IsNullOrEmpty(request.JoinToken))
{
return Problem(StatusCodes.Status400BadRequest, FleetErrors.InvalidParams, "join_token is required");
}
Result<TrustBundle> result = await service.GetTrustAsync(request.JoinToken, http.RequestAborted);
if (!result.TryGet(out TrustBundle? bundle, out FleetError? error))
{
return Problem(error.Code == FleetErrors.JoinTokenExpired ? StatusCodes.Status410Gone : StatusCodes.Status401Unauthorized, error.Code, error.Message);
}
http.Response.Headers.CacheControl = "no-store";
return Results.Json(bundle, EnrollmentJsonContext.Default.TrustBundle);
}
private static async Task<IResult> HandleAsync(HttpContext http, FleetEnrollmentService service)
{
EnrollRequest? request;
@@ -9,13 +9,15 @@ public sealed partial class FleetEnrollmentService
{
private readonly IFleetStore _store;
private readonly IEnumerable<IEnrollmentHook> _hooks;
private readonly IEnumerable<IOperatorTrustSource> _trust;
private readonly FleetServerOptions _options;
private readonly TimeProvider _time;
public FleetEnrollmentService(IFleetStore store, IEnumerable<IEnrollmentHook> hooks, FleetServerOptions options, TimeProvider time)
public FleetEnrollmentService(IFleetStore store, IEnumerable<IEnrollmentHook> hooks, IEnumerable<IOperatorTrustSource> trust, FleetServerOptions options, TimeProvider time)
{
_store = store;
_hooks = hooks;
_trust = trust;
_options = options;
_time = time;
}
@@ -29,9 +31,16 @@ public sealed partial class FleetEnrollmentService
if (!TryDecode(request.PublicKey, out byte[] publicKey) || !NodeIdentity.IsValidPublicKey(publicKey)
|| !TryDecode(request.TlsSpki, out byte[] tlsSpki) || tlsSpki.Length != 32
|| !TryDecode(request.TlsPublicKey, out byte[] tlsPublicKey) || !NodeIdentity.IsValidPublicKey(tlsPublicKey)
|| !TryDecode(request.Proof, out byte[] proof))
{
return Result<EnrollResponse>.Fail(FleetErrors.InvalidParams, "pubkey, tls_spki or proof is malformed");
return Result<EnrollResponse>.Fail(FleetErrors.InvalidParams, "pubkey, tls_spki, tls_pubkey or proof is malformed");
}
// 指纹和完整公钥必须是同一把密钥,否则对端按指纹固定证书、按公钥验签,会各认各的
if (!System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(NodeIdentity.Fingerprint(tlsPublicKey), tlsSpki))
{
return Result<EnrollResponse>.Fail(FleetErrors.InvalidParams, "tls_spki does not match tls_pubkey");
}
byte[] tokenHash = TokenHash.Sha256(request.JoinToken);
@@ -61,8 +70,9 @@ public sealed partial class FleetEnrollmentService
bool nameTaken = await _store.FindEstablishedNodeByNameAsync(request.Name, ct) is not null;
NodeStatus status = token.AutoApprove && !nameTaken ? NodeStatus.Active : NodeStatus.Pending;
string prefix = token.Kind.Length > 0 && _options.NodeIdPrefixes.TryGetValue(token.Kind, out string? kindPrefix) ? kindPrefix : _options.NodeIdPrefix;
var node = new NewNode(
new NodeId(Ulid.NewPrefixed(_options.NodeIdPrefix, _time)),
new NodeId(Ulid.NewPrefixed(prefix, _time)),
request.Name,
status,
publicKey,
@@ -70,7 +80,10 @@ public sealed partial class FleetEnrollmentService
token.Labels,
request.Meta?.GetRawText(),
request.NodeVersion,
nowMs);
nowMs,
token.Kind,
tlsPublicKey,
token.CreatedBy);
Result<NodeRecord> created = await _store.CreateNodeAsync(node, ct);
if (!created.TryGet(out NodeRecord? record, out FleetError? error))
@@ -91,6 +104,30 @@ public sealed partial class FleetEnrollmentService
});
}
/// <summary>节点注册之前取:操作员集合的链。码要是有效的;不能用的码不给。</summary>
public async ValueTask<Result<TrustBundle>> GetTrustAsync(string joinToken, CancellationToken ct)
{
JoinTokenRecord? token = await _store.FindJoinTokenAsync(TokenHash.Sha256(joinToken), ct);
long nowMs = _time.GetUtcNow().ToUnixTimeMilliseconds();
if (token is null || token.RevokedAtMs is not null || token.Uses >= token.MaxUses)
{
return Result<TrustBundle>.Fail(FleetErrors.JoinTokenInvalid, "join token is invalid");
}
if (token.ExpiresAtMs <= nowMs)
{
return Result<TrustBundle>.Fail(FleetErrors.JoinTokenExpired, "join token has expired");
}
var bundle = new TrustBundle();
foreach (IOperatorTrustSource source in _trust)
{
bundle.Chain.AddRange(await source.ChainAsync(ct));
}
return Result<TrustBundle>.Ok(bundle);
}
private static bool TryDecode(string text, out byte[] data)
{
try
@@ -23,8 +23,12 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
IEnumerable<IStateProvider> stateProviders,
IEnumerable<IInbox> inboxes,
IEnumerable<INodeEventSink> sinks,
FleetRouter router)
FleetRouter router,
IServerIdentity? serverIdentity = null,
IBinaryAttestor? binaryAttestor = null)
{
ServerIdentity = serverIdentity;
BinaryAttestor = binaryAttestor;
Store = store;
Options = options;
Time = time;
@@ -33,10 +37,17 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
Inboxes = inboxes.ToDictionary(i => i.Channel);
Sinks = [.. sinks];
Router = router.Inner;
RoleOf = router.RoleOf;
}
internal IFleetStore Store { get; }
/// <summary>服务器的身份密钥;没有时节点连接用旧的明文认证。</summary>
internal IServerIdentity? ServerIdentity { get; }
/// <summary>节点二进制的把关;没有时不检查。</summary>
internal IBinaryAttestor? BinaryAttestor { get; }
internal FleetServerOptions Options { get; }
internal TimeProvider Time { get; }
@@ -51,6 +62,8 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
internal RpcRouter<NodeCallContext> Router { get; }
internal Func<NodeRecord, string?>? RoleOf { get; }
public IReadOnlyCollection<NodeId> OnlineNodes => [.. _connections.Keys];
public bool IsOnline(NodeId node) => _connections.ContainsKey(node);
@@ -161,10 +174,18 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
/// <summary>产品注册的 RPC 方法路由。</summary>
public sealed class FleetRouter
{
public FleetRouter(RpcRouter<NodeCallContext> inner)
public FleetRouter(RpcRouter<NodeCallContext> inner, Func<NodeRecord, string?>? roleOf)
{
Inner = inner;
RoleOf = roleOf;
}
internal RpcRouter<NodeCallContext> Inner { get; }
internal Func<NodeRecord, string?>? RoleOf { get; }
/// <summary>已注册的入站方法名(只读,测试用它核对声明表)。</summary>
public IReadOnlyCollection<string> Methods => Inner.Methods;
public MethodTable Table => Inner.Table;
}
@@ -19,6 +19,9 @@ internal static partial class FleetLog
[LoggerMessage(Level = LogLevel.Warning, Message = "state push failed node={Node} type={Type} err={Err}")]
public static partial void StatePushFailed(ILogger logger, string node, string type, string err);
[LoggerMessage(Level = LogLevel.Warning, Message = "protocol violation node={Node} method={Method} reason={Reason}")]
public static partial void ProtocolViolation(ILogger logger, string node, string method, string reason);
[LoggerMessage(Level = LogLevel.Error, Message = "unhandled error in handler method={Method}")]
public static partial void HandlerFailed(ILogger logger, Exception exception, string method);
@@ -12,6 +12,8 @@ namespace Fleet.Server;
public sealed class FleetServerBuilder
{
private readonly List<Action<RpcRouter<NodeCallContext>, IServiceProvider>> _routeConfigurators = [];
private MethodTable? _methods;
private Func<NodeRecord, string?>? _roleOf;
internal FleetServerBuilder(IServiceCollection services)
{
@@ -22,6 +24,21 @@ public sealed class FleetServerBuilder
internal IReadOnlyList<Action<RpcRouter<NodeCallContext>, IServiceProvider>> RouteConfigurators => _routeConfigurators;
internal MethodTable Methods => _methods ?? throw new InvalidOperationException("call WithMethods(...) when adding the Fleet server: the method table is required");
internal Func<NodeRecord, string?>? RoleOf => _roleOf;
/// <summary>
/// 方法声明表(框架的加产品的,见 <see cref="MethodTable.Combine"/>)和节点的角色解析(例如按节点 ID 前缀区分 Agent 和 Probe)。必须设置:
/// 路由器据此核对每个处理函数的方向、类型和角色,并拒绝没有声明或不该由节点发起的方法。
/// </summary>
public FleetServerBuilder WithMethods(MethodTable methods, Func<NodeRecord, string?> roleOf)
{
_methods = methods;
_roleOf = roleOf;
return this;
}
public FleetServerBuilder AddState<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TProvider>()
where TProvider : class, IStateProvider
{
@@ -71,13 +88,18 @@ public static class FleetServerExtensions
services.AddSingleton(provider =>
{
ILogger logger = provider.GetRequiredService<ILoggerFactory>().CreateLogger("Fleet.Rpc");
var router = new RpcRouter<NodeCallContext>((method, ex) => FleetLog.HandlerFailed(logger, ex, method));
Func<NodeRecord, string?>? roleOf = builder.RoleOf;
var router = new RpcRouter<NodeCallContext>(
RpcSide.Server,
builder.Methods,
callerRole: roleOf is null ? null : context => roleOf(context.Node),
onUnhandled: (method, ex) => FleetLog.HandlerFailed(logger, ex, method));
foreach (Action<RpcRouter<NodeCallContext>, IServiceProvider> configurator in builder.RouteConfigurators)
{
configurator(router, provider);
}
return new FleetRouter(router);
return new FleetRouter(router, roleOf);
});
services.AddSingleton(provider => new FleetLinkHost(
provider.GetRequiredService<IFleetStore>(),
@@ -87,7 +109,9 @@ public static class FleetServerExtensions
provider.GetServices<IStateProvider>(),
provider.GetServices<IInbox>(),
provider.GetServices<INodeEventSink>(),
provider.GetRequiredService<FleetRouter>()));
provider.GetRequiredService<FleetRouter>(),
provider.GetService<IServerIdentity>(),
provider.GetService<IBinaryAttestor>()));
services.AddSingleton<IFleetConnections>(provider => provider.GetRequiredService<FleetLinkHost>());
services.AddSingleton<IStateNotifier>(provider => provider.GetRequiredService<FleetLinkHost>());
return builder;
@@ -0,0 +1,131 @@
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Text.Json;
using Fleet.Core;
using Fleet.Identity;
using Fleet.Rpc;
namespace Fleet.Server;
/// <summary>认证:挑战、签名校验、拒绝。</summary>
internal sealed partial class NodeConnection
{
private async Task<bool> AuthenticateAsync(CancellationToken ct)
{
string nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
long expiresAtMs = _host.Time.GetUtcNow().Add(_host.Options.ChallengeTtl).ToUnixTimeMilliseconds();
// 有服务器身份密钥:挑战里带上这次连接的临时公钥和服务器的签名,节点据此确认服务器、协商加密链路
using var serverEphemeral = _host.ServerIdentity is null ? null : new EphemeralKey();
var challenge = new ChallengeParams { Nonce = nonce, ExpiresAtMs = expiresAtMs };
string? serverEphemeralText = null;
if (_host.ServerIdentity is { } identity && serverEphemeral is not null)
{
serverEphemeralText = Convert.ToBase64String(serverEphemeral.PublicKeySpki);
challenge.ServerEphemeral = serverEphemeralText;
challenge.ServerSignature = Convert.ToBase64String(identity.Sign(ServerHello.Payload(nonce, serverEphemeral.PublicKeySpki)));
}
await SendAsync(RpcMessage.Notification(FleetMethods.Challenge, challenge, FleetRpcContext.Default.ChallengeParams), ct);
using var timeout = new CancellationTokenSource(s_authTimeout, _host.Time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
Frame? frame;
try
{
frame = await ReceiveFrameAsync(linked.Token);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
await RejectAsync(null, "auth timeout", ct);
return false;
}
if (frame is not { IsText: true } || !TryParse(frame.Data, out RpcMessage? message) || message.Method != FleetMethods.Auth || message.Id is null)
{
await RejectAsync(null, "expected fleet.auth", ct);
return false;
}
AuthParams? auth = null;
try
{
auth = message.Params?.Deserialize(FleetRpcContext.Default.AuthParams);
}
catch (JsonException)
{
// 按缺失处理
}
NodeRecord? node = auth is null ? null : await _host.Store.GetNodeAsync(new NodeId(auth.NodeId), ct);
// 安全链路:节点的签名覆盖双方的临时公钥(AuthSecure),验过之后这条连接上的所有帧都是加密的
byte[] nodeEphemeral = [];
bool secure = auth?.NodeEphemeral is not null && serverEphemeral is not null && serverEphemeralText is not null && TryDecode(auth.NodeEphemeral, out nodeEphemeral);
bool signatureOk = node is not null
&& _host.Time.GetUtcNow().ToUnixTimeMilliseconds() <= expiresAtMs
&& TryDecode(auth!.Signature, out byte[] signature)
&& (secure
? NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.AuthSecure, auth.NodeId, nonce, serverEphemeralText!, auth.NodeEphemeral!)
: auth.NodeEphemeral is null && NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.Auth, auth.NodeId, nonce));
if (signatureOk && secure)
{
_cipher = LinkCipher.ForServer(serverEphemeral!, nodeEphemeral, nonce);
}
if (!signatureOk)
{
// 节点不存在与签名错误返回同样的错误,不泄露节点是否存在。
await RejectAsync(message.Id, "authentication failed", ct);
return false;
}
_node = node;
if (node!.Status == NodeStatus.Disabled)
{
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.NodeDisabled, "node is disabled")), ct);
await RevokeAsync("disabled", ct);
return false;
}
// 二进制:第一次握手成功的这一刻绑定,之后哈希对不上又不在 CI 签名的更新清单里,就不让它继续运行
if (_host.BinaryAttestor is { } attestor)
{
BinaryDecision decision = await attestor.CheckAsync(node, new BinaryEvidence(auth!.Binary, auth.Release, auth.ReleaseSignature), ct);
if (!decision.Accept)
{
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.BinaryMismatch, decision.Reason)), ct);
await RejectAsync(null, "binary mismatch: " + decision.Reason, ct);
return false;
}
}
_session = new NodeSessionInfo(node.Id, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), node.NodeVersion, [], null);
await SendAsync(
RpcMessage.Success(message.Id.Value, new AuthResult { Status = StatusName(node.Status) }, FleetRpcContext.Default.AuthResult),
ct);
return true;
}
private async Task RejectAsync(long? id, string reason, CancellationToken ct)
{
FleetLog.AuthRejected(_host.Logger, reason);
_closeReason ??= reason;
if (id is not null)
{
await SendAsync(RpcMessage.Failure(id, new FleetError(FleetErrors.Unauthorized, "authentication failed")), ct);
}
if (_socket.State == WebSocketState.Open)
{
try
{
await _socket.CloseOutputAsync(WebSocketCloseStatus.PolicyViolation, "unauthorized", ct);
}
catch (WebSocketException)
{
// 对端已经断开
}
}
}
}
@@ -0,0 +1,240 @@
using System.Text.Json;
using Fleet.Core;
using Fleet.Rpc;
namespace Fleet.Server;
/// <summary>接收:读帧、分发消息、hello、心跳、二进制通道(收件箱)。</summary>
internal sealed partial class NodeConnection
{
private async Task ReceiveLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
using var idle = new CancellationTokenSource(_host.Options.HeartbeatTimeout, _host.Time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, idle.Token);
Frame? frame;
try
{
frame = await ReceiveFrameAsync(linked.Token);
}
catch (OperationCanceledException) when (idle.IsCancellationRequested && !ct.IsCancellationRequested)
{
_closeReason ??= "heartbeat timeout";
return;
}
if (frame is null)
{
_closeReason ??= "closed by peer";
return;
}
if (frame.IsText)
{
if (TryParse(frame.Data, out RpcMessage? message))
{
await HandleMessageAsync(message, ct);
}
}
else
{
await HandleBinaryAsync(frame.Data, ct);
}
}
}
private async Task HandleMessageAsync(RpcMessage message, CancellationToken ct)
{
if (message.IsResponse)
{
if (_pending.TryGetValue(message.Id!.Value, out TaskCompletionSource<RpcMessage>? waiter))
{
waiter.TrySetResult(message);
}
return;
}
switch (message.Method)
{
case FleetMethods.Hello:
await HandleHelloAsync(message, ct);
return;
case FleetMethods.Heartbeat:
await HandleHeartbeatAsync(message, ct);
return;
}
if (_node!.Status != NodeStatus.Active)
{
if (message.Id is not null)
{
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.NodePending, "node is not approved yet")), ct);
}
return;
}
var context = new NodeCallContext(_node, _session!, _host);
RpcDispatch outcome;
try
{
outcome = await _host.Router.DispatchCheckedAsync(context, message, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
if (outcome.Response is not null)
{
await SendAsync(outcome.Response, ct);
}
await NoteViolationAsync(outcome.Violation);
}
/// <summary>记录节点的违规;超过上限就断开这条连接。</summary>
private async Task NoteViolationAsync(RpcViolation? violation)
{
if (violation is not { } v)
{
return;
}
FleetLog.ProtocolViolation(_host.Logger, _node!.Id.Value, v.Method, v.Reason);
if (++_violations > _host.Options.MaxViolations)
{
await CloseAsync("protocol violations");
}
}
private async Task HandleHelloAsync(RpcMessage message, CancellationToken ct)
{
HelloParams? hello = null;
try
{
hello = message.Params?.Deserialize(FleetRpcContext.Default.HelloParams);
}
catch (JsonException)
{
// 按缺失处理
}
if (hello is null)
{
if (message.Id is not null)
{
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.InvalidParams, "invalid hello")), ct);
}
return;
}
long nowMs = _host.Time.GetUtcNow().ToUnixTimeMilliseconds();
_session = _session! with
{
NodeVersion = hello.NodeVersion,
Capabilities = hello.Capabilities,
HelloMetaJson = hello.Meta?.GetRawText(),
};
_node = _node! with { NodeVersion = hello.NodeVersion };
_helloDone = true;
await UpdatePresenceAsync(online: true, ct);
await FireAsync(NodeEventKind.Hello, ct, hello);
if (message.Id is not null)
{
await SendAsync(
RpcMessage.Success(
message.Id.Value,
new HelloResult { ServerTimeMs = nowMs, ClockOffsetMs = hello.NowUtcMs - nowMs },
FleetRpcContext.Default.HelloResult),
ct);
}
SignalStateChanged();
}
private async Task HandleHeartbeatAsync(RpcMessage message, CancellationToken ct)
{
JsonElement? extra = null;
try
{
extra = message.Params?.Deserialize(FleetRpcContext.Default.HeartbeatParams)?.Extra;
}
catch (JsonException)
{
// 忽略格式不正确的附加字段
}
long now = _host.Time.GetTimestamp();
if (_host.Time.GetElapsedTime(Interlocked.Read(ref _lastPresenceTicks), now) >= s_presenceRefresh)
{
await UpdatePresenceAsync(online: true, ct);
}
await FireAsync(NodeEventKind.Heartbeat, ct, hello: null, extra);
if (message.Id is not null)
{
await SendAsync(new RpcMessage { Id = message.Id, Result = JsonSerializer.SerializeToElement(new EmptyObject(), FleetEmptyObject.Info) }, ct);
}
}
private async Task HandleBinaryAsync(byte[] data, CancellationToken ct)
{
if (_node!.Status != NodeStatus.Active || !OutboxFrame.TryParse(data, out byte channel, out Guid msgId, out ReadOnlyMemory<byte> payload))
{
return;
}
// 通道也要声明:节点只能往声明过、并且允许它的角色使用的通道发
ChannelSpec? spec = _host.Router.Table.FindChannel(RpcPlane.Node, channel);
if (spec is null || spec.From != RpcSide.Node || !spec.AllowsRole(_host.RoleOf?.Invoke(_node)))
{
await NoteViolationAsync(new RpcViolation($"channel {channel}", $"channel {channel} is not declared for this node"));
return;
}
if (!_host.Inboxes.TryGetValue(channel, out IInbox? inbox))
{
return;
}
InboxOutcome outcome;
try
{
outcome = await inbox.HandleAsync(_node, msgId, payload, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
// 没有持久化就不确认,节点会重发。
FleetLog.InboxFailed(_host.Logger, ex, _node.Id.Value, channel);
return;
}
if (outcome == InboxOutcome.Rejected)
{
return;
}
var ack = new OutboxAckParams();
string id = msgId.ToString();
if (outcome == InboxOutcome.Duplicate)
{
ack.Duplicates.Add(id);
}
else
{
ack.Ids.Add(id);
}
await SendAsync(RpcMessage.Notification(FleetMethods.OutboxAck, ack, FleetRpcContext.Default.OutboxAckParams), ct);
}
}
@@ -0,0 +1,128 @@
using System.Net.WebSockets;
using System.Text.Json;
using Fleet.Core;
using Fleet.Rpc;
namespace Fleet.Server;
/// <summary>发给节点:关闭、吊销、发送一条消息、发起请求并等响应。</summary>
internal sealed partial class NodeConnection
{
/// <summary>
/// 服务端发起的关闭:发送 Close 帧,等对端确认之后接收循环自然结束。
/// 注意不能直接取消接收的令牌——取消 <c>ReceiveAsync</c> 会中止整个 WebSocket,对端拿不到关闭原因;取消只作为超时兜底。
/// </summary>
public async ValueTask CloseAsync(string reason)
{
_closeReason ??= reason;
try
{
await _sendLock.WaitAsync(CancellationToken.None);
}
catch (ObjectDisposedException)
{
return;
}
try
{
if (_socket.State == WebSocketState.Open)
{
await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, reason, CancellationToken.None);
}
}
catch (WebSocketException)
{
// 对端已经断开
}
finally
{
_sendLock.Release();
}
_lifetime.CancelAfter(s_closeTimeout);
}
public async ValueTask RevokeAsync(string reason, CancellationToken ct)
{
await SendAsync(RpcMessage.Notification(FleetMethods.Revoked, new RevokedParams { Reason = reason }, FleetRpcContext.Default.RevokedParams), ct);
await CloseAsync(reason);
}
public async ValueTask<bool> SendAsync(RpcMessage message, CancellationToken ct)
{
if (message.Method is not null)
{
// 服务端只能发自己有资格发起的方法,并且对端的角色要允许(响应没有方法名,不检查)
string? peerRole = _node is null ? null : _host.RoleOf?.Invoke(_node);
_host.Router.CheckSend(message.Method, message.Id is null ? MethodKind.Notification : MethodKind.Request, peerRole);
}
byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage);
try
{
await _sendLock.WaitAsync(ct);
}
catch (ObjectDisposedException)
{
return false;
}
try
{
if (_socket.State != WebSocketState.Open)
{
return false;
}
if (_cipher is { } cipher)
{
await _socket.SendAsync(cipher.Seal(0, bytes), WebSocketMessageType.Binary, endOfMessage: true, ct);
}
else
{
await _socket.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct);
}
return true;
}
catch (WebSocketException)
{
return false;
}
finally
{
_sendLock.Release();
}
}
public async ValueTask<Result<JsonElement>> RequestAsync(string method, JsonElement parameters, CancellationToken ct)
{
long id = Interlocked.Increment(ref _nextRequestId);
var waiter = new TaskCompletionSource<RpcMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
_pending[id] = waiter;
try
{
var request = new RpcMessage { Id = id, Method = method, Params = parameters };
if (!await SendAsync(request, ct))
{
return Result<JsonElement>.Fail(FleetErrors.NodeOffline, "node is not connected");
}
using var timeout = new CancellationTokenSource(_host.Options.RequestTimeout, _host.Time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
RpcMessage response = await waiter.Task.WaitAsync(linked.Token);
return response.Error is not null
? Result<JsonElement>.Fail(response.Error.ToFleetError())
: Result<JsonElement>.Ok(response.Result ?? default);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
return Result<JsonElement>.Fail(FleetErrors.Timeout, $"{method} timed out");
}
finally
{
_pending.TryRemove(id, out _);
}
}
}
@@ -0,0 +1,123 @@
using System.Text.Json;
using Fleet.Core;
using Fleet.Rpc;
namespace Fleet.Server;
/// <summary>状态:期望状态推送、在线状态、节点事件。</summary>
internal sealed partial class NodeConnection
{
public void SignalStateChanged() => _stateSignal.Writer.TryWrite(true);
private async Task StatePushLoopAsync(CancellationToken ct)
{
while (await _stateSignal.Reader.WaitToReadAsync(ct))
{
_stateSignal.Reader.TryRead(out _);
bool complete = await PushStateAsync(ct);
if (!complete)
{
await Task.Delay(s_statePushRetry, _host.Time, ct);
SignalStateChanged();
}
}
}
private async Task<bool> PushStateAsync(CancellationToken ct)
{
NodeRecord? fresh = await _host.Store.GetNodeAsync(_node!.Id, ct);
if (fresh is null)
{
return true;
}
_node = fresh with { NodeVersion = _node.NodeVersion ?? fresh.NodeVersion };
if (fresh.Status == NodeStatus.Disabled)
{
await RevokeAsync("disabled", ct);
return true;
}
if (fresh.Status != NodeStatus.Active || !_helloDone)
{
// pending 不下发状态;还没有 hello 的连接等 hello 之后再推送。
return true;
}
bool complete = true;
foreach (IStateProvider provider in _host.StateProviders)
{
StateDocument? document = await provider.GetAsync(_node, ct);
if (document is null || (_lastPushedVersion.TryGetValue(provider.Type, out long last) && last == document.Version))
{
continue;
}
Result<JsonElement> raw = await RequestAsync(
FleetMethods.StateSync,
JsonSerializer.SerializeToElement(
new StateSyncParams { Type = provider.Type, Version = document.Version, Doc = document.Doc },
FleetRpcContext.Default.StateSyncParams),
ct);
if (!raw.TryGet(out JsonElement value, out FleetError? error))
{
FleetLog.StatePushFailed(_host.Logger, _node.Id.Value, provider.Type, error.Code.Value);
complete = false;
continue;
}
StateSyncResult? result = null;
try
{
result = value.Deserialize(FleetRpcContext.Default.StateSyncResult);
}
catch (JsonException)
{
// 按缺失处理
}
if (result is null)
{
complete = false;
continue;
}
_lastPushedVersion[provider.Type] = document.Version;
await provider.OnAppliedAsync(_node, document.Version, result, ct);
}
return complete;
}
private async Task UpdatePresenceAsync(bool online, CancellationToken ct)
{
Interlocked.Exchange(ref _lastPresenceTicks, _host.Time.GetTimestamp());
await _host.Store.UpdatePresenceAsync(
_node!.Id,
new Presence(online, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), _session?.NodeVersion, _session?.Capabilities ?? [], _session?.HelloMetaJson),
ct);
}
private async Task FireAsync(NodeEventKind kind, CancellationToken ct, HelloParams? hello = null, JsonElement? extra = null)
{
var nodeEvent = new NodeEvent(kind, _node!, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), hello, extra);
foreach (INodeEventSink sink in _host.Sinks)
{
try
{
await sink.OnEventAsync(nodeEvent, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
// 产品的事件处理失败不能影响连接。
FleetLog.SinkFailed(_host.Logger, ex, kind.ToString(), _node!.Id.Value);
}
}
}
}
@@ -0,0 +1,98 @@
using System.Buffers;
using System.Net.WebSockets;
using System.Text.Json;
using Fleet.Rpc;
namespace Fleet.Server;
/// <summary>线上的帧:WebSocket 收帧、解析、状态名。</summary>
internal sealed partial class NodeConnection
{
private async ValueTask<Frame?> ReceiveFrameAsync(CancellationToken ct)
{
var writer = new ArrayBufferWriter<byte>(1024);
while (true)
{
Memory<byte> buffer = writer.GetMemory(4096);
ValueWebSocketReceiveResult result = await _socket.ReceiveAsync(buffer, ct);
if (result.MessageType == WebSocketMessageType.Close)
{
return null;
}
writer.Advance(result.Count);
if (writer.WrittenCount > _host.Options.MaxFrameBytes)
{
_closeReason ??= "frame too large";
await _socket.CloseOutputAsync(WebSocketCloseStatus.MessageTooBig, "frame too large", CancellationToken.None);
return null;
}
if (result.EndOfMessage)
{
if (_cipher is { } cipher)
{
// 加密链路上只接受通过认证的二进制帧;明文、被改过的、重放的、乱序的一律断开
if (result.MessageType != WebSocketMessageType.Binary || !cipher.TryOpen(writer.WrittenSpan, out byte kind, out byte[] plain))
{
_closeReason ??= "frame failed authentication";
await _socket.CloseOutputAsync(WebSocketCloseStatus.PolicyViolation, "frame failed authentication", CancellationToken.None);
return null;
}
return new Frame(kind == 0, plain);
}
return new Frame(result.MessageType == WebSocketMessageType.Text, writer.WrittenSpan.ToArray());
}
}
}
private static bool TryParse(byte[] data, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out RpcMessage? message)
{
try
{
message = JsonSerializer.Deserialize(data, FleetRpcContext.Default.RpcMessage);
return message is not null;
}
catch (JsonException)
{
message = null;
return false;
}
}
private static bool TryDecode(string text, out byte[] data)
{
try
{
data = Convert.FromBase64String(text);
return true;
}
catch (FormatException)
{
data = [];
return false;
}
}
private static string StatusName(NodeStatus status) => status switch
{
NodeStatus.Active => "active",
NodeStatus.Pending => "pending",
_ => "disabled",
};
private sealed record Frame(bool IsText, byte[] Data);
}
[System.Text.Json.Serialization.JsonSerializable(typeof(EmptyObject))]
internal sealed partial class FleetEmptyObjectContext : System.Text.Json.Serialization.JsonSerializerContext;
internal sealed class EmptyObject;
internal static class FleetEmptyObject
{
public static System.Text.Json.Serialization.Metadata.JsonTypeInfo<EmptyObject> Info => FleetEmptyObjectContext.Default.EmptyObject;
}
@@ -1,9 +1,6 @@
using System.Buffers;
using System.Collections.Concurrent;
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Threading.Channels;
using System.Text.Json;
using Fleet.Core;
using Fleet.Identity;
using Fleet.Rpc;
@@ -14,7 +11,7 @@ namespace Fleet.Server;
/// 一个节点的 WebSocket 会话:挑战 → 签名认证 → hello → 状态推送 / 收件箱 / 产品 RPC。
/// 收到的消息按顺序处理,所以产品的处理函数不能等待“向同一个节点发起的请求”,否则会死锁。
/// </summary>
internal sealed class NodeConnection : IDisposable
internal sealed partial class NodeConnection : IDisposable
{
private static readonly TimeSpan s_authTimeout = TimeSpan.FromSeconds(15);
private static readonly TimeSpan s_presenceRefresh = TimeSpan.FromSeconds(60);
@@ -28,12 +25,14 @@ internal sealed class NodeConnection : IDisposable
private readonly Channel<bool> _stateSignal = Channel.CreateBounded<bool>(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite });
private readonly Dictionary<string, long> _lastPushedVersion = new(StringComparer.Ordinal);
private readonly CancellationTokenSource _lifetime = new();
private LinkCipher? _cipher;
private long _nextRequestId;
private long _lastPresenceTicks;
private NodeRecord? _node;
private NodeSessionInfo? _session;
private string? _closeReason;
private bool _helloDone;
private int _violations;
public NodeConnection(FleetLinkHost host, WebSocket socket)
{
@@ -137,593 +136,8 @@ internal sealed class NodeConnection : IDisposable
public void Dispose()
{
_cipher?.Dispose();
_lifetime.Dispose();
_sendLock.Dispose();
}
public void SignalStateChanged() => _stateSignal.Writer.TryWrite(true);
/// <summary>
/// 服务端发起的关闭:发送 Close 帧,等对端确认之后接收循环自然结束。
/// 注意不能直接取消接收的令牌——取消 <c>ReceiveAsync</c> 会中止整个 WebSocket,对端拿不到关闭原因;取消只作为超时兜底。
/// </summary>
public async ValueTask CloseAsync(string reason)
{
_closeReason ??= reason;
try
{
await _sendLock.WaitAsync(CancellationToken.None);
}
catch (ObjectDisposedException)
{
return;
}
try
{
if (_socket.State == WebSocketState.Open)
{
await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, reason, CancellationToken.None);
}
}
catch (WebSocketException)
{
// 对端已经断开
}
finally
{
_sendLock.Release();
}
_lifetime.CancelAfter(s_closeTimeout);
}
public async ValueTask RevokeAsync(string reason, CancellationToken ct)
{
await SendAsync(RpcMessage.Notification(FleetMethods.Revoked, new RevokedParams { Reason = reason }, FleetRpcContext.Default.RevokedParams), ct);
await CloseAsync(reason);
}
public async ValueTask<bool> SendAsync(RpcMessage message, CancellationToken ct)
{
byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage);
try
{
await _sendLock.WaitAsync(ct);
}
catch (ObjectDisposedException)
{
return false;
}
try
{
if (_socket.State != WebSocketState.Open)
{
return false;
}
await _socket.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct);
return true;
}
catch (WebSocketException)
{
return false;
}
finally
{
_sendLock.Release();
}
}
public async ValueTask<Result<JsonElement>> RequestAsync(string method, JsonElement parameters, CancellationToken ct)
{
long id = Interlocked.Increment(ref _nextRequestId);
var waiter = new TaskCompletionSource<RpcMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
_pending[id] = waiter;
try
{
var request = new RpcMessage { Id = id, Method = method, Params = parameters };
if (!await SendAsync(request, ct))
{
return Result<JsonElement>.Fail(FleetErrors.NodeOffline, "node is not connected");
}
using var timeout = new CancellationTokenSource(_host.Options.RequestTimeout, _host.Time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
RpcMessage response = await waiter.Task.WaitAsync(linked.Token);
return response.Error is not null
? Result<JsonElement>.Fail(response.Error.ToFleetError())
: Result<JsonElement>.Ok(response.Result ?? default);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
return Result<JsonElement>.Fail(FleetErrors.Timeout, $"{method} timed out");
}
finally
{
_pending.TryRemove(id, out _);
}
}
// ───────────────────────── 认证 ─────────────────────────
private async Task<bool> AuthenticateAsync(CancellationToken ct)
{
string nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
long expiresAtMs = _host.Time.GetUtcNow().Add(_host.Options.ChallengeTtl).ToUnixTimeMilliseconds();
await SendAsync(
RpcMessage.Notification(FleetMethods.Challenge, new ChallengeParams { Nonce = nonce, ExpiresAtMs = expiresAtMs }, FleetRpcContext.Default.ChallengeParams),
ct);
using var timeout = new CancellationTokenSource(s_authTimeout, _host.Time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
Frame? frame;
try
{
frame = await ReceiveFrameAsync(linked.Token);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
await RejectAsync(null, "auth timeout", ct);
return false;
}
if (frame is not { IsText: true } || !TryParse(frame.Data, out RpcMessage? message) || message.Method != FleetMethods.Auth || message.Id is null)
{
await RejectAsync(null, "expected fleet.auth", ct);
return false;
}
AuthParams? auth = null;
try
{
auth = message.Params?.Deserialize(FleetRpcContext.Default.AuthParams);
}
catch (JsonException)
{
// 按缺失处理
}
NodeRecord? node = auth is null ? null : await _host.Store.GetNodeAsync(new NodeId(auth.NodeId), ct);
bool signatureOk = node is not null
&& _host.Time.GetUtcNow().ToUnixTimeMilliseconds() <= expiresAtMs
&& TryDecode(auth!.Signature, out byte[] signature)
&& NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.Auth, auth.NodeId, nonce);
if (!signatureOk)
{
// 节点不存在与签名错误返回同样的错误,不泄露节点是否存在。
await RejectAsync(message.Id, "authentication failed", ct);
return false;
}
_node = node;
if (node!.Status == NodeStatus.Disabled)
{
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.NodeDisabled, "node is disabled")), ct);
await RevokeAsync("disabled", ct);
return false;
}
_session = new NodeSessionInfo(node.Id, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), node.NodeVersion, [], null);
await SendAsync(
RpcMessage.Success(message.Id.Value, new AuthResult { Status = StatusName(node.Status) }, FleetRpcContext.Default.AuthResult),
ct);
return true;
}
private async Task RejectAsync(long? id, string reason, CancellationToken ct)
{
FleetLog.AuthRejected(_host.Logger, reason);
_closeReason ??= reason;
if (id is not null)
{
await SendAsync(RpcMessage.Failure(id, new FleetError(FleetErrors.Unauthorized, "authentication failed")), ct);
}
if (_socket.State == WebSocketState.Open)
{
try
{
await _socket.CloseOutputAsync(WebSocketCloseStatus.PolicyViolation, "unauthorized", ct);
}
catch (WebSocketException)
{
// 对端已经断开
}
}
}
// ───────────────────────── 接收循环 ─────────────────────────
private async Task ReceiveLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
using var idle = new CancellationTokenSource(_host.Options.HeartbeatTimeout, _host.Time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, idle.Token);
Frame? frame;
try
{
frame = await ReceiveFrameAsync(linked.Token);
}
catch (OperationCanceledException) when (idle.IsCancellationRequested && !ct.IsCancellationRequested)
{
_closeReason ??= "heartbeat timeout";
return;
}
if (frame is null)
{
_closeReason ??= "closed by peer";
return;
}
if (frame.IsText)
{
if (TryParse(frame.Data, out RpcMessage? message))
{
await HandleMessageAsync(message, ct);
}
}
else
{
await HandleBinaryAsync(frame.Data, ct);
}
}
}
private async Task HandleMessageAsync(RpcMessage message, CancellationToken ct)
{
if (message.IsResponse)
{
if (_pending.TryGetValue(message.Id!.Value, out TaskCompletionSource<RpcMessage>? waiter))
{
waiter.TrySetResult(message);
}
return;
}
switch (message.Method)
{
case FleetMethods.Hello:
await HandleHelloAsync(message, ct);
return;
case FleetMethods.Heartbeat:
await HandleHeartbeatAsync(message, ct);
return;
}
if (_node!.Status != NodeStatus.Active)
{
if (message.Id is not null)
{
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.NodePending, "node is not approved yet")), ct);
}
return;
}
var context = new NodeCallContext(_node, _session!, _host);
RpcMessage? response;
try
{
response = await _host.Router.DispatchAsync(context, message, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
if (response is not null)
{
await SendAsync(response, ct);
}
}
private async Task HandleHelloAsync(RpcMessage message, CancellationToken ct)
{
HelloParams? hello = null;
try
{
hello = message.Params?.Deserialize(FleetRpcContext.Default.HelloParams);
}
catch (JsonException)
{
// 按缺失处理
}
if (hello is null)
{
if (message.Id is not null)
{
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.InvalidParams, "invalid hello")), ct);
}
return;
}
long nowMs = _host.Time.GetUtcNow().ToUnixTimeMilliseconds();
_session = _session! with
{
NodeVersion = hello.NodeVersion,
Capabilities = hello.Capabilities,
HelloMetaJson = hello.Meta?.GetRawText(),
};
_node = _node! with { NodeVersion = hello.NodeVersion };
_helloDone = true;
await UpdatePresenceAsync(online: true, ct);
await FireAsync(NodeEventKind.Hello, ct, hello);
if (message.Id is not null)
{
await SendAsync(
RpcMessage.Success(
message.Id.Value,
new HelloResult { ServerTimeMs = nowMs, ClockOffsetMs = hello.NowUtcMs - nowMs },
FleetRpcContext.Default.HelloResult),
ct);
}
SignalStateChanged();
}
private async Task HandleHeartbeatAsync(RpcMessage message, CancellationToken ct)
{
JsonElement? extra = null;
try
{
extra = message.Params?.Deserialize(FleetRpcContext.Default.HeartbeatParams)?.Extra;
}
catch (JsonException)
{
// 忽略格式不正确的附加字段
}
long now = _host.Time.GetTimestamp();
if (_host.Time.GetElapsedTime(Interlocked.Read(ref _lastPresenceTicks), now) >= s_presenceRefresh)
{
await UpdatePresenceAsync(online: true, ct);
}
await FireAsync(NodeEventKind.Heartbeat, ct, hello: null, extra);
if (message.Id is not null)
{
await SendAsync(new RpcMessage { Id = message.Id, Result = JsonSerializer.SerializeToElement(new EmptyObject(), FleetEmptyObject.Info) }, ct);
}
}
private async Task HandleBinaryAsync(byte[] data, CancellationToken ct)
{
if (_node!.Status != NodeStatus.Active
|| !OutboxFrame.TryParse(data, out byte channel, out Guid msgId, out ReadOnlyMemory<byte> payload)
|| !_host.Inboxes.TryGetValue(channel, out IInbox? inbox))
{
return;
}
InboxOutcome outcome;
try
{
outcome = await inbox.HandleAsync(_node, msgId, payload, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
// 没有持久化就不确认,节点会重发。
FleetLog.InboxFailed(_host.Logger, ex, _node.Id.Value, channel);
return;
}
if (outcome == InboxOutcome.Rejected)
{
return;
}
var ack = new OutboxAckParams();
string id = msgId.ToString();
if (outcome == InboxOutcome.Duplicate)
{
ack.Duplicates.Add(id);
}
else
{
ack.Ids.Add(id);
}
await SendAsync(RpcMessage.Notification(FleetMethods.OutboxAck, ack, FleetRpcContext.Default.OutboxAckParams), ct);
}
// ───────────────────────── 状态推送 ─────────────────────────
private async Task StatePushLoopAsync(CancellationToken ct)
{
while (await _stateSignal.Reader.WaitToReadAsync(ct))
{
_stateSignal.Reader.TryRead(out _);
bool complete = await PushStateAsync(ct);
if (!complete)
{
await Task.Delay(s_statePushRetry, _host.Time, ct);
SignalStateChanged();
}
}
}
private async Task<bool> PushStateAsync(CancellationToken ct)
{
NodeRecord? fresh = await _host.Store.GetNodeAsync(_node!.Id, ct);
if (fresh is null)
{
return true;
}
_node = fresh with { NodeVersion = _node.NodeVersion ?? fresh.NodeVersion };
if (fresh.Status == NodeStatus.Disabled)
{
await RevokeAsync("disabled", ct);
return true;
}
if (fresh.Status != NodeStatus.Active || !_helloDone)
{
// pending 不下发状态;还没有 hello 的连接等 hello 之后再推送。
return true;
}
bool complete = true;
foreach (IStateProvider provider in _host.StateProviders)
{
StateDocument? document = await provider.GetAsync(_node, ct);
if (document is null || (_lastPushedVersion.TryGetValue(provider.Type, out long last) && last == document.Version))
{
continue;
}
Result<JsonElement> raw = await RequestAsync(
FleetMethods.StateSync,
JsonSerializer.SerializeToElement(
new StateSyncParams { Type = provider.Type, Version = document.Version, Doc = document.Doc },
FleetRpcContext.Default.StateSyncParams),
ct);
if (!raw.TryGet(out JsonElement value, out FleetError? error))
{
FleetLog.StatePushFailed(_host.Logger, _node.Id.Value, provider.Type, error.Code.Value);
complete = false;
continue;
}
StateSyncResult? result = null;
try
{
result = value.Deserialize(FleetRpcContext.Default.StateSyncResult);
}
catch (JsonException)
{
// 按缺失处理
}
if (result is null)
{
complete = false;
continue;
}
_lastPushedVersion[provider.Type] = document.Version;
await provider.OnAppliedAsync(_node, document.Version, result, ct);
}
return complete;
}
// ───────────────────────── 帮助方法 ─────────────────────────
private async Task UpdatePresenceAsync(bool online, CancellationToken ct)
{
Interlocked.Exchange(ref _lastPresenceTicks, _host.Time.GetTimestamp());
await _host.Store.UpdatePresenceAsync(
_node!.Id,
new Presence(online, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), _session?.NodeVersion, _session?.Capabilities ?? [], _session?.HelloMetaJson),
ct);
}
private async Task FireAsync(NodeEventKind kind, CancellationToken ct, HelloParams? hello = null, JsonElement? extra = null)
{
var nodeEvent = new NodeEvent(kind, _node!, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), hello, extra);
foreach (INodeEventSink sink in _host.Sinks)
{
try
{
await sink.OnEventAsync(nodeEvent, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
// 产品的事件处理失败不能影响连接。
FleetLog.SinkFailed(_host.Logger, ex, kind.ToString(), _node!.Id.Value);
}
}
}
private async ValueTask<Frame?> ReceiveFrameAsync(CancellationToken ct)
{
var writer = new ArrayBufferWriter<byte>(1024);
while (true)
{
Memory<byte> buffer = writer.GetMemory(4096);
ValueWebSocketReceiveResult result = await _socket.ReceiveAsync(buffer, ct);
if (result.MessageType == WebSocketMessageType.Close)
{
return null;
}
writer.Advance(result.Count);
if (writer.WrittenCount > _host.Options.MaxFrameBytes)
{
_closeReason ??= "frame too large";
await _socket.CloseOutputAsync(WebSocketCloseStatus.MessageTooBig, "frame too large", CancellationToken.None);
return null;
}
if (result.EndOfMessage)
{
return new Frame(result.MessageType == WebSocketMessageType.Text, writer.WrittenSpan.ToArray());
}
}
}
private static bool TryParse(byte[] data, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out RpcMessage? message)
{
try
{
message = JsonSerializer.Deserialize(data, FleetRpcContext.Default.RpcMessage);
return message is not null;
}
catch (JsonException)
{
message = null;
return false;
}
}
private static bool TryDecode(string text, out byte[] data)
{
try
{
data = Convert.FromBase64String(text);
return true;
}
catch (FormatException)
{
data = [];
return false;
}
}
private static string StatusName(NodeStatus status) => status switch
{
NodeStatus.Active => "active",
NodeStatus.Pending => "pending",
_ => "disabled",
};
private sealed record Frame(bool IsText, byte[] Data);
}
[System.Text.Json.Serialization.JsonSerializable(typeof(EmptyObject))]
internal sealed partial class FleetEmptyObjectContext : System.Text.Json.Serialization.JsonSerializerContext;
internal sealed class EmptyObject;
internal static class FleetEmptyObject
{
public static System.Text.Json.Serialization.Metadata.JsonTypeInfo<EmptyObject> Info => FleetEmptyObjectContext.Default.EmptyObject;
}
@@ -9,6 +9,7 @@
<ProjectReference Include="..\..\src\Fleet.Identity\Fleet.Identity.csproj" />
<ProjectReference Include="..\..\src\Fleet.Rpc\Fleet.Rpc.csproj" />
<ProjectReference Include="..\..\src\Fleet.Link\Fleet.Link.csproj" />
<ProjectReference Include="..\..\src\Fleet.Hosting\Fleet.Hosting.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
@@ -67,7 +67,7 @@ public sealed class IdentityAndRpcTests
[Fact]
public async Task Router_DispatchesTypedRequests_AndReportsStructuredErrors()
{
var router = new RpcRouter<string>()
var router = new RpcRouter<string>(RpcSide.Listener, TestTables.Of(TestTables.Req("test.add"), TestTables.Req("test.boom"), TestTables.Req("test.nope")))
.On("test.add", EchoContext.Default.AddParams, EchoContext.Default.AddResult,
(caller, p, _) => ValueTask.FromResult(p.A < 0 ? Result<AddResult>.Fail(FleetErrors.Forbidden, "negative") : Result<AddResult>.Ok(new AddResult { Sum = p.A + p.B, Caller = caller })))
.On<AddParams, AddResult>("test.boom", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new InvalidOperationException("secret detail"));
@@ -92,7 +92,7 @@ public sealed class IdentityAndRpcTests
public async Task Router_HandlerExceptions_AreContained_AndNeverLeakDetails()
{
string? logged = null;
var router = new RpcRouter<string>((method, ex) => logged = method + ":" + ex.Message)
var router = new RpcRouter<string>(RpcSide.Listener, TestTables.Of(TestTables.Req("test.boom")), onUnhandled: (method, ex) => logged = method + ":" + ex.Message)
.On<AddParams, AddResult>("test.boom", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new InvalidOperationException("secret detail"));
RpcMessage reply = (await router.DispatchAsync("a", Request(1, "test.boom", """{"a":1,"b":1}"""), CancellationToken.None))!;
@@ -105,7 +105,7 @@ public sealed class IdentityAndRpcTests
public async Task Router_Notifications_ProduceNoResponse_EvenOnFailure()
{
int calls = 0;
var router = new RpcRouter<string>().OnNotify("test.note", EchoContext.Default.AddParams, (_, _, _) =>
var router = new RpcRouter<string>(RpcSide.Listener, TestTables.Of(TestTables.Note("test.note"), TestTables.Note("test.unknown"))).OnNotify("test.note", EchoContext.Default.AddParams, (_, _, _) =>
{
calls++;
return ValueTask.CompletedTask;
@@ -0,0 +1,112 @@
using System.Net;
using Fleet.Identity;
namespace Fleet.Tests;
public sealed class KnockTests
{
private sealed class Clock : TimeProvider
{
public DateTimeOffset Now { get; set; } = new(2026, 10, 7, 12, 0, 0, TimeSpan.Zero);
public override DateTimeOffset GetUtcNow() => Now;
}
[Fact]
public void PairKey_IsTheSameOnBothSides_AndDiffersPerPair()
{
using NodeIdentity probe = NodeIdentity.Generate();
using NodeIdentity agent = NodeIdentity.Generate();
using NodeIdentity other = NodeIdentity.Generate();
byte[] fromProbe = Knock.DeriveKey(probe, agent.PublicKeySpki);
byte[] fromAgent = Knock.DeriveKey(agent, probe.PublicKeySpki);
Assert.Equal(fromProbe, fromAgent);
Assert.Equal(32, fromProbe.Length);
Assert.NotEqual(fromProbe, Knock.DeriveKey(other, agent.PublicKeySpki));
}
[Fact]
public void Packet_IsAcceptedWithinTheWindowAndOneWindowOfSkew_ButNotBeyond()
{
byte[] key = new byte[32];
long now = new DateTimeOffset(2026, 10, 7, 12, 0, 15, TimeSpan.Zero).ToUnixTimeMilliseconds();
byte[] packet = Knock.Build(key, now);
Assert.Equal(Knock.PacketBytes, packet.Length);
Assert.True(Knock.Check(key, packet, now));
Assert.True(Knock.Check(key, packet, now + (Knock.WindowSeconds * 1000)));
Assert.True(Knock.Check(key, packet, now - (Knock.WindowSeconds * 1000)));
Assert.False(Knock.Check(key, packet, now + (3 * Knock.WindowSeconds * 1000)));
Assert.False(Knock.Check(key, packet, now - (3 * Knock.WindowSeconds * 1000)));
}
[Fact]
public void Packet_WithAWrongKeyATamperedByteOrTheWrongLength_IsRejected()
{
byte[] key = new byte[32];
long now = 1_700_000_000_000;
byte[] packet = Knock.Build(key, now);
Assert.False(Knock.Check(new byte[32] { 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, packet, now));
foreach (int index in new[] { 0, 15, 16, 31 })
{
byte[] tampered = (byte[])packet.Clone();
tampered[index] ^= 0x01;
Assert.False(Knock.Check(key, tampered, now), $"byte {index}");
}
Assert.False(Knock.Check(key, packet.AsSpan(0, 31), now));
Assert.False(Knock.Check(key, [], now));
}
[Fact]
public void Gate_AdmitsRosterMembersOnce_AndRejectsReplaysStrangersAndExpiredPackets()
{
using NodeIdentity agent = NodeIdentity.Generate();
using NodeIdentity probe = NodeIdentity.Generate();
using NodeIdentity stranger = NodeIdentity.Generate();
var roster = new List<byte[]> { probe.PublicKeySpki };
var gate = new KnockGate(agent, () => roster, new Clock());
long now = 1_700_000_000_000;
byte[] packet = Knock.Build(Knock.DeriveKey(probe, agent.PublicKeySpki), now);
Assert.True(gate.Accept(packet, now));
Assert.False(gate.Accept(packet, now), "the same nonce cannot be used twice");
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(stranger, agent.PublicKeySpki), now), now), "not on the roster");
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(probe, agent.PublicKeySpki), now - 10 * 60_000), now), "expired");
// 名册变了:新成员马上能进,被移除的马上进不来
using NodeIdentity second = NodeIdentity.Generate();
roster.Add(second.PublicKeySpki);
Assert.True(gate.Accept(Knock.Build(Knock.DeriveKey(second, agent.PublicKeySpki), now), now));
roster.RemoveAt(0);
Assert.False(gate.Accept(Knock.Build(Knock.DeriveKey(probe, agent.PublicKeySpki), now), now));
}
[Fact]
public async Task Gate_RateLimitsEachSource_AndReadsAtMost32Bytes()
{
using NodeIdentity agent = NodeIdentity.Generate();
using NodeIdentity probe = NodeIdentity.Generate();
var clock = new Clock();
var gate = new KnockGate(agent, () => [probe.PublicKeySpki], clock, attemptsPerPeriod: 3);
IPAddress noisy = IPAddress.Parse("203.0.113.9");
IPAddress quiet = IPAddress.Parse("203.0.113.10");
byte[] key = Knock.DeriveKey(probe, agent.PublicKeySpki);
for (int i = 0; i < 3; i++)
{
Assert.False(await gate.AdmitAsync(new MemoryStream(new byte[Knock.PacketBytes]), noisy, default));
}
// 超过限额:连有效的敲门包也不读、不算
byte[] valid = Knock.Build(key, clock.Now.ToUnixTimeMilliseconds());
Assert.False(await gate.AdmitAsync(new MemoryStream(valid), noisy, default));
Assert.True(await gate.AdmitAsync(new MemoryStream(valid), quiet, default));
// 过了统计周期,恢复
clock.Now += TimeSpan.FromSeconds(11);
Assert.True(await gate.AdmitAsync(new MemoryStream(Knock.Build(key, clock.Now.ToUnixTimeMilliseconds())), noisy, default));
// 流在 32 字节之前就结束:拒绝,不抛异常
Assert.False(await gate.AdmitAsync(new MemoryStream(new byte[10]), quiet, default));
}
}
+79 -18
View File
@@ -227,8 +227,9 @@ public sealed class LinkTests
{
(TlsFrameTransport left, TlsFrameTransport right) = await PairAsync();
RpcSession<string>? rightSession = null;
var leftRouter = new RpcRouter<string>().On("left.double", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result<Number>.Ok(new Number { Value = p.Value * 2 })));
var rightRouter = new RpcRouter<string>().On(
MethodTable table = TestTables.Of(TestTables.Req("left.double", RpcSide.Listener), TestTables.Req("right.relay"), TestTables.Req("right.unhandled"));
var leftRouter = new RpcRouter<string>(RpcSide.Dialer, table).On("left.double", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result<Number>.Ok(new Number { Value = p.Value * 2 })));
var rightRouter = new RpcRouter<string>(RpcSide.Listener, table).On(
"right.relay", LinkJson.Default.Number, LinkJson.Default.Number,
async (_, p, ct) =>
{
@@ -248,7 +249,7 @@ public sealed class LinkTests
Result<Number> result = await leftSession.RequestAsync("right.relay", new Number { Value = 21 }, LinkJson.Default.Number, LinkJson.Default.Number, default);
Assert.Equal(43, result.Value.Value);
Result<JsonElement> unknown = await leftSession.RequestAsync("nope", null, default);
Result<JsonElement> unknown = await leftSession.RequestAsync("right.unhandled", null, default);
Assert.Equal("fleet_unsupported", unknown.Error.Code.Value);
await leftSession.CloseAsync("bye", default);
@@ -262,13 +263,13 @@ public sealed class LinkTests
{
(TlsFrameTransport left, TlsFrameTransport right) = await PairAsync();
await using var silent = right; // 对端不读也不回
await using var session = new RpcSession<string>(left, new RpcRouter<string>(), "x", TimeProvider.System, new RpcSessionOptions { RequestTimeout = TimeSpan.FromMilliseconds(200) });
await using var session = new RpcSession<string>(left, new RpcRouter<string>(RpcSide.Dialer, TestTables.Of(TestTables.Req("test.any"))), "x", TimeProvider.System, new RpcSessionOptions { RequestTimeout = TimeSpan.FromMilliseconds(200) });
Task run = session.RunAsync(default);
Result<JsonElement> timedOut = await session.RequestAsync("any", null, default);
Result<JsonElement> timedOut = await session.RequestAsync("test.any", null, default);
Assert.Equal(FleetErrors.Timeout, timedOut.Error.Code);
Task<Result<JsonElement>> pending = session.RequestAsync("any", null, default, TimeSpan.FromSeconds(30)).AsTask();
Task<Result<JsonElement>> pending = session.RequestAsync("test.any", null, default, TimeSpan.FromSeconds(30)).AsTask();
await silent.CloseAsync("gone", default);
Assert.Equal(FleetErrors.Timeout, (await pending.WaitAsync(TimeSpan.FromSeconds(5))).Error.Code);
await run.WaitAsync(TimeSpan.FromSeconds(5));
@@ -279,12 +280,20 @@ public sealed class LinkTests
private sealed class PeerFixture : IAsyncDisposable
{
private readonly CancellationTokenSource _stop = new();
private readonly NodeIdentity _identity;
public PeerFixture(Func<string, byte[]?> lookup, Func<PeerSessionInfo, IRpcTransport, CancellationToken, Task>? onSession = null)
{
Certificate = NodeCertificate.LoadPfx(NodeCertificate.CreateSelfSignedPfx("agent", TimeSpan.FromDays(30), TimeProvider.System));
_identity = NodeIdentity.FromPkcs8(Certificate.GetECDsaPrivateKey()!.ExportPkcs8PrivateKey());
Listener = new PeerListener(
new PeerListenerOptions { Listen = new IPEndPoint(IPAddress.Loopback, 0), Certificate = Certificate, LookupPeerKey = lookup },
new PeerListenerOptions
{
Listen = new IPEndPoint(IPAddress.Loopback, 0),
Certificate = Certificate,
LookupPeerKey = lookup,
Knock = new KnockGate(_identity, () => KnockPeers, TimeProvider.System),
},
onSession ?? ((_, transport, ct) => KeepOpenAsync(transport, ct)),
NullNodeLog.Instance, TimeProvider.System);
Task = Listener.RunAsync(_stop.Token);
@@ -298,10 +307,17 @@ public sealed class LinkTests
public byte[] Spki => NodeCertificate.SpkiFingerprint(Certificate);
/// <summary>Agent 的 TLS 证书公钥:Probe 用它固定证书、派生敲门密钥。</summary>
public byte[] PublicKey => Certificate.PublicKey.ExportSubjectPublicKeyInfo();
/// <summary>名册:能敲开门的 Probe 公钥。</summary>
public List<byte[]> KnockPeers { get; } = [];
public async ValueTask DisposeAsync()
{
await _stop.CancelAsync();
await Task;
_identity.Dispose();
Certificate.Dispose();
_stop.Dispose();
}
@@ -324,9 +340,10 @@ public sealed class LinkTests
seen.TrySetResult(info);
return Task.Delay(Timeout.Infinite, ct).ContinueWith(_ => { }, CancellationToken.None);
});
agent.KnockPeers.Add(probe.PublicKeySpki);
await agent.Listener.Started.Task;
(PeerSessionInfo client, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
(PeerSessionInfo client, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
await using (transport)
{
PeerSessionInfo server = await seen.Task.WaitAsync(TimeSpan.FromSeconds(5));
@@ -342,17 +359,18 @@ public sealed class LinkTests
using NodeIdentity probe = NodeIdentity.Generate();
using NodeIdentity impostor = NodeIdentity.Generate();
await using var agent = new PeerFixture(id => id == "prb_1" ? probe.PublicKeySpki : null);
agent.KnockPeers.AddRange([probe.PublicKeySpki, impostor.PublicKeySpki]); // 敲门通过之后,名册外的 ID 和伪造的签名仍然被拒绝
await agent.Listener.Started.Task;
InvalidOperationException unknown = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_unknown", null, 1 << 20, TimeProvider.System, default));
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_unknown", null, 1 << 20, TimeProvider.System, default));
InvalidOperationException forged = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, impostor, "prb_1", null, 1 << 20, TimeProvider.System, default));
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, impostor, "prb_1", null, 1 << 20, TimeProvider.System, default));
Assert.Equal(unknown.Message, forged.Message);
Assert.Contains("fleet_unauthorized", forged.Message);
// 正主仍然可以连接
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
await ok.DisposeAsync();
}
@@ -361,18 +379,21 @@ public sealed class LinkTests
{
using NodeIdentity probe = NodeIdentity.Generate();
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
agent.KnockPeers.Add(probe.PublicKeySpki);
await agent.Listener.Started.Task;
byte[] wrong = SHA256.HashData("another certificate"u8);
await Assert.ThrowsAnyAsync<AuthenticationException>(async () =>
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, wrong, probe, "prb_1", null, 1 << 20, TimeProvider.System, default));
// 公钥不对:敲门密钥对不上,Agent 直接断开(拿到的不是证书,连不是它的证书都看不到)
using NodeIdentity another = NodeIdentity.Generate();
await Assert.ThrowsAnyAsync<Exception>(async () =>
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, another.PublicKeySpki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default));
}
[Fact]
public async Task PeerSession_CarriesRpcBothWays_AfterTheHandshake()
{
using NodeIdentity probe = NodeIdentity.Generate();
var router = new RpcRouter<string>().On("agent.echo", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result<Number>.Ok(new Number { Value = p.Value + 1 })));
MethodTable table = TestTables.Of(TestTables.Req("agent.echo"));
var router = new RpcRouter<string>(RpcSide.Listener, table).On("agent.echo", LinkJson.Default.Number, LinkJson.Default.Number, (_, p, _) => ValueTask.FromResult(Result<Number>.Ok(new Number { Value = p.Value + 1 })));
await using var agent = new PeerFixture(
_ => probe.PublicKeySpki,
async (info, transport, ct) =>
@@ -380,10 +401,11 @@ public sealed class LinkTests
await using var session = new RpcSession<string>(transport, router, info.PeerId, TimeProvider.System);
await session.RunAsync(ct);
});
agent.KnockPeers.Add(probe.PublicKeySpki);
await agent.Listener.Started.Task;
(_, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
await using var client = new RpcSession<string>(transport, new RpcRouter<string>(), "probe", TimeProvider.System);
(_, TlsFrameTransport transport) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
await using var client = new RpcSession<string>(transport, new RpcRouter<string>(RpcSide.Dialer, table), "probe", TimeProvider.System);
Task run = client.RunAsync(default);
Result<Number> result = await client.RequestAsync("agent.echo", new Number { Value = 41 }, LinkJson.Default.Number, LinkJson.Default.Number, default);
Assert.Equal(42, result.Value.Value);
@@ -396,6 +418,7 @@ public sealed class LinkTests
{
using NodeIdentity probe = NodeIdentity.Generate();
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
agent.KnockPeers.Add(probe.PublicKeySpki);
await agent.Listener.Started.Task;
// 不说 TLS 的连接:握手失败,监听继续
@@ -405,7 +428,45 @@ public sealed class LinkTests
await garbage.GetStream().WriteAsync("GET / HTTP/1.1\r\n\r\n"u8.ToArray());
}
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.Spki, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
await ok.DisposeAsync();
}
[Fact]
public async Task PeerListener_WritesNothingToStrangers_NotEvenATlsServerHello()
{
using NodeIdentity probe = NodeIdentity.Generate();
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
agent.KnockPeers.Add(probe.PublicKeySpki);
await agent.Listener.Started.Task;
// 一个像样的 TLS ClientHello 开头、随机数据、什么都不发:都拿不到任何字节(连接被直接关闭)
foreach (byte[] payload in new[] { new byte[] { 0x16, 0x03, 0x01, 0x00, 0x05, 0x01, 0x00, 0x00, 0x01, 0x00 }.Concat(RandomNumberGenerator.GetBytes(64)).ToArray(), RandomNumberGenerator.GetBytes(32), [] })
{
using var scanner = new TcpClient();
await scanner.ConnectAsync(IPAddress.Loopback, agent.Listener.Port);
NetworkStream stream = scanner.GetStream();
if (payload.Length > 0)
{
await stream.WriteAsync(payload);
}
byte[] buffer = new byte[64];
int read;
try
{
read = await stream.ReadAsync(buffer).AsTask().WaitAsync(TimeSpan.FromSeconds(10));
}
catch (IOException)
{
read = 0; // 连接被重置也是「什么都没收到」
}
Assert.Equal(0, read);
}
// 名册里的 Probe 仍然进得来
(_, TlsFrameTransport ok) = await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, agent.PublicKey, probe, "prb_1", null, 1 << 20, TimeProvider.System, default);
await ok.DisposeAsync();
}
@@ -0,0 +1,200 @@
using System.Text.Json;
using System.Threading.Channels;
using Fleet.Core;
using Fleet.Rpc;
namespace Fleet.Tests;
/// <summary>协议方向声明:路由器是唯一卡口,注册、分发、发送三处都核对声明表。</summary>
public sealed class MethodDirectionTests
{
private static readonly MethodTable s_table = TestTables.Of(
MethodSpec.Request("t.ping", RpcSide.Node),
MethodSpec.Notification("t.note", RpcSide.Node, "agent"),
MethodSpec.Request("t.push", RpcSide.Server),
MethodSpec.Request("t.inline", RpcSide.Node).AsInline(),
// 同一个方法名在对等面里另有发起方:按(平面,名字)区分
MethodSpec.Request("t.ping", RpcSide.Listener));
private static readonly JsonElement s_empty = JsonDocument.Parse("{}").RootElement.Clone();
private static RpcMessage Request(string method) => new() { Id = 1, Method = method, Params = s_empty };
private static RpcMessage Notification(string method) => new() { Method = method, Params = s_empty };
private static RpcRouter<string> Server(string? localRole = null) => new(RpcSide.Server, s_table, localRole, callerRole: role => role);
[Fact]
public void Registration_RefusesUndeclaredMethods_WrongDirection_WrongKind_AndWrongRole()
{
RpcRouter<string> server = Server();
Assert.Throws<InvalidOperationException>(() => server.On("t.unknown", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new NotSupportedException()));
// t.push 是服务端发给节点的:服务端这一侧不能处理它
Assert.Throws<InvalidOperationException>(() => server.On("t.push", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new NotSupportedException()));
// t.ping 声明成请求:不能按通知注册
Assert.Throws<InvalidOperationException>(() => server.OnNotify("t.ping", EchoContext.Default.AddParams, (_, _, _) => ValueTask.CompletedTask));
// 节点这一侧:t.note 只给 agent 角色,probe 注册不了;t.push 是它该处理的
var probe = new RpcRouter<string>(RpcSide.Node, s_table, "probe");
probe.On("t.push", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => throw new NotSupportedException());
Assert.Throws<InvalidOperationException>(() => new RpcRouter<string>(RpcSide.Server, s_table, "probe").OnNotify("t.note", EchoContext.Default.AddParams, (_, _, _) => ValueTask.CompletedTask));
server.On("t.ping", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => ValueTask.FromResult(Result<AddResult>.Ok(new AddResult())));
}
[Fact]
public async Task Dispatch_RefusesWhatThePeerMayNotInitiate_AndReportsAViolation()
{
int calls = 0;
RpcRouter<string> server = Server()
.On("t.ping", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) =>
{
calls++;
return ValueTask.FromResult(Result<AddResult>.Ok(new AddResult()));
})
.OnNotify("t.note", EchoContext.Default.AddParams, (_, _, _) =>
{
calls++;
return ValueTask.CompletedTask;
});
// 正常
RpcDispatch ok = await server.DispatchCheckedAsync("agent", Request("t.ping"), default);
Assert.Null(ok.Violation);
Assert.NotNull(ok.Response!.Result);
// 没有声明的方法
RpcDispatch undeclared = await server.DispatchCheckedAsync("agent", Request("t.nope"), default);
Assert.NotNull(undeclared.Violation);
Assert.Equal("fleet_protocol_violation", undeclared.Response!.Error!.Data!.Code);
// 节点冒充服务端:t.push 只能由服务端发起
RpcDispatch backwards = await server.DispatchCheckedAsync("agent", Request("t.push"), default);
Assert.NotNull(backwards.Violation);
Assert.Equal("fleet_protocol_violation", backwards.Response!.Error!.Data!.Code);
// 请求和通知用反了
Assert.NotNull((await server.DispatchCheckedAsync("agent", Notification("t.ping"), default)).Violation);
RpcDispatch asRequest = await server.DispatchCheckedAsync("agent", Request("t.note"), default);
Assert.NotNull(asRequest.Violation);
Assert.NotNull(asRequest.Response);
// 违规的消息到不了处理函数;通知违规不回响应
Assert.Equal(1, calls);
RpcDispatch silentViolation = await server.DispatchCheckedAsync("agent", Notification("t.nope"), default);
Assert.NotNull(silentViolation.Violation);
Assert.Null(silentViolation.Response);
}
[Fact]
public async Task Dispatch_ADeclaredMethodWithoutAHandler_IsUnsupported_NotAViolation()
{
RpcDispatch outcome = await Server().DispatchCheckedAsync("agent", Request("t.ping"), default);
Assert.Null(outcome.Violation);
Assert.Equal("fleet_unsupported", outcome.Response!.Error!.Data!.Code);
}
[Fact]
public async Task Dispatch_ChecksTheCallersRole()
{
RpcRouter<string> server = Server().OnNotify("t.note", EchoContext.Default.AddParams, (_, _, _) => ValueTask.CompletedTask);
Assert.Null((await server.DispatchCheckedAsync("agent", Notification("t.note"), default)).Violation);
Assert.NotNull((await server.DispatchCheckedAsync("probe", Notification("t.note"), default)).Violation);
Assert.NotNull((await server.DispatchCheckedAsync(null!, Notification("t.note"), default)).Violation);
}
[Fact]
public void CheckSend_RefusesWhatThisSideMayNotInitiate()
{
RpcRouter<string> server = Server();
server.CheckSend("t.push", MethodKind.Request);
Assert.Throws<InvalidOperationException>(() => server.CheckSend("t.ping", MethodKind.Request)); // 那是节点发起的
Assert.Throws<InvalidOperationException>(() => server.CheckSend("t.push", MethodKind.Notification)); // 类型不对
Assert.Throws<InvalidOperationException>(() => server.CheckSend("t.unknown", MethodKind.Request));
// 对端的角色也要允许
var node = new RpcRouter<string>(RpcSide.Node, s_table, "agent");
node.CheckSend("t.note", MethodKind.Notification, "agent");
Assert.Throws<InvalidOperationException>(() => node.CheckSend("t.note", MethodKind.Notification, "probe"));
}
[Fact]
public void MethodTable_RefusesDuplicates_AndReportsMissingHandlers()
{
Assert.Throws<ArgumentException>(() => TestTables.Of(TestTables.Req("a"), TestTables.Note("a")));
Assert.Throws<ArgumentException>(() => new MethodTable([], [new ChannelSpec(16, RpcSide.Node), new ChannelSpec(16, RpcSide.Server)]));
// 服务端要处理 t.ping 和 t.note;t.inline 在握手阶段直接处理,不算缺
Assert.Equal(["t.note", "t.ping"], s_table.MissingHandlers(RpcSide.Server, null, []));
Assert.Equal(["t.ping"], s_table.MissingHandlers(RpcSide.Server, "probe", []));
Assert.Equal(["t.note"], s_table.MissingHandlers(RpcSide.Server, "agent", ["t.ping"]));
Assert.Empty(s_table.MissingHandlers(RpcSide.Server, "agent", ["t.ping", "t.note"]));
// 同名方法在对等面的接收方是拨号方?不:t.ping 在对等面由监听方发起,拨号方处理
Assert.Equal(["t.ping"], s_table.MissingHandlers(RpcSide.Dialer, null, []));
}
[Fact]
public async Task Session_ClosesTheConnection_AfterTooManyViolations()
{
(ChannelTransport a, ChannelTransport b) = ChannelTransport.Pair();
RpcRouter<string> server = Server().On("t.ping", EchoContext.Default.AddParams, EchoContext.Default.AddResult, (_, _, _) => ValueTask.FromResult(Result<AddResult>.Ok(new AddResult())));
await using var session = new RpcSession<string>(b, server, "agent", TimeProvider.System, new RpcSessionOptions { MaxViolations = 3 });
Task run = session.RunAsync(default);
for (int i = 1; i <= 6; i++)
{
await a.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(new RpcMessage { Id = i, Method = "t.nope", Params = s_empty }, FleetRpcContext.Default.RpcMessage)), default);
}
// 违规的请求先收到失败响应,超过上限之后连接被关闭
int failures = 0;
while (await a.ReceiveAsync(default).AsTask().WaitAsync(TimeSpan.FromSeconds(5)) is { } frame)
{
if (JsonSerializer.Deserialize(frame.Data.Span, FleetRpcContext.Default.RpcMessage)?.Error?.Data?.Code == "fleet_protocol_violation")
{
failures++;
}
}
Assert.InRange(failures, 3, 6);
await a.CloseAsync("done", default); // 对端确认关闭之后,会话的接收循环正常结束
await run.WaitAsync(TimeSpan.FromSeconds(5));
}
/// <summary>进程内的一对传输:一端发的帧从另一端收到;关闭时对端收到 null。</summary>
private sealed class ChannelTransport : IRpcTransport
{
private readonly Channel<RpcFrame?> _in;
private readonly Channel<RpcFrame?> _out;
private ChannelTransport(Channel<RpcFrame?> input, Channel<RpcFrame?> output)
{
_in = input;
_out = output;
}
public static (ChannelTransport A, ChannelTransport B) Pair()
{
Channel<RpcFrame?> ab = Channel.CreateUnbounded<RpcFrame?>();
Channel<RpcFrame?> ba = Channel.CreateUnbounded<RpcFrame?>();
return (new ChannelTransport(ba, ab), new ChannelTransport(ab, ba));
}
public ValueTask SendAsync(RpcFrame frame, CancellationToken ct)
{
_out.Writer.TryWrite(frame);
return ValueTask.CompletedTask;
}
public async ValueTask<RpcFrame?> ReceiveAsync(CancellationToken ct) =>
await _in.Reader.WaitToReadAsync(ct) && _in.Reader.TryRead(out RpcFrame? frame) ? frame : null;
public ValueTask CloseAsync(string reason, CancellationToken ct)
{
_out.Writer.TryWrite(null);
return ValueTask.CompletedTask;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
@@ -0,0 +1,123 @@
using System.Buffers.Text;
using System.Security.Cryptography;
using System.Text;
using Fleet.Identity;
namespace Fleet.Tests;
/// <summary>软件 passkey:只做断言(navigator.credentials.get 的结果),用来测信任链。</summary>
internal sealed class TestAuthenticator : IDisposable
{
public const string RpId = "panel.example.com";
public const string Origin = "https://panel.example.com";
private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
private uint _counter;
public string CredentialId { get; } = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32));
public OperatorKey Key => new(CredentialId, _key.ExportSubjectPublicKeyInfo());
public SignedStatement Sign(string statement, byte flags = 0x05, string origin = Origin)
{
byte[] challenge = SHA256.HashData(Encoding.UTF8.GetBytes(statement));
_counter++;
byte[] authData = [.. SHA256.HashData(Encoding.UTF8.GetBytes(RpId)), flags, (byte)(_counter >> 24), (byte)(_counter >> 16), (byte)(_counter >> 8), (byte)_counter];
byte[] clientData = Encoding.UTF8.GetBytes($"{{\"type\":\"webauthn.get\",\"challenge\":\"{Base64Url.EncodeToString(challenge)}\",\"origin\":\"{origin}\",\"crossOrigin\":false}}");
byte[] signature = _key.SignData([.. authData, .. SHA256.HashData(clientData)], HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
return new SignedStatement(statement, CredentialId, Base64Url.EncodeToString(authData), Base64Url.EncodeToString(clientData), Base64Url.EncodeToString(signature));
}
public void Dispose() => _key.Dispose();
}
public sealed class OperatorTrustTests : IDisposable
{
private readonly TestAuthenticator _alice = new();
private readonly TestAuthenticator _bob = new();
public void Dispose()
{
_alice.Dispose();
_bob.Dispose();
}
private OperatorSet Genesis(params TestAuthenticator[] keys) =>
new(1, OperatorTrust.Genesis, TestAuthenticator.RpId, [TestAuthenticator.Origin], [.. keys.Select(k => k.Key)]);
private SignedStatement SignedGenesis(OperatorSet set, TestAuthenticator signer) => signer.Sign(OperatorTrust.Encode(set));
[Fact]
public void TheGenesis_IsPinnedByItsFingerprint_AndMustBeSelfSigned()
{
OperatorSet set = Genesis(_alice);
SignedStatement genesis = SignedGenesis(set, _alice);
string pin = OperatorTrust.Fingerprint(genesis.Statement);
Assert.NotNull(OperatorTrust.VerifyChain(pin, [genesis]));
Assert.Null(OperatorTrust.VerifyChain(new string('0', 64), [genesis])); // 指纹对不上:Backend 换了一份创世
Assert.Null(OperatorTrust.VerifyChain(pin, [SignedGenesis(set, _bob)])); // 不是集合里的凭证签的(也改不了指纹)
Assert.Null(OperatorTrust.VerifyChain(pin, []));
}
[Fact]
public void ABackendThatSwapsTheKeys_CannotMakeAChainTheNodeAccepts()
{
SignedStatement real = SignedGenesis(Genesis(_alice), _alice);
string pin = OperatorTrust.Fingerprint(real.Statement);
using var attacker = new TestAuthenticator();
SignedStatement forged = SignedGenesis(Genesis(attacker), attacker); // 自洽的假创世,但指纹不同
Assert.Null(OperatorTrust.VerifyChain(pin, [forged]));
// 在真创世后面接一个攻击者签的更新:上一版里没有他的凭证
var update = new OperatorSet(2, OperatorTrust.Fingerprint(real.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_alice.Key, attacker.Key]);
Assert.Null(OperatorTrust.VerifyChain(pin, [real, attacker.Sign(OperatorTrust.Encode(update))]));
}
[Fact]
public void AnUpdate_NeedsAKeyOfThePreviousVersion_AndFollowsTheChain()
{
SignedStatement genesis = SignedGenesis(Genesis(_alice), _alice);
string pin = OperatorTrust.Fingerprint(genesis.Statement);
var second = new OperatorSet(2, OperatorTrust.Fingerprint(genesis.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_alice.Key, _bob.Key]);
SignedStatement signedSecond = _alice.Sign(OperatorTrust.Encode(second));
OperatorSet? current = OperatorTrust.VerifyChain(pin, [genesis, signedSecond]);
Assert.Equal(2, current?.Version);
Assert.Equal(2, current?.Keys.Count);
// 新加的 bob 可以签下一版(上一版里有他)
var third = new OperatorSet(3, OperatorTrust.Fingerprint(signedSecond.Statement), TestAuthenticator.RpId, [TestAuthenticator.Origin], [_bob.Key]);
Assert.Equal(3, OperatorTrust.VerifyChain(pin, [genesis, signedSecond, _bob.Sign(OperatorTrust.Encode(third))])?.Version);
// 版本号跳了、prev 不对、域名换了:都不行
var skipped = second with { Version = 3 };
Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(skipped))]));
var wrongPrev = second with { Previous = new string('a', 64) };
Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(wrongPrev))]));
var otherRp = second with { RpId = "evil.example" };
Assert.Null(OperatorTrust.VerifyChain(pin, [genesis, _alice.Sign(OperatorTrust.Encode(otherRp))]));
}
[Fact]
public void AnAssertion_NeedsUserVerification_TheRightOrigin_AndTheExactStatement()
{
OperatorSet set = Genesis(_alice);
Assert.True(OperatorTrust.VerifyOperator(_alice.Sign("hello"), set));
Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello", flags: 0x01), set)); // 没有用户验证
Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello", origin: "https://evil.example"), set));
Assert.False(OperatorTrust.VerifyOperator(_alice.Sign("hello") with { Statement = "hellO" }, set)); // 语句被改
Assert.False(OperatorTrust.VerifyOperator(_bob.Sign("hello"), set)); // 不在集合里
}
[Fact]
public void Parsing_IsStrict()
{
Assert.Null(OperatorTrust.ParseSet("moshiro-opset-v2\nversion=1"));
OperatorSet set = Genesis(_alice);
Assert.Equal(set.Keys[0].CredentialId, OperatorTrust.ParseSet(OperatorTrust.Encode(set))!.Keys[0].CredentialId);
Assert.Null(OperatorTrust.ParseSet(OperatorTrust.Encode(set with { Previous = "abc" }))); // 创世的 prev 必须是 0
Assert.Null(OperatorTrust.ParseSet(OperatorTrust.Encode(set) + "\nnot-a-key"));
}
}
@@ -0,0 +1,70 @@
using Fleet.Core;
using Fleet.Hosting;
using Fleet.Identity;
namespace Fleet.Tests;
/// <summary>节点自己要求有属主(passkey):Agent 和 Probe 共用这一份,没有属主就不运行。</summary>
public sealed class OwnerGuardTests : IDisposable
{
private readonly TestAuthenticator _alice = new();
private readonly string _directory = Path.Combine(Path.GetTempPath(), "moshiro-owner-" + Guid.NewGuid().ToString("N"));
public OwnerGuardTests()
{
Directory.CreateDirectory(_directory);
}
public void Dispose()
{
_alice.Dispose();
Directory.Delete(_directory, recursive: true);
}
private void Pin(params string[] controllers)
{
var set = new OperatorSet(1, OperatorTrust.Genesis, TestAuthenticator.RpId, [TestAuthenticator.Origin], [_alice.Key]);
SignedStatement genesis = _alice.Sign(OperatorTrust.Encode(set));
TrustAnchor.Pin(_directory, OperatorTrust.Fingerprint(genesis.Statement), [genesis.ToDto()], controllers);
}
[Fact]
public void ANodeEnrolledWithoutATrustRoot_HasNoOwner_AndDoesNotRun()
{
TrustAnchor none = TrustAnchor.Load(_directory, NullNodeLog.Instance);
Assert.NotNull(OwnerGuard.Problem(none, require: true));
Assert.False(OwnerGuard.Check(_directory, require: true, NullNodeLog.Instance));
}
[Fact]
public void ANodePinnedToAnOwnerPasskey_Runs()
{
Pin(_alice.CredentialId);
TrustAnchor pinned = TrustAnchor.Load(_directory, NullNodeLog.Instance);
Assert.Null(OwnerGuard.Problem(pinned, require: true));
Assert.True(OwnerGuard.Check(_directory, require: true, NullNodeLog.Instance));
Assert.Equal([_alice.CredentialId], pinned.Controllers);
}
[Fact]
public void APinnedNodeWhoseOwnerIsNoLongerInTheOperatorSet_DoesNotRun()
{
Pin("some-passkey-that-is-not-in-the-set");
TrustAnchor pinned = TrustAnchor.Load(_directory, NullNodeLog.Instance);
Assert.Empty(pinned.Controllers);
Assert.NotNull(OwnerGuard.Problem(pinned, require: true));
}
[Fact]
public void TheRequirementCanBeTurnedOff_ForAPanelWithoutATrustRoot()
{
TrustAnchor none = TrustAnchor.Load(_directory, NullNodeLog.Instance);
Assert.Null(OwnerGuard.Problem(none, require: false));
}
}
@@ -0,0 +1,266 @@
using Fleet.Core;
using Fleet.Hosting;
namespace Fleet.Tests;
public sealed class RestartScheduleTests
{
private static RestartSchedule Parse(string text)
{
Assert.True(RestartSchedule.TryParse(text, out RestartSchedule? schedule), text);
return schedule!;
}
[Theory]
[InlineData("04:00")]
[InlineData("04:00Z")]
[InlineData("04:00+08:00")]
[InlineData("23:59-05:30")]
[InlineData(" 00:00 ")]
public void Parse_AcceptsTimeWithOptionalOffset(string text) => Parse(text);
[Theory]
[InlineData("")]
[InlineData("4:00")]
[InlineData("24:00")]
[InlineData("04:60")]
[InlineData("04:00+8:00")]
[InlineData("04:00+15:00")]
[InlineData("04:00 UTC")]
[InlineData("0400")]
[InlineData("04:00+08:60")]
public void Parse_RejectsEverythingElse(string text) => Assert.False(RestartSchedule.TryParse(text, out _));
[Fact]
public void NextAfter_IsStrictlyLater_AndRollsToTomorrow()
{
RestartSchedule utc = Parse("04:00");
Assert.Equal(new DateTimeOffset(2026, 10, 7, 4, 0, 0, TimeSpan.Zero), utc.NextAfter(new DateTimeOffset(2026, 10, 7, 3, 59, 59, TimeSpan.Zero)));
Assert.Equal(new DateTimeOffset(2026, 10, 8, 4, 0, 0, TimeSpan.Zero), utc.NextAfter(new DateTimeOffset(2026, 10, 7, 4, 0, 0, TimeSpan.Zero)));
Assert.Equal(new DateTimeOffset(2026, 10, 8, 4, 0, 0, TimeSpan.Zero), utc.NextAfter(new DateTimeOffset(2026, 10, 7, 20, 0, 0, TimeSpan.Zero)));
}
[Fact]
public void NextAfter_UsesTheWrittenOffset()
{
// 04:00+08:00 就是 UTC 前一天 20:00
RestartSchedule china = Parse("04:00+08:00");
Assert.Equal(new DateTimeOffset(2026, 10, 6, 20, 0, 0, TimeSpan.Zero), china.NextAfter(new DateTimeOffset(2026, 10, 6, 19, 0, 0, TimeSpan.Zero)));
Assert.Equal(new DateTimeOffset(2026, 10, 7, 20, 0, 0, TimeSpan.Zero), china.NextAfter(new DateTimeOffset(2026, 10, 6, 20, 0, 0, TimeSpan.Zero)));
}
[Fact]
public void NextAfter_WithMinimumUptime_DoesNotRestartAgainRightAfterARestart()
{
RestartSchedule schedule = Parse("04:00");
// 04:00:03 重启后启动:加上最短运行时间再算,下一次是明天,而不是几秒后
var started = new DateTimeOffset(2026, 10, 7, 4, 0, 3, TimeSpan.Zero);
Assert.Equal(new DateTimeOffset(2026, 10, 8, 4, 0, 0, TimeSpan.Zero), schedule.NextAfter(started + RestartSchedule.MinimumUptime));
// 03:55 启动:离 04:00 只有 5 分钟,不够最短运行时间,顺延到明天
var early = new DateTimeOffset(2026, 10, 7, 3, 55, 0, TimeSpan.Zero);
Assert.Equal(new DateTimeOffset(2026, 10, 8, 4, 0, 0, TimeSpan.Zero), schedule.NextAfter(early + RestartSchedule.MinimumUptime));
}
[Fact]
public void Resolve_ReportsABadValueInsteadOfIgnoringIt()
{
Assert.Null(RestartSchedule.Resolve(null));
Assert.Null(RestartSchedule.Resolve(" "));
InvalidOperationException error = Assert.Throws<InvalidOperationException>(() => RestartSchedule.Resolve("tomorrow"));
Assert.Contains("restart_at", error.Message);
}
[Fact]
public async Task ServiceShell_StopsAtTheScheduledTime()
{
var time = new ImmediateTimeProvider(new DateTimeOffset(2026, 10, 7, 12, 0, 0, TimeSpan.Zero));
var log = new ListLog();
bool stoppedByToken = false;
// 业务循环只在被取消时返回;定时重启到点以后令牌被取消,RunAsync 正常返回
await ServiceShell.RunAsync(async ct =>
{
try
{
await Task.Delay(Timeout.Infinite, ct);
}
catch (OperationCanceledException)
{
stoppedByToken = true;
throw;
}
}, log, new RestartController(Parse("04:00")), time).WaitAsync(TimeSpan.FromSeconds(10));
Assert.True(stoppedByToken);
Assert.Contains(log.Lines, l => l.Contains("scheduled_restart", StringComparison.Ordinal));
Assert.Contains(log.Lines, l => l.Contains("restart scheduled", StringComparison.Ordinal));
}
[Fact]
public async Task ServiceShell_PicksUpAScheduleThePanelSendsLater()
{
var time = new ImmediateTimeProvider(new DateTimeOffset(2026, 10, 7, 12, 0, 0, TimeSpan.Zero));
var log = new ListLog();
using var controller = new RestartController();
Task shell = ServiceShell.RunAsync(ct => Task.Delay(Timeout.Infinite, ct), log, controller, time);
await Task.Delay(300);
Assert.False(shell.IsCompleted, "no schedule yet: the node keeps running");
Assert.True(controller.SetRemote("04:30+08:00"));
await shell.WaitAsync(TimeSpan.FromSeconds(10));
Assert.Contains(log.Lines, l => l.Contains("scheduled_restart", StringComparison.Ordinal));
Assert.Contains(log.Lines, l => l.Contains("source=panel", StringComparison.Ordinal));
}
[Fact]
public async Task ServiceShell_ClearingTheScheduleCancelsTheRestart()
{
var time = new ManualTimeProvider(new DateTimeOffset(2026, 10, 7, 12, 0, 0, TimeSpan.Zero));
var log = new ListLog();
using var controller = new RestartController();
controller.SetRemote("04:30+08:00");
Task shell = ServiceShell.RunAsync(ct => Task.Delay(Timeout.Infinite, ct), log, controller, time);
await Task.Delay(300);
controller.SetRemote(null); // 面板关掉了
await Task.Delay(300);
Assert.False(shell.IsCompleted);
Assert.Contains(log.Lines, l => l.Contains("restart scheduled", StringComparison.Ordinal));
Assert.DoesNotContain(log.Lines, l => l.Contains("scheduled_restart", StringComparison.Ordinal));
// 清理:把进程停掉
Assert.True(controller.SetRemote("04:30+08:00"));
// 新的计时器是异步注册的:反复推进,直到进程退出
for (int i = 0; i < 100 && !shell.IsCompleted; i++)
{
time.Advance();
await Task.Delay(50);
}
await shell.WaitAsync(TimeSpan.FromSeconds(10));
}
[Fact]
public void Controller_LocalSettingWinsOverThePanel_AndBadTextIsRejected()
{
using var controller = new RestartController(Parse("04:00"));
Assert.True(controller.IsLocal);
Assert.True(controller.SetRemote("05:00"));
Assert.Equal(new DateTimeOffset(2026, 10, 7, 4, 0, 0, TimeSpan.Zero), controller.Current!.NextAfter(new DateTimeOffset(2026, 10, 7, 0, 0, 0, TimeSpan.Zero)));
using var remoteOnly = new RestartController();
Assert.Null(remoteOnly.Current);
Assert.False(remoteOnly.SetRemote("whenever"));
Assert.Null(remoteOnly.Current);
Assert.True(remoteOnly.SetRemote("05:00"));
Assert.NotNull(remoteOnly.Current);
Assert.True(remoteOnly.SetRemote(null));
Assert.Null(remoteOnly.Current);
}
/// <summary>计时器马上触发:用来验证「到点」之后的行为,不用真的等。</summary>
private sealed class ImmediateTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
public ImmediateTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
{
// TrimHeapAsync 这类每 30 秒一次的循环也会走到这里;只让「很长」的延迟(定时重启,至少 10 分钟)马上触发
var timer = new ImmediateTimer(callback, state);
if (dueTime >= RestartSchedule.MinimumUptime)
{
ThreadPool.QueueUserWorkItem(_ => timer.Fire());
}
return timer;
}
}
/// <summary>计时器只在 <see cref="Advance"/> 时触发:用来验证「还没到点」时不会退出。</summary>
private sealed class ManualTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
private readonly List<ImmediateTimer> _timers = [];
public ManualTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
{
var timer = new ImmediateTimer(callback, state);
lock (_timers)
{
_timers.Add(timer);
}
return timer;
}
public void Advance()
{
ImmediateTimer[] due;
lock (_timers)
{
due = [.. _timers];
_timers.Clear();
}
foreach (ImmediateTimer timer in due)
{
timer.Fire();
}
}
}
private sealed class ImmediateTimer : ITimer
{
private readonly TimerCallback _callback;
private readonly object? _state;
public ImmediateTimer(TimerCallback callback, object? state)
{
_callback = callback;
_state = state;
}
public void Fire() => _callback(_state);
public bool Change(TimeSpan dueTime, TimeSpan period) => true;
public void Dispose()
{
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
private sealed class ListLog : INodeLog
{
private readonly List<string> _lines = [];
public IReadOnlyList<string> Lines
{
get
{
lock (_lines)
{
return [.. _lines];
}
}
}
public void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields)
{
lock (_lines)
{
_lines.Add(message + " " + string.Join(' ', fields.Select(f => f.Key + "=" + f.Value)));
}
}
}
}
@@ -0,0 +1,114 @@
using System.Security.Cryptography;
using Fleet.Identity;
namespace Fleet.Tests;
public sealed class SecureChannelTests
{
private const string Nonce = "n0nce-from-the-server";
private static (LinkCipher Node, LinkCipher Server) Pair(string nodeNonce = Nonce, string serverNonce = Nonce)
{
using var node = new EphemeralKey();
using var server = new EphemeralKey();
return (LinkCipher.ForNode(node, server.PublicKeySpki, nodeNonce), LinkCipher.ForServer(server, node.PublicKeySpki, serverNonce));
}
[Fact]
public void TheServerProof_VerifiesOnlyForTheRightKey_Nonce_AndEphemeralKey()
{
using ECDsa serverKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using ECDsa other = ECDsa.Create(ECCurve.NamedCurves.nistP256);
using var eph = new EphemeralKey();
using var otherEph = new EphemeralKey();
byte[] spki = serverKey.ExportSubjectPublicKeyInfo();
byte[] proof = ServerHello.Sign(serverKey, Nonce, eph.PublicKeySpki);
Assert.True(ServerHello.Verify(spki, Nonce, eph.PublicKeySpki, proof));
Assert.False(ServerHello.Verify(other.ExportSubjectPublicKeyInfo(), Nonce, eph.PublicKeySpki, proof)); // 别人的服务器密钥
Assert.False(ServerHello.Verify(spki, "another-nonce", eph.PublicKeySpki, proof)); // 挪到别的挑战上
Assert.False(ServerHello.Verify(spki, Nonce, otherEph.PublicKeySpki, proof)); // 中间人换了自己的临时公钥
Assert.False(ServerHello.Verify(spki, Nonce, eph.PublicKeySpki, proof.AsSpan(0, proof.Length - 1)));
Assert.False(ServerHello.Verify([1, 2, 3], Nonce, eph.PublicKeySpki, proof)); // 公钥不合法也不抛异常
}
[Fact]
public void BothDirections_RoundTrip_AndKeepTheFrameKind()
{
(LinkCipher node, LinkCipher server) = Pair();
using LinkCipher n = node;
using LinkCipher s = server;
byte[] toServer = n.Seal(0, "hello server"u8);
Assert.True(s.TryOpen(toServer, out byte kind, out byte[] data));
Assert.Equal(0, kind);
Assert.Equal("hello server"u8.ToArray(), data);
byte[] big = RandomNumberGenerator.GetBytes(300_000);
byte[] toNode = s.Seal(1, big);
Assert.True(n.TryOpen(toNode, out kind, out data));
Assert.Equal(1, kind);
Assert.Equal(big, data);
Assert.Equal(big.Length + LinkCipher.Overhead + 1, toNode.Length);
}
[Fact]
public void AReplayedReorderedTamperedOrReflectedFrame_IsRefused()
{
(LinkCipher node, LinkCipher server) = Pair();
using LinkCipher n = node;
using LinkCipher s = server;
byte[] first = n.Seal(0, "one"u8);
byte[] second = n.Seal(0, "two"u8);
Assert.False(s.TryOpen(second, out _, out _)); // 乱序(丢了第一帧)
Assert.True(s.TryOpen(first, out _, out _));
Assert.False(s.TryOpen(first, out _, out _)); // 重放
Assert.True(s.TryOpen(second, out _, out _));
byte[] tampered = n.Seal(0, "three"u8);
tampered[12] ^= 0x01;
Assert.False(s.TryOpen(tampered, out _, out _)); // 改了一位
byte[] fromNode = n.Seal(0, "four"u8);
(LinkCipher otherNode, LinkCipher otherServer) = Pair();
using LinkCipher on = otherNode;
using LinkCipher os = otherServer;
Assert.False(os.TryOpen(fromNode, out _, out _)); // 另一条连接的密钥打不开
byte[] fromServer = s.Seal(0, "reflected"u8);
Assert.False(s.TryOpen(fromServer, out _, out _)); // 方向不同:服务器发的东西不能原样塞回给服务器
Assert.False(n.TryOpen([1, 2, 3], out _, out _));
}
[Fact]
public void ADifferentNonce_GivesDifferentKeys()
{
(LinkCipher node, LinkCipher server) = Pair("nonce-a", "nonce-b"); // 两边对不上:没人能解开对方的东西
using LinkCipher n = node;
using LinkCipher s = server;
Assert.False(s.TryOpen(n.Seal(0, "x"u8), out _, out _));
}
[Fact]
public void AnOperatorSet_CarriesTheServerKeys_ThroughTheChain()
{
using var alice = new TestAuthenticator();
using ECDsa serverKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
byte[] spki = serverKey.ExportSubjectPublicKeyInfo();
var set = new OperatorSet(1, OperatorTrust.Genesis, TestAuthenticator.RpId, [TestAuthenticator.Origin], [alice.Key], [spki]);
string text = OperatorTrust.Encode(set);
OperatorSet parsed = OperatorTrust.ParseSet(text)!;
Assert.Equal(spki, Assert.Single(parsed.Servers!));
Assert.Equal(text, OperatorTrust.Encode(parsed)); // 编码和解析互逆,指纹稳定
SignedStatement genesis = alice.Sign(text);
OperatorSet verified = OperatorTrust.VerifyChain(OperatorTrust.Fingerprint(text), [genesis])!;
Assert.Equal(spki, Assert.Single(verified.Servers!));
// 服务器密钥只能写在凭证后面;顺序不对、内容不是 base64 都算坏
Assert.Null(OperatorTrust.ParseSet(text + "\nkey=late:AAAA"));
Assert.Null(OperatorTrust.ParseSet(text + "\nserver=***"));
}
}
@@ -0,0 +1,13 @@
using Fleet.Rpc;
namespace Fleet.Tests;
/// <summary>测试里用的方法声明:除非另有说明,方法都由拨号方发给监听方。</summary>
internal static class TestTables
{
public static MethodTable Of(params MethodSpec[] specs) => new(specs);
public static MethodSpec Req(string name, RpcSide from = RpcSide.Dialer) => MethodSpec.Request(name, from);
public static MethodSpec Note(string name, RpcSide from = RpcSide.Dialer) => MethodSpec.Notification(name, from);
}
+12 -3
View File
@@ -12,7 +12,7 @@ string? name = Arg("--name");
string? directory = Arg("--identity-dir");
if (server is null || join is null || name is null || directory is null)
{
Console.Error.WriteLine("usage: fleet-enroll --server <url> --join <token> --name <node name> --identity-dir <dir> [--meta <json>] [--version <v>]");
Console.Error.WriteLine("usage: fleet-enroll --server <url> --join <token> --name <node name> --identity-dir <dir> [--meta <json>] [--version <v>] [--genesis <trust root fingerprint> --controllers <passkey credential ids, comma separated>]");
return 2;
}
@@ -30,14 +30,23 @@ if (Arg("--meta") is { } metaText)
}
}
string? genesis = Arg("--genesis");
string[] controllers = (Arg("--controllers") ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (genesis is not null && controllers.Length == 0)
{
Console.Error.WriteLine("--genesis needs --controllers: every node has an owner (the passkey credential ids from the panel's command)");
return 2;
}
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
Result<EnrollmentResult> result = await EnrollmentClient.EnrollAsync(
http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None);
http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None, genesis, controllers);
if (!result.TryGet(out EnrollmentResult? enrolled, out FleetError? error))
{
Console.Error.WriteLine($"enrollment failed ({error.Code}): {error.Message}");
return 1;
}
Console.WriteLine($"enrolled as {enrolled.NodeId} ({enrolled.Status}); identity written to {enrolled.IdentityDirectory}");
Console.WriteLine($"enrolled as {enrolled.NodeId} ({enrolled.Status}); identity written to {enrolled.IdentityDirectory}" + (enrolled.Pinned ? "; operator trust pinned" : "; operator trust NOT pinned (no --genesis)"));
return 0;
+1652 -267
View File
File diff suppressed because it is too large. Load diff
+8 -2
View File
@@ -11,7 +11,7 @@ Backend ──任务──> Agent <──测量会话──> Probe
└─────Agent 列表、状态(Fleet 长连接)───────────┘
```
Probe 使用面板生成的 `probe.json` 读取自身身份、面板地址(`server`)和本地测试限制。连上面板之后,面板下发要连接的 Agent 列表;Probe 将列表缓存到同目录的 `agents.json`,面板暂时连不上时靠它继续测量。`probe.json` 含私钥,请限制文件访问权限,不要提交到版本控制。
Probe 用 `probe.json` 读取面板地址(`fleet.server`)、身份目录(`fleet.identity_dir`,由 `fleet-enroll` 注册时生成)和本地测试限制。连上面板之后,面板下发要连接的 Agent 列表;Probe 将列表缓存到同目录的 `agents.json`,面板暂时连不上时靠它继续测量。身份目录里有私钥,请限制访问权限,不要提交到版本控制。
容器节点的运行方式(podman + systemd)和宿主机更新器见 [`../../docs/deployment.md`](../../docs/deployment.md);Probe 也可以用 OCI 镜像 `registry.oeo.one/moshiro/moshiro-probe:<version>`,容器默认读取 `/etc/moshiro/probe.json`,命名 volume 保存身份配置和缓存,镜像以 UID/GID 1654 运行。
@@ -34,7 +34,13 @@ dotnet publish probe/src/MoShiro.Probe -c Release -r <RID> -p:PublishAot=true
## 配置与运行
在管理端创建 Probe 并下载一次性身份配置,将文件保存为目标机上的 `/etc/moshiro/probe.json`。随后运行:
在管理端添加 Probe(生成 probe 角色的加入令牌),在目标机上注册(密钥由 Probe 自己生成):
```sh
fleet-enroll --server https://panel.example.com --join <加入令牌> --name home --identity-dir /etc/moshiro
```
写 `/etc/moshiro/probe.json`:`{"fleet":{"server":"https://panel.example.com","identity_dir":"/etc/moshiro"}}`(`install.sh probe --server … --join … --name …` 会一并完成)。随后运行:
```sh
moshiro-probe --config /etc/moshiro/probe.json
@@ -25,6 +25,9 @@ public sealed class AgentConnection
private static readonly TimeSpan s_clockInterval = TimeSpan.FromSeconds(30);
private static readonly RpcRouter<ConnectionSession> s_router = BuildRouter();
/// <summary>对等面入站方法的路由器(只读,测试用)。</summary>
public static RpcRouter<ConnectionSession> Router => s_router;
private readonly ConnectionEnv _env;
private volatile ConnectionSession? _session;
@@ -86,8 +89,9 @@ public sealed class AgentConnection
throw new SocketException((int)SocketError.HostNotFound);
}
byte[] agentKey = Convert.FromBase64String(Endpoint.PublicKey);
(PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync(
address.ToString(), Endpoint.TcpPort, Convert.FromBase64String(Endpoint.Spki), _env.Identity, _env.ProbeId, ProbeApp.Version, 8 << 20, _env.Time, ct);
address.ToString(), Endpoint.TcpPort, agentKey, _env.Identity, _env.ProbeId, ProbeApp.Version, 8 << 20, _env.Time, ct);
var remote = (IPEndPoint)info.Remote!;
var data = new DataSession(info.Credentials.SessionIdValue, info.Credentials.Key);
using var udp = new UdpMux(new SingleSession(data), null, _env.Clock, new DataPlaneCounters(), _env.Log, remote.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, []);
@@ -102,7 +106,8 @@ public sealed class AgentConnection
Session = () => data,
Udp = udp,
PeerAddress = remote.Address,
PeerSpkiSha256 = Convert.FromBase64String(Endpoint.Spki),
PeerSpkiSha256 = NodeIdentity.Fingerprint(agentKey),
KnockKey = Knock.DeriveKey(_env.Identity, agentKey),
Fragments = shipper,
MaxPacketsPerSecond = _env.Limits.MaxTotalPps,
MaxBulkMbps = _env.Limits.MaxTestMbps,
@@ -191,7 +196,7 @@ public sealed class AgentConnection
}
private static RpcRouter<ConnectionSession> BuildRouter() =>
new RpcRouter<ConnectionSession>()
new RpcRouter<ConnectionSession>(RpcSide.Dialer, MoShiroMethodTable.Table)
.On(
FleetMethods.StateSync,
FleetRpcContext.Default.StateSyncParams,
@@ -16,11 +16,13 @@ public sealed class AgentDirectory
private readonly object _gate = new();
private readonly ConfigStore _store;
private readonly INodeLog _log;
private readonly TrustAnchor _trust;
private List<KnownAgent> _agents = [];
private long _version;
public AgentDirectory(ConfigStore store, string configDirectory, INodeLog log)
public AgentDirectory(ConfigStore store, string configDirectory, INodeLog log, TrustAnchor? trust = null)
{
_trust = trust ?? TrustAnchor.None;
_store = store;
_log = log;
string cache = Path.Combine(configDirectory, FileName);
@@ -68,16 +70,17 @@ public sealed class AgentDirectory
public void Apply(ProbeAgentList result)
{
var next = new List<KnownAgent>();
_trust.Update(result.TrustChain);
foreach (AgentEndpoint agent in result.Agents)
{
if (!string.IsNullOrWhiteSpace(agent.Host))
{
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Host, TcpPort = agent.TcpPort, Spki = agent.Spki });
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Host, TcpPort = agent.TcpPort, PublicKey = agent.PublicKey });
}
if (!string.IsNullOrWhiteSpace(agent.Ipv6Host))
{
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Ipv6Host, TcpPort = agent.TcpPort, Spki = agent.Spki, IpVersion = IpVersions.V6 });
next.Add(new KnownAgent { AgentId = agent.AgentId.Value, Host = agent.Ipv6Host, TcpPort = agent.TcpPort, PublicKey = agent.PublicKey, IpVersion = IpVersions.V6 });
}
}
lock (_gate)
@@ -108,5 +111,5 @@ public sealed class AgentDirectory
a.Count == b.Count && a.Zip(b).All(p => Equal(p.First, p.Second));
public static bool Equal(KnownAgent a, KnownAgent b) =>
a.AgentId == b.AgentId && a.IpVersion == b.IpVersion && a.Host == b.Host && a.TcpPort == b.TcpPort && a.Spki == b.Spki;
a.AgentId == b.AgentId && a.IpVersion == b.IpVersion && a.Host == b.Host && a.TcpPort == b.TcpPort && a.PublicKey == b.PublicKey;
}
+22 -9
View File
@@ -23,24 +23,28 @@ public sealed class ProbeApp : IDisposable
private readonly INodeLog _log;
private readonly FleetLink _link;
public ProbeApp(ProbeFile file, string configDirectory, TimeProvider time, INodeLog log)
/// <param name="identity">注册时由节点自己生成:<c>Identity</c> 用来连面板,<c>PeerIdentity</c> 是另一把独立的密钥,只用来连 Agent。</param>
public ProbeApp(ProbeFile file, LoadedNodeIdentity identity, string configDirectory, TimeProvider time, INodeLog log, RestartController? restart = null)
{
_log = log;
var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log);
NodeIdentity identity = NodeIdentity.FromPkcs8(Convert.FromBase64String(file.PrivateKey));
_env = new ConnectionEnv(identity, file.ProbeId, file.Limits, directory, new MonoClock(time), time, log);
var trust = TrustAnchor.Load(file.Fleet.IdentityDir, log);
var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log, trust);
_env = new ConnectionEnv(identity.PeerIdentity, identity.NodeId.Value, file.Limits, directory, new MonoClock(time), time, log);
directory.Changed += () => _changed.Release();
var update = new UpdateBridge(configDirectory, new ConfigStore(configDirectory).Write, log);
NodeBinary.Prime(); // 正在运行的二进制的哈希:先在后台算好,握手时用
var options = new FleetLinkOptions
{
Server = new Uri(file.Server),
Identity = identity,
NodeId = new NodeId(file.ProbeId),
Server = new Uri(file.Fleet.Server),
Identity = identity.Identity,
ServerVerifier = trust,
Attestation = update.Attest,
NodeId = identity.NodeId,
NodeVersion = Version,
Capabilities = [MoShiroCapabilities.Base],
HeartbeatExtra = () => JsonSerializer.SerializeToElement(new ProbeHeartbeatExtra { Update = update.ReadStatus(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHeartbeatExtra),
HelloMeta = () => JsonSerializer.SerializeToElement(new ProbeHelloMeta { Platform = PlatformDetector.Detect(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHelloMeta),
HelloMeta = () => JsonSerializer.SerializeToElement(new ProbeHelloMeta { Owners = [.. trust.Controllers], Platform = PlatformDetector.Detect(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHelloMeta),
};
var list = new StateHandler<ProbeAgentList>(
ProbeAgentList.StateType,
@@ -50,7 +54,7 @@ public sealed class ProbeApp : IDisposable
directory.Apply(doc);
return ValueTask.FromResult(new StateSyncResult { AppliedVersion = version });
});
var router = new RpcRouter<LinkContext>()
var router = new RpcRouter<LinkContext>(RpcSide.Node, MoShiroMethodTable.Table, NodeRoles.Probe)
.OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) =>
{
update.WriteOffer(offer);
@@ -59,6 +63,15 @@ public sealed class ProbeApp : IDisposable
.OnNotify(MoShiroMethods.UpdateApply, MoShiroJsonContext.Default.UpdateOfferParams, (_, _, _) =>
{
update.RequestApply();
return ValueTask.CompletedTask;
})
.OnNotify(MoShiroMethods.Schedule, MoShiroJsonContext.Default.ScheduleParams, (_, schedule, _) =>
{
if (restart is not null && !restart.SetRemote(schedule.RestartAt))
{
log.Warn("ignoring an invalid restart schedule from the panel");
}
return ValueTask.CompletedTask;
});
_link = new FleetLink(options, router, [list], new Outbox(1 << 20), time, log);
+22 -11
View File
@@ -4,24 +4,35 @@ using MoShiro.Protocol;
namespace MoShiro.Probe;
/// <summary>
/// <c>probe.json</c>:面板 <c>POST /api/admin/probe/identity</c> 生成的文件(只能下载一次)。
/// <c>probe.json</c>:本地配置,和 Agent 一样由 <c>fleet-enroll</c> 注册之后写出身份目录(节点自己生成的两把密钥),这里只放面板地址和本地硬上限。
/// <c>limits</c> 是本地硬上限,面板无法修改;在路由器上建议调低,避免 Probe 自己的 CPU 成为瓶颈、反过来影响测量。
/// </summary>
public sealed class ProbeFile
{
[JsonPropertyName("probe_id")]
public string ProbeId { get; set; } = "";
[JsonPropertyName("fleet")]
public ProbeFleetSection Fleet { get; set; } = new();
/// <summary>PKCS#8,base64。</summary>
[JsonPropertyName("private_key")]
public string PrivateKey { get; set; } = "";
/// <summary>每天定时重启一次,例如 <c>04:00+08:00</c>(不写偏移按 UTC);不写就不重启。环境变量 <c>MOSHIRO_RESTART_AT</c> 优先;这里是本地配置,从面板重新下载 probe.json 会丢掉它,所以容器里推荐用环境变量。</summary>
[JsonPropertyName("restart_at")]
public string? RestartAt { get; set; }
[JsonPropertyName("limits")]
public ProbeLimits Limits { get; set; } = new();
}
public sealed class ProbeFleetSection
{
/// <summary>面板的地址(http 或 https)。Probe 直接连它:身份、Agent 列表、更新都从这里来。</summary>
[JsonPropertyName("server")]
public string Server { get; set; } = "";
[JsonPropertyName("limits")]
public ProbeLimits Limits { get; set; } = new();
/// <summary><c>fleet-enroll</c> 写出的身份目录:<c>node.json</c>、<c>node.key</c>(连面板的密钥)、<c>tls.pfx</c>(对等面的密钥)。</summary>
[JsonPropertyName("identity_dir")]
public string IdentityDir { get; set; } = "/etc/moshiro/probe-id";
/// <summary>节点自己要求有属主(passkey):没有就不运行。见 <c>OwnerGuard</c>(Agent 和 Probe 共用)。</summary>
[JsonPropertyName("require_owner")]
public bool RequireOwner { get; set; } = true;
}
public sealed class KnownAgent
@@ -41,9 +52,9 @@ public sealed class KnownAgent
[JsonPropertyName("tcp_port")]
public int TcpPort { get; set; }
/// <summary>Agent 的 TLS 证书指纹(SHA-256,base64)。</summary>
[JsonPropertyName("spki")]
public string Spki { get; set; } = "";
/// <summary>Agent 的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它派生。</summary>
[JsonPropertyName("public_key")]
public string PublicKey { get; set; } = "";
}
public sealed class ProbeLimits
+23 -11
View File
@@ -21,30 +21,42 @@ for (int i = 0; i < args.Length - 1; i++)
}
ProbeFile file;
LoadedNodeIdentity identity;
RestartController restart;
try
{
file = NodeConfig.Load(configPath, ProbeConfigContext.Default.ProbeFile);
if (file.ProbeId.Length == 0 || file.PrivateKey.Length == 0 || !Uri.TryCreate(file.Server, UriKind.Absolute, out _))
restart = new RestartController(RestartSchedule.Resolve(file.RestartAt));
if (!Uri.TryCreate(file.Fleet.Server, UriKind.Absolute, out _))
{
throw new InvalidOperationException("probe.json needs probe_id, private_key and server (download it from the panel: Probes → Create)");
throw new InvalidOperationException("probe.json needs fleet.server (the panel address)");
}
identity = LoadedNodeIdentity.Load(file.Fleet.IdentityDir);
if (!identity.NodeId.Value.StartsWith(MoShiro.Protocol.ProbeId.Prefix, StringComparison.Ordinal))
{
identity.Dispose();
throw new InvalidOperationException($"the identity in {file.Fleet.IdentityDir} is not a probe's: enroll with a probe join token");
}
if (!OwnerGuard.Check(file.Fleet.IdentityDir, file.Fleet.RequireOwner, log))
{
identity.Dispose();
return 1;
}
}
catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or System.Text.Json.JsonException)
catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or System.Text.Json.JsonException or System.Security.Cryptography.CryptographicException)
{
log.Error("startup failed", ("err", ex.Message));
return 1;
}
using var restartScope = restart;
string directory = Path.GetDirectoryName(Path.GetFullPath(configPath))!;
try
using (identity)
{
using var app = new ProbeApp(file, directory, time, log);
await ServiceShell.RunAsync(app.RunAsync, log);
}
catch (Exception ex) when (ex is FormatException or System.Security.Cryptography.CryptographicException)
{
log.Error("startup failed", ("err", "private_key is not a valid PKCS#8 key: " + ex.Message));
return 1;
using var app = new ProbeApp(file, identity, directory, time, log, restart);
await ServiceShell.RunAsync(app.RunAsync, log, restart, time);
}
return 0;
@@ -58,7 +58,7 @@ public sealed class ProbeTests
private static ProbeAgentList List(long version, params string[] ids) => new()
{
Version = version,
Agents = [.. ids.Select(id => new AgentEndpoint { AgentId = new NodeId(id), Host = id + ".example", TcpPort = 7443, Spki = "c3BraQ==" })],
Agents = [.. ids.Select(id => new AgentEndpoint { AgentId = new NodeId(id), Host = id + ".example", TcpPort = 7443, PublicKey = "c3BraQ==" })],
};
[Fact]
@@ -217,6 +217,9 @@ public sealed class BlockEnv
/// <summary>拨号方:对端 TLS 证书的 SPKI 指纹;null 表示不校验证书(目标任务,目标是任意地址)。</summary>
public byte[]? PeerSpkiSha256 { get; init; }
/// <summary>拨号方:和对端(Agent)的配对敲门密钥;每条 TCP 连接建立后先发敲门包。null 表示对端不要求敲门(目标任务的任意地址)。</summary>
public byte[]? KnockKey { get; init; }
/// <summary>拨号方:列块出口。</summary>
public IFragmentSink? Fragments { get; init; }
@@ -241,6 +244,7 @@ public sealed class BlockEnv
TcpListen = TcpListen,
PeerAddress = PeerAddress,
PeerSpkiSha256 = PeerSpkiSha256,
KnockKey = KnockKey,
Fragments = Fragments,
MaxBulkMbps = MaxBulkMbps,
MaxPacketsPerSecond = MaxPacketsPerSecond,
@@ -6,5 +6,6 @@
<InternalsVisibleTo Include="MoShiro.Measurement.Tests" />
<AdditionalFiles Include="$(MSBuildThisFileDirectory)../../BannedSymbols.Node.txt" />
<ProjectReference Include="..\MoShiro.Protocol\MoShiro.Protocol.csproj" />
<ProjectReference Include="..\..\fleet\src\Fleet.Identity\Fleet.Identity.csproj" />
</ItemGroup>
</Project>
+18 -2
View File
@@ -5,6 +5,7 @@ using System.Net.Sockets;
using System.Security.Authentication;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Fleet.Identity;
using MoShiro.Protocol;
namespace MoShiro.Measurement;
@@ -46,6 +47,12 @@ public sealed class TcpLink : IDisposable
await client.ConnectAsync(env.PeerAddress!, port, ct);
long tcpUs = env.Clock.NowUs() - start;
Stream stream = client.GetStream();
if (env.KnockKey is { } knockKey)
{
// 预握手敲门:监听方不认就不会回任何字节
await stream.WriteAsync(Knock.Build(knockKey, env.Time.GetUtcNow().ToUnixTimeMilliseconds()), ct);
}
long? tlsUs = null;
if (tls)
{
@@ -115,7 +122,7 @@ public sealed class TcpConnection
public delegate Task TcpResponder(TcpConnection connection, CancellationToken ct);
/// <summary>
/// 监听方的 TCP 数据面:每个端口一个监听器。连接的第一个字节是 0x16 就先做 TLS(节点自己的证书),否则直接是明文。
/// 监听方的 TCP 数据面:每个端口一个监听器。连接先过预握手敲门,然后第一个字节是 0x16 就先做 TLS(节点自己的证书),否则直接是明文。
/// 随后是认证首帧(<c>"MN" | ver | session_id | stream_id | nonce | mac</c>,随机数只能用一次),通过之后交给按 <c>stream_id</c> 登记的处理函数。
/// 首帧在限定时间内没有到达或者校验失败,直接关闭连接并计数。
/// </summary>
@@ -128,11 +135,13 @@ public sealed class TcpAcceptor
private readonly X509Certificate2 _certificate;
private readonly IDataSessions _sessions;
private readonly DataPlaneCounters _counters;
private readonly KnockGate _knock;
private readonly ConcurrentDictionary<uint, TcpResponder> _handlers = new();
private readonly List<TcpListener> _listeners = [];
public TcpAcceptor(IPAddress bind, IReadOnlyList<int> ports, X509Certificate2 certificate, IDataSessions sessions, DataPlaneCounters counters)
public TcpAcceptor(IPAddress bind, IReadOnlyList<int> ports, X509Certificate2 certificate, IDataSessions sessions, DataPlaneCounters counters, KnockGate knock)
{
_knock = knock;
_bind = bind;
_ports = ports;
_certificate = certificate;
@@ -214,6 +223,13 @@ public sealed class TcpAcceptor
using var authCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
authCts.CancelAfter(s_authTimeout);
// 预握手敲门:名册里的 Probe 才算得出敲门包;不通过就不写任何字节、直接断开
if (!await _knock.AdmitAsync(stream, (client.Client.RemoteEndPoint as IPEndPoint)?.Address, authCts.Token))
{
_counters.DropInvalid();
return;
}
byte[] first = new byte[1];
if (await stream.ReadAsync(first, authCts.Token) != 1)
{
@@ -0,0 +1,106 @@
using System.Globalization;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using MoShiro.Protocol;
namespace MoShiro.Measurement;
/// <summary>
/// 本机的公网地址:Agent 注册之后在 hello 里上报,面板用它自动填 <c>public_host</c> 和 <c>ipv6_host</c>,不需要管理员手填。
/// 只列出「别人能拨进来」的地址:IPv4 要排除私网、CGNAT、回环、链路本地;IPv6 只要全球单播(2000::/3),
/// 并且排除临时地址、tentative、deprecated 和文档/6to4/Teredo 段。读不到网卡信息时返回空,面板退回到管理员手填或观察到的来源地址。
/// </summary>
public static class LocalAddresses
{
public static IReadOnlyList<string> Sample()
{
var result = new List<string>();
HashSet<string> unusable = OperatingSystem.IsLinux() ? UnusableIpv6(Read("/proc/net/if_inet6")) : [];
try
{
foreach (NetworkInterface adapter in NetworkInterface.GetAllNetworkInterfaces())
{
if (adapter.OperationalStatus != OperationalStatus.Up || adapter.NetworkInterfaceType is NetworkInterfaceType.Loopback or NetworkInterfaceType.Tunnel)
{
continue;
}
foreach (UnicastIPAddressInformation unicast in adapter.GetIPProperties().UnicastAddresses)
{
IPAddress address = unicast.Address;
bool usable = address.AddressFamily == AddressFamily.InterNetwork
? PublicAddress.IsPublicIpv4(address)
: PublicAddress.IsGlobalIpv6(address) && !unusable.Contains(address.ToString()) && !IsTemporary(unicast);
if (usable && !result.Contains(address.ToString()))
{
result.Add(address.ToString());
}
}
}
}
catch (Exception ex) when (ex is NetworkInformationException or PlatformNotSupportedException)
{
return [];
}
// 稳定顺序:同一台机器每次上报的顺序一样,面板不会因为顺序变化而改地址
result.Sort(StringComparer.Ordinal);
return result;
}
/// <summary>Linux 的 <c>/proc/net/if_inet6</c>:临时(0x01)、dadfailed(0x08)、deprecated(0x20)、tentative(0x40)的地址不能当作对外地址。</summary>
public static HashSet<string> UnusableIpv6(string? procNetIfInet6)
{
var result = new HashSet<string>(StringComparer.Ordinal);
if (procNetIfInet6 is null)
{
return result;
}
foreach (string line in procNetIfInet6.Split('\n'))
{
string[] fields = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (fields.Length != 6 || fields[0].Length != 32 || !uint.TryParse(fields[4], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint flags) || (flags & 0x69) == 0)
{
continue;
}
var bytes = new byte[16];
for (int i = 0; i < 16; i++)
{
bytes[i] = byte.Parse(fields[0].AsSpan(i * 2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
}
result.Add(new IPAddress(bytes).ToString());
}
return result;
}
// Windows 和 macOS:随机后缀就是隐私(临时)地址
private static bool IsTemporary(UnicastIPAddressInformation unicast)
{
try
{
return OperatingSystem.IsWindows()
&& (unicast.SuffixOrigin == SuffixOrigin.Random || unicast.DuplicateAddressDetectionState is DuplicateAddressDetectionState.Tentative or DuplicateAddressDetectionState.Duplicate);
}
catch (PlatformNotSupportedException)
{
return false;
}
}
private static string? Read(string path)
{
try
{
return File.Exists(path) ? File.ReadAllText(path) : null;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return null;
}
}
}
@@ -96,5 +96,34 @@ public sealed class UpdateBridge
}
}
/// <summary>
/// 握手时自述的二进制:正在运行的可执行文件的哈希,加上宿主机更新器验过的、安装这个版本时用的 CI 签名清单(更新器写在只读的 <c>update-status/</c>)。
/// 容器节点以外(没有更新器)没有清单,只有哈希;读不到可执行文件时返回 null(不做绑定)。
/// </summary>
public BinaryAttestation? Attest()
{
if (NodeBinary.Sha256Hex is not { } hash)
{
return null;
}
string? manifest = ReadSmall("installed.manifest", MaxManifestBytes);
string? signature = ReadSmall("installed.sig", MaxSignatureBytes);
return manifest is not null && signature is not null ? new BinaryAttestation(hash, manifest, signature) : new BinaryAttestation(hash, null, null);
}
private string? ReadSmall(string name, int maxBytes)
{
try
{
string path = Path.Combine(Path.GetDirectoryName(_statusFile)!, name);
return File.Exists(path) && new FileInfo(path).Length <= maxBytes ? File.ReadAllText(path, Encoding.UTF8) : null;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return null;
}
}
private static bool Same(string path, byte[] content) => File.Exists(path) && File.ReadAllBytes(path).AsSpan().SequenceEqual(content);
}
@@ -0,0 +1,46 @@
using System.Net;
using System.Net.Sockets;
namespace MoShiro.Protocol;
/// <summary>哪些地址算「公网上能拨进来的地址」:Agent 上报本机地址和面板自动识别共用同一套判断。</summary>
public static class PublicAddress
{
public static bool IsPublicIpv4(IPAddress address)
{
if (address.AddressFamily != AddressFamily.InterNetwork)
{
return false;
}
byte[] b = address.GetAddressBytes();
return !(b[0] is 0 or 10 or 127 or >= 224
|| (b[0] == 100 && b[1] is >= 64 and <= 127)
|| (b[0] == 169 && b[1] == 254)
|| (b[0] == 172 && b[1] is >= 16 and <= 31)
|| (b[0] == 192 && b[1] == 168)
|| (b[0] == 192 && b[1] == 0 && b[2] is 0 or 2)
|| (b[0] == 198 && b[1] is 18 or 19)
|| (b[0] == 198 && b[1] == 51 && b[2] == 100)
|| (b[0] == 203 && b[1] == 0 && b[2] == 113));
}
public static bool IsGlobalIpv6(IPAddress address)
{
if (address.AddressFamily != AddressFamily.InterNetworkV6 || address.IsIPv4MappedToIPv6)
{
return false;
}
byte[] b = address.GetAddressBytes();
if ((b[0] & 0xE0) != 0x20)
{
return false;
}
// 2001:db8::/32 文档、2002::/16 6to4、2001::/32 Teredo
return !((b[0] == 0x20 && b[1] == 0x01 && b[2] == 0x0d && b[3] == 0xb8)
|| (b[0] == 0x20 && b[1] == 0x02)
|| (b[0] == 0x20 && b[1] == 0x01 && b[2] == 0 && b[3] == 0));
}
}
@@ -0,0 +1,55 @@
using Fleet.Rpc;
namespace MoShiro.Protocol;
/// <summary>节点的角色。服务端按节点 ID 的前缀判断;声明表用它限定哪些角色可以参与某个方法或通道。</summary>
public static class NodeRoles
{
public const string Agent = "agent";
public const string Probe = "probe";
public static string? Of(string nodeId) =>
nodeId.StartsWith(AgentIds.Prefix, StringComparison.Ordinal) ? Agent
: nodeId.StartsWith(ProbeId.Prefix, StringComparison.Ordinal) ? Probe
: null;
}
/// <summary>
/// 所有方法和二进制通道的方向声明,单一真源(框架的 <c>fleet.*</c> 见 <see cref="FleetMethodTable"/>)。
/// 新增一个方法:在 <see cref="MoShiroMethods"/> 里加名字和参数,在这里加一行声明,再在对应一侧注册处理函数;
/// 声明了但没人处理、或者处理了但没声明,都会被测试和启动时的检查发现。
/// 同一个方法名只有一个发起方;节点面是服务端和节点(B 和 A、P),对等面是 Probe(拨号方)和 Agent(监听方)。
/// </summary>
public static class MoShiroMethodTable
{
public static readonly MethodTable Table = MethodTable.Combine(
FleetMethodTable.Table,
new MethodTable(
[
// 节点面:Backend → 节点
MethodSpec.Request(MoShiroMethods.TestRun, RpcSide.Server, NodeRoles.Agent),
MethodSpec.Request(MoShiroMethods.TestCancel, RpcSide.Server, NodeRoles.Agent),
MethodSpec.Request(MoShiroMethods.ProbeDisconnect, RpcSide.Server, NodeRoles.Agent),
MethodSpec.Notification(MoShiroMethods.UpdateOffer, RpcSide.Server),
MethodSpec.Notification(MoShiroMethods.UpdateApply, RpcSide.Server),
MethodSpec.Notification(MoShiroMethods.Schedule, RpcSide.Server),
// 节点面:Agent → Backend
MethodSpec.Notification(MoShiroMethods.TestStatus, RpcSide.Node, NodeRoles.Agent),
MethodSpec.Notification(MoShiroMethods.Event, RpcSide.Node, NodeRoles.Agent),
MethodSpec.Notification(MoShiroMethods.Host, RpcSide.Node, NodeRoles.Agent),
// 对等面:Probe(拨号方)→ Agent(监听方)
MethodSpec.Request(MoShiroMethods.Clock, RpcSide.Dialer),
// 对等面:Agent → Probe
MethodSpec.Request(MoShiroMethods.TestPhase, RpcSide.Listener),
MethodSpec.Request(MoShiroMethods.TestPhaseEnd, RpcSide.Listener),
MethodSpec.Notification(MoShiroMethods.TestAbort, RpcSide.Listener),
],
[
// 原始块:只有 Agent 往 Backend 发;列块:Probe 经对等会话发给 Agent
new ChannelSpec(MoShiroChannels.RawChunk, RpcSide.Node, [NodeRoles.Agent]),
new ChannelSpec(MoShiroChannels.ColumnBlock, RpcSide.Dialer),
]));
}
@@ -15,6 +15,7 @@ public static class MoShiroMethods
public const string Host = "moshiro.host";
public const string UpdateOffer = "moshiro.update.offer";
public const string UpdateApply = "moshiro.update.apply";
public const string Schedule = "moshiro.schedule";
// Probe ↔ Agent(点对点会话)
public const string Clock = "moshiro.clock";
@@ -148,9 +149,9 @@ public sealed class AgentEndpoint
[JsonPropertyName("udp_ports")]
public List<int> UdpPorts { get; set; } = [];
/// <summary>Agent 面向 Probe 的 TLS 证书指纹(SHA-256,base64)。</summary>
[JsonPropertyName("spki")]
public string Spki { get; set; } = "";
/// <summary>Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它和 Probe 的身份密钥派生。</summary>
[JsonPropertyName("public_key")]
public string PublicKey { get; set; } = "";
}
/// <summary>期望状态文档(Fleet state type = <c>probe_agents</c>):Backend 直接下发给每个 Probe 的 Agent 列表。</summary>
@@ -163,6 +164,9 @@ public sealed class ProbeAgentList
[JsonPropertyName("agents")]
public List<AgentEndpoint> Agents { get; set; } = [];
[JsonPropertyName("trust_chain")]
public List<SignedStatementDto>? TrustChain { get; set; }
}
/// <summary>注册元数据(MoShiro 定义,经 Fleet 的 <c>meta</c> 字段带上来)。</summary>
@@ -238,6 +242,17 @@ public sealed class UpdateOfferParams
public string Signature { get; set; } = "";
}
/// <summary>
/// 面板给节点的定时重启设置(<c>moshiro.schedule</c> 通知):<c>restart_at</c> 形如 <c>04:30+08:00</c>,为空表示不定时重启。
/// 节点连上时补发、设置变化时群发;节点本地写了 <c>restart_at</c> 或环境变量 <c>MOSHIRO_RESTART_AT</c> 时以本地为准。
/// 节点只是到点自己有序退出,由服务管理器拉起,不执行任何命令。
/// </summary>
public sealed class ScheduleParams
{
[JsonPropertyName("restart_at")]
public string? RestartAt { get; set; }
}
/// <summary>宿主机更新器已经验证过的可用更新。</summary>
public sealed class UpdateAvailable
{
@@ -291,6 +306,10 @@ public sealed class ProbeHeartbeatExtra
/// <summary>Probe 的 <c>fleet.hello</c> 附加字段:系统、架构和构建信息(只用于展示和更新)。</summary>
public sealed class ProbeHelloMeta
{
/// <summary>这个 Probe 的属主:安装时固定在节点本地的 passkey 凭证 id。面板据此知道它绑定在哪个账户的哪把 passkey 上。</summary>
[JsonPropertyName("owners")]
public List<string>? Owners { get; set; }
[JsonPropertyName("ipv6")]
public Ipv6Diagnostic? Ipv6 { get; set; }
@@ -301,6 +320,10 @@ public sealed class ProbeHelloMeta
/// <summary><c>fleet.hello</c> 的附加字段。</summary>
public sealed class AgentHelloMeta
{
/// <summary>这个 Agent 的属主:安装时固定在节点本地的 passkey 凭证 id。面板据此知道它绑定在哪个账户的哪把 passkey 上。</summary>
[JsonPropertyName("owners")]
public List<string>? Owners { get; set; }
[JsonPropertyName("ipv6")]
public Ipv6Diagnostic? Ipv6 { get; set; }
@@ -311,8 +334,13 @@ public sealed class AgentHelloMeta
[JsonPropertyName("platform")]
public PlatformInfo? Platform { get; set; }
[JsonPropertyName("tls_spki")]
public string? TlsSpki { get; set; }
/// <summary>Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64);面板据此得到证书指纹,并下发给 Probe 用来固定证书和派生敲门密钥。</summary>
[JsonPropertyName("tls_pubkey")]
public string? TlsPublicKey { get; set; }
/// <summary>本机的公网地址(IPv4 公网地址、IPv6 全球单播地址):面板据此自动填 <c>public_host</c> 和 <c>ipv6_host</c>,管理员手填的优先。</summary>
[JsonPropertyName("addresses")]
public List<string>? Addresses { get; set; }
[JsonPropertyName("probe_tcp_port")]
public int? ProbeTcpPort { get; set; }
@@ -560,6 +588,7 @@ public static class FragmentKeys
[JsonSerializable(typeof(AgentEventParams))]
[JsonSerializable(typeof(ProbeAgentList))]
[JsonSerializable(typeof(UpdateOfferParams))]
[JsonSerializable(typeof(ScheduleParams))]
[JsonSerializable(typeof(UpdateStatus))]
[JsonSerializable(typeof(ProbeHeartbeatExtra))]
[JsonSerializable(typeof(ProbeHelloMeta))]
@@ -56,6 +56,10 @@ public sealed class TaskSet
/// <summary>允许连接到这个 Agent 的 Probe 公钥。</summary>
[JsonPropertyName("peers")]
public List<PeerSpec> Peers { get; set; } = [];
/// <summary>操作员集合的链(passkey 签的,创世在前):节点从固定的创世指纹验过才采用。</summary>
[JsonPropertyName("trust_chain")]
public List<SignedStatementDto>? TrustChain { get; set; }
}
public static class TaskSchedule
@@ -0,0 +1,36 @@
using MoShiro.Measurement;
namespace MoShiro.Measurement.Tests;
public sealed class LocalAddressesTests
{
[Fact]
public void Linux_TemporaryTentativeDeprecatedAndFailedAddressesAreNotUsable()
{
const string text =
"2409c0d0000000000000000000000001 02 40 00 80 eth0\n" + // 稳定地址:可用
"2409c0d0000000000000000000000002 02 40 00 01 eth0\n" + // 临时(隐私)地址
"2409c0d0000000000000000000000003 02 40 00 40 eth0\n" + // tentative
"2409c0d0000000000000000000000004 02 40 00 20 eth0\n" + // deprecated
"2409c0d0000000000000000000000005 02 40 00 08 eth0\n"; // dad 失败
HashSet<string> unusable = LocalAddresses.UnusableIpv6(text);
Assert.DoesNotContain("2409:c0d0::1", unusable);
Assert.Contains("2409:c0d0::2", unusable);
Assert.Contains("2409:c0d0::3", unusable);
Assert.Contains("2409:c0d0::4", unusable);
Assert.Contains("2409:c0d0::5", unusable);
Assert.Empty(LocalAddresses.UnusableIpv6(null));
}
[Fact]
public void Sample_NeverReturnsLoopbackPrivateOrLinkLocalAddresses()
{
foreach (string text in LocalAddresses.Sample())
{
var address = System.Net.IPAddress.Parse(text);
Assert.True(address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork
? MoShiro.Protocol.PublicAddress.IsPublicIpv4(address)
: MoShiro.Protocol.PublicAddress.IsGlobalIpv6(address));
}
}
}
@@ -0,0 +1,35 @@
using System.Net;
using MoShiro.Protocol;
namespace MoShiro.Protocol.Tests;
public sealed class PublicAddressTests
{
[Theory]
[InlineData("8.8.8.8", true)]
[InlineData("23.176.40.145", true)]
[InlineData("10.1.2.3", false)]
[InlineData("172.16.0.1", false)]
[InlineData("172.32.0.1", true)]
[InlineData("192.168.2.162", false)]
[InlineData("100.64.0.1", false)]
[InlineData("169.254.1.1", false)]
[InlineData("127.0.0.1", false)]
[InlineData("203.0.113.10", false)]
[InlineData("224.0.0.1", false)]
public void Ipv4_OnlyPublicAddressesCountAsReachable(string text, bool expected) =>
Assert.Equal(expected, PublicAddress.IsPublicIpv4(IPAddress.Parse(text)));
[Theory]
[InlineData("2409:8a55:6c45:62d1::69d", true)]
[InlineData("2606:4700:4700::1111", true)]
[InlineData("fd00::1", false)]
[InlineData("fe80::1", false)]
[InlineData("::1", false)]
[InlineData("2001:db8::1", false)]
[InlineData("2002:c000:204::1", false)]
[InlineData("2001:0:4136:e378::1", false)]
[InlineData("::ffff:8.8.8.8", false)]
public void Ipv6_OnlyGlobalUnicastCountsAsReachable(string text, bool expected) =>
Assert.Equal(expected, PublicAddress.IsGlobalIpv6(IPAddress.Parse(text)));
}
@@ -1,4 +1,5 @@
using Fleet.Core;
using Fleet.Identity;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
@@ -17,56 +18,67 @@ public static class AgentEndpoints
public static void Map(RouteGroupBuilder group)
{
group.MapGet("/agents", ListAsync)
.Api("agents_list", "All agents including pending and disabled ones", AgentsTag).Returns<PageDto<AgentDto>>().ProducesProblems(401, 403);
.Api("agents_list", "All agents including pending and disabled ones", AgentsTag).Returns<PageDto<AgentDto>>().ProducesProblems(401, 403)
.Requires(Capability.FleetRead);
group.MapGet("/agents/{id}", GetAsync)
.Api("agents_get", "One agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404);
.Api("agents_get", "One agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404)
.Requires(Capability.FleetRead);
group.MapPatch("/agents/{id}", PatchAsync)
.Accepts<AgentPatchRequest>("application/merge-patch+json", "application/json")
.Api("agents_update", "Update name, labels, public host or visibility (merge-patch, If-Match supported)", AgentsTag)
.Returns<AgentDto>().ProducesProblems(400, 401, 403, 404, 409, 412);
.Returns<AgentDto>().ProducesProblems(400, 401, 403, 404, 409, 412)
.Requires(Capability.FleetWrite);
group.MapPost("/agents/{id}/approve", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.ApproveAsync(nodeId, ct)))
.Api("agents_approve", "Approve a pending agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409);
.Api("agents_approve", "Approve a pending agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409)
.Requires(Capability.FleetEnroll);
group.MapPost("/agents/{id}/disable", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.DisableAsync(nodeId, ct)))
.Api("agents_disable", "Disable an agent and drop its connection", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409);
.Api("agents_disable", "Disable an agent and drop its connection", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409)
.Requires(Capability.FleetWrite);
group.MapPost("/agents/{id}/enable", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.EnableAsync(nodeId, ct)))
.Api("agents_enable", "Enable a disabled agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409);
.Api("agents_enable", "Enable a disabled agent", AgentsTag).Returns<AgentDto>().ProducesProblems(401, 403, 404, 409)
.Requires(Capability.FleetWrite);
group.MapPost("/agents/{id}/takeover", (HttpContext http, AgentService agents, string id) => TransitionAsync(http, id, (nodeId, ct) => agents.TakeoverAsync(nodeId, ct)))
.Api("agents_takeover", "Replace the existing node of the same name with this pending one, keeping history", AgentsTag)
.Returns<AgentDto>().ProducesProblems(401, 403, 404);
.Returns<AgentDto>().ProducesProblems(401, 403, 404)
.Requires(Capability.FleetEnroll);
group.MapDelete("/agents/{id}", DeleteAsync)
.Api("agents_delete", "Delete an agent; history expires with the retention policy", AgentsTag)
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
.Requires(Capability.FleetRevoke);
group.MapGet("/join-tokens", ListTokensAsync)
.Api("join_tokens_list", "Join tokens", TokensTag).Returns<PageDto<JoinTokenDto>>().ProducesProblems(401, 403);
.Api("join_tokens_list", "Join tokens", TokensTag).Returns<PageDto<JoinTokenDto>>().ProducesProblems(401, 403)
.Requires(Capability.FleetRead);
group.MapPost("/join-tokens", CreateTokenAsync)
.Api("join_tokens_create", "Create a join token; the plaintext is returned only once", TokensTag)
.Returns<CreatedJoinTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403);
.Api("join_tokens_create", "Create a join token for an agent or a probe (role); the plaintext is returned only once", TokensTag)
.Returns<CreatedJoinTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403)
.Requires(Capability.FleetEnroll);
group.MapDelete("/join-tokens/{id}", RevokeTokenAsync)
.Api("join_tokens_revoke", "Revoke a join token; with purge=true delete the record for good", TokensTag)
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
.Requires(Capability.FleetEnroll);
group.MapGet("/probes", ListProbesAsync)
.Api("probes_list", "All probes, with the agents each one is connected to", ProbeTag).Returns<PageDto<ProbeDto>>().ProducesProblems(401, 403);
group.MapPost("/probes", CreateProbeAsync)
.Api("probes_create", "Create a probe identity; the private key is returned only once", ProbeTag)
.Returns<ProbeIdentityDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409);
.Api("probes_list", "All probes, with the agents each one is connected to", ProbeTag).Returns<PageDto<ProbeDto>>().ProducesProblems(401, 403)
.Requires(Capability.FleetRead);
group.MapGet("/probes/{id}", GetProbeAsync)
.Api("probes_get", "One probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(401, 403, 404);
.Api("probes_get", "One probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(401, 403, 404)
.Requires(Capability.FleetRead);
group.MapPatch("/probes/{id}", PatchProbeAsync)
.Api("probes_update", "Rename a probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(400, 401, 403, 404, 409);
group.MapPost("/probes/{id}/rotate", RotateProbeAsync)
.Api("probes_rotate", "Give a probe a new key (same id): the old key stops working and its sessions are dropped; the new probe.json is returned only once", ProbeTag)
.Returns<ProbeIdentityDto>().ProducesProblems(401, 403, 404, 409);
.Api("probes_update", "Rename a probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(400, 401, 403, 404, 409)
.Requires(Capability.FleetWrite);
group.MapPost("/probes/{id}/revoke", RevokeProbeAsync)
.Api("probes_revoke", "Revoke a probe: every agent drops its session and refuses it; history stays until it expires", ProbeTag)
.Returns<ProbeDto>().ProducesProblems(401, 403, 404);
.Returns<ProbeDto>().ProducesProblems(401, 403, 404)
.Requires(Capability.FleetRevoke);
group.MapDelete("/probes/{id}", DeleteProbeAsync)
.Api("probes_delete", "Delete a probe and its lines; stored data expires with the retention policy", ProbeTag)
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
.Requires(Capability.FleetRevoke);
group.MapPost("/agents/{id}/probes/{probe_id}/disconnect", DisconnectProbeAsync)
.Api("agents_probe_disconnect", "Make an agent drop one probe's session (the probe reconnects unless it is revoked)", AgentsTag)
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 404, 409);
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 404, 409)
.Requires(Capability.FleetWrite);
}
private static async Task<IResult> ListAsync(AgentService agents, CancellationToken ct) =>
@@ -87,7 +99,7 @@ public static class AgentEndpoints
private static async Task<IResult> PatchAsync(HttpContext http, AgentService agents, AgentPatchRequest request, string id, CancellationToken ct)
{
Result<AgentView> result = await agents.PatchAsync(
new NodeId(id), new AgentPatch { Name = request.Name, Labels = request.Labels, PublicHost = request.PublicHost, Ipv6Host = request.Ipv6Host, PublicVisible = request.PublicVisible }, Concurrency.ExpectedRowVersion(http), ct);
new NodeId(id), new AgentPatch { Name = request.Name, Labels = request.Labels, PublicHost = request.PublicHost, Ipv6Host = request.Ipv6Host, PublicHostAuto = request.PublicHostAuto, Ipv6HostAuto = request.Ipv6HostAuto, PublicVisible = request.PublicVisible }, Concurrency.ExpectedRowVersion(http), ct);
if (!result.TryGet(out AgentView? view, out FleetError? error))
{
return Problems.From(http, error);
@@ -118,11 +130,23 @@ public static class AgentEndpoints
private static async Task<IResult> ListTokensAsync(JoinTokenService tokens, CancellationToken ct) =>
TypedResults.Json(new PageDto<JoinTokenDto>([.. (await tokens.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoJoinTokenDto);
private static async Task<IResult> CreateTokenAsync(HttpContext http, JoinTokenService tokens, CreateJoinTokenRequest request, BackendOptions options)
private static async Task<IResult> CreateTokenAsync(HttpContext http, JoinTokenService tokens, TrustService trust, CreateJoinTokenRequest request, BackendOptions options)
{
string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}";
// 信任根建立之后,命令里带上创世指纹和属主;属主默认是操作员集合里的全部 passkey,也可以只选其中几把
OperatorSet? set = await trust.CurrentAsync(http.RequestAborted);
string? genesis = set is null ? null : (await trust.StateAsync(AuthEndpoints.PasskeyContextOf(http, options), http.RequestAborted)).Genesis;
Result<string[]> owner = OwnerPolicy.Resolve(options.RequireOwner, set, request.Controllers);
if (!owner.TryGet(out string[]? controllers, out FleetError? ownerError))
{
return Problems.From(http, ownerError);
}
var plan = new InstallPlan(genesis, controllers, InstallScripts.Sha256(InstallScripts.Sh), InstallScripts.Sha256(InstallScripts.Ps1));
http.SetDetail($"{request.Role ?? JoinTokenService.AgentRole}: {request.Note}");
Result<CreatedJoinToken> result = await tokens.CreateAsync(
new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24), baseUrl, http.RequestAborted);
new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24, request.Role ?? JoinTokenService.AgentRole, http.GetPrincipal().Label), baseUrl, plan, http.RequestAborted);
if (!result.TryGet(out CreatedJoinToken? created, out FleetError? error))
{
return Problems.From(http, error);
@@ -150,19 +174,6 @@ public static class AgentEndpoints
private static async Task<IResult> ListProbesAsync(ProbeService probes, CancellationToken ct) =>
TypedResults.Json(new PageDto<ProbeDto>([.. (await probes.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoProbeDto);
private static async Task<IResult> CreateProbeAsync(HttpContext http, ProbeService probes, BackendOptions options, CreateProbeRequest? request, CancellationToken ct)
{
Result<ProbeIdentityFile> result = await probes.CreateAsync(request?.Name, PanelUrl(http, options), ct);
if (!result.TryGet(out ProbeIdentityFile? file, out FleetError? error))
{
return Problems.From(http, error);
}
http.Response.Headers.CacheControl = "no-store";
http.Response.Headers.Location = "/api/admin/probes/" + file.ProbeId.Value;
return TypedResults.Json(ToIdentityDto(file), ApiJsonContext.Default.ProbeIdentityDto, statusCode: StatusCodes.Status201Created);
}
private static async Task<IResult> GetProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct) =>
await probes.GetAsync(id, ct) is { } view
? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto)
@@ -179,18 +190,6 @@ public static class AgentEndpoints
return result.TryGet(out ProbeView? view, out FleetError? error) ? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto) : Problems.From(http, error);
}
private static async Task<IResult> RotateProbeAsync(HttpContext http, ProbeService probes, BackendOptions options, string id, CancellationToken ct)
{
Result<ProbeIdentityFile> result = await probes.RotateAsync(id, PanelUrl(http, options), ct);
if (!result.TryGet(out ProbeIdentityFile? file, out FleetError? error))
{
return Problems.From(http, error);
}
http.Response.Headers.CacheControl = "no-store";
return TypedResults.Json(ToIdentityDto(file), ApiJsonContext.Default.ProbeIdentityDto);
}
private static async Task<IResult> RevokeProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct)
{
Result<ProbeView> result = await probes.RevokeAsync(id, ct);
@@ -213,10 +212,4 @@ public static class AgentEndpoints
return dropped ? TypedResults.NoContent() : Problems.Create(http, MoShiroErrors.ProbeOffline, "this probe has no session with this agent");
}
/// <summary>Probe 要连的面板地址:配置的公开地址,没有就用这次请求的地址。</summary>
private static string PanelUrl(HttpContext http, BackendOptions options) => (options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}").TrimEnd('/');
private static ProbeIdentityDto ToIdentityDto(ProbeIdentityFile file) =>
new(file.ProbeId.Value, file.PrivateKey, file.Server, new ProbeLimitsDto(file.Limits.MaxTestMbps, file.Limits.MaxTotalPps));
}
@@ -0,0 +1,44 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using MoShiro.Backend.Application;
using MoShiro.Protocol;
namespace MoShiro.Backend.Api;
/// <summary>审计日志:管理接口里每个写操作、登录都会记一行,这里只读。</summary>
public static class AuditEndpoints
{
private const string Tag = "admin-audit";
/// <summary>handler 往这里放一句「这次具体做了什么」(例如新建令牌的名字),守卫记审计时一起写进去。</summary>
public const string DetailItem = "moshiro.audit.detail";
public static void Map(RouteGroupBuilder guarded) =>
guarded.MapGet("/audit", ListAsync)
.Api("audit_list", "Audit log of admin actions, newest first", Tag).Returns<PageDto<AuditDto>>().ProducesProblems(400, 401, 403)
.Requires(Capability.AuditRead);
public static void SetDetail(this HttpContext http, string detail) => http.Items[DetailItem] = detail;
private static async Task<IResult> ListAsync(HttpContext http, AuditService audit, string? actor, string? action, int? limit, string? cursor)
{
long? before = null;
if (cursor is not null)
{
if (!long.TryParse(cursor, out long parsed))
{
return Problems.From(http, ValidationError.Single("cursor", "is not a valid cursor"));
}
before = parsed;
}
int size = Math.Clamp(limit ?? 50, 1, 500);
IReadOnlyList<AuditRecord> rows = await audit.ListAsync(new AuditQuery(before, size + 1, string.IsNullOrEmpty(actor) ? null : actor, string.IsNullOrEmpty(action) ? null : action), http.RequestAborted);
bool more = rows.Count > size;
IReadOnlyList<AuditRecord> page = [.. rows.Take(size)];
string? next = more ? page[^1].Id.ToString(System.Globalization.CultureInfo.InvariantCulture) : null;
return TypedResults.Json(new PageDto<AuditDto>([.. page.Select(Mappers.ToDto)], next), ApiJsonContext.Default.PageDtoAuditDto);
}
}
@@ -16,36 +16,104 @@ public static class AuthEndpoints
public static void MapLogin(RouteGroupBuilder open)
{
open.MapPost("/auth/login", LoginAsync)
.AllowsAnonymous()
.Api("auth_login", "Sign in with a username and password", Tag)
.Returns<LoginResponse>().ProducesProblems(400, 401, 429);
open.MapGet("/auth/setup", SetupStatusAsync)
.AllowsAnonymous()
.Api("setup_status", "Whether the panel has no users yet and still has to be claimed", Tag)
.Returns<SetupStatusDto>();
open.MapPost("/auth/setup", SetupAsync)
.AllowsAnonymous()
.Api("setup_complete", "Create the first administrator (needs the claim code from the backend's log); the same response as the password sign-in. Closed once the panel is claimed (a user exists)", Tag)
.Returns<LoginResponse>(StatusCodes.Status201Created).ProducesProblems(400, 401, 409, 429);
open.MapPost("/auth/passkey/options", PasskeyLoginOptions)
.AllowsAnonymous()
.Api("auth_passkey_options", "A challenge for signing in with a passkey (discoverable credential, no username needed)", Tag)
.Returns<PasskeyRequestOptionsDto>().ProducesProblems(429);
open.MapPost("/auth/passkey/login", PasskeyLoginAsync)
.AllowsAnonymous()
.Api("auth_passkey_login", "Sign in with a passkey assertion; same response as the password sign-in", Tag)
.Returns<LoginResponse>().ProducesProblems(400, 401, 429);
}
public static void MapSession(RouteGroupBuilder guarded)
{
guarded.MapPost("/auth/logout", LogoutAsync)
.Api("auth_logout", "Sign out and clear the session cookie", Tag)
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401);
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401)
.Requires(Capability.Authenticated);
guarded.MapGet("/auth/passkeys", ListPasskeysAsync)
.Api("auth_passkeys_list", "The signed-in user's passkeys", Tag)
.Returns<PageDto<PasskeyDto>>().ProducesProblems(401)
.Requires(Capability.Authenticated);
guarded.MapPost("/auth/passkeys/options", PasskeyCreationOptionsAsync)
.Api("auth_passkeys_options", "A challenge for registering a new passkey (the user must be signed in)", Tag)
.Returns<PasskeyCreationOptionsDto>().ProducesProblems(400, 401, 403)
.Requires(Capability.Authenticated);
guarded.MapPost("/auth/passkeys", RegisterPasskeyAsync)
.Api("auth_passkeys_register", "Register a passkey from a WebAuthn attestation (ES256, user verification required)", Tag)
.Returns<PasskeyDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
.Requires(Capability.Authenticated);
guarded.MapDelete("/auth/passkeys/{id}", DeletePasskeyAsync)
.Api("auth_passkeys_delete", "Remove one of your own passkeys", Tag)
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 404)
.Requires(Capability.Authenticated);
guarded.MapGet("/auth/me", Me)
.Api("auth_me", "The current identity", Tag)
.Returns<MeDto>().ProducesProblems(401);
.Returns<MeDto>().ProducesProblems(401)
.Requires(Capability.Authenticated);
}
private static async Task<IResult> LoginAsync(HttpContext http, LoginRequest request, AuthService auth, LoginRateLimiter limiter, BackendOptions options)
private static async Task<IResult> LoginAsync(HttpContext http, LoginRequest request, AuthService auth, AuditService audit, LoginRateLimiter limiter, BackendOptions options)
{
string source = http.Connection.RemoteIpAddress?.ToString() ?? "unknown";
string source = http.ClientAddress();
if (!limiter.TryAcquire(source, out int retryAfter))
{
return Problems.RetryAfter(http, new FleetError(MoShiroErrors.TooManyRequests, "too many sign-in attempts"), retryAfter);
}
Result<LoginResult> result = await auth.LoginAsync(request.Username ?? "", request.Password ?? "", http.RequestAborted);
string remote = source;
if (!result.TryGet(out LoginResult? login, out FleetError? error))
{
await audit.RecordAsync(null, request.Username ?? "", AuthMethods.Password, "auth.login", null, null, remote, false, http.RequestAborted);
return Problems.From(http, error);
}
// 跨域的前端(return_session)不用 Cookie:会话令牌放在响应里,之后用 Bearer 带上
bool returnSession = request.ReturnSession == true;
await audit.RecordAsync(login.UserId, login.Username, AuthMethods.Password, "auth.login", null, null, remote, true, http.RequestAborted);
return IssueSession(http, login, request.ReturnSession == true, options);
}
private static async Task<IResult> SetupStatusAsync(SetupService setup, CancellationToken ct)
{
return TypedResults.Json(new SetupStatusDto(await setup.NeedsSetupAsync(ct)), ApiJsonContext.Default.SetupStatusDto);
}
private static async Task<IResult> SetupAsync(HttpContext http, SetupRequest request, SetupService setup, AuditService audit, LoginRateLimiter limiter, BackendOptions options)
{
IResult limit = Limited(http, limiter, out bool ok, "setup:");
if (!ok)
{
return limit;
}
string remote = http.ClientAddress();
Result<LoginResult> result = await setup.CompleteAsync(request.ClaimCode, request.Username ?? "", request.Password ?? "", http.RequestAborted);
if (!result.TryGet(out LoginResult? login, out FleetError? error))
{
await audit.RecordAsync(null, request.Username ?? "", AuthMethods.Password, "setup.complete", null, null, remote, false, http.RequestAborted);
return Problems.From(http, error);
}
await audit.RecordAsync(login.UserId, login.Username, AuthMethods.Password, "setup.complete", null, null, remote, true, http.RequestAborted);
Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<LoginResponse> session = IssueSession(http, login, request.ReturnSession == true, options);
return TypedResults.Json(session.Value!, ApiJsonContext.Default.LoginResponse, statusCode: StatusCodes.Status201Created);
}
/// <summary>发放会话:Cookie(同源前端)或在响应里返回会话令牌(跨域前端的 return_session)。密码登录和 passkey 登录共用。</summary>
private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<LoginResponse> IssueSession(HttpContext http, LoginResult login, bool returnSession, BackendOptions options)
{
if (!returnSession)
{
http.Response.Cookies.Append(
@@ -65,6 +133,98 @@ public static class AuthEndpoints
return TypedResults.Json(new LoginResponse(login.UserId, login.Username, login.CsrfToken, Mappers.Time(login.ExpiresAtMs), returnSession ? login.SessionToken : null), ApiJsonContext.Default.LoginResponse);
}
/// <summary>这次请求的 WebAuthn 依赖方:配置优先,其次面板对外地址,最后是请求自己的主机。</summary>
internal static PasskeyContext PasskeyContextOf(HttpContext http, BackendOptions options)
{
string own = $"{http.Request.Scheme}://{http.Request.Host}";
Uri? configured = Uri.TryCreate(options.PublicUrl, UriKind.Absolute, out Uri? uri) ? uri : null;
string rpId = !string.IsNullOrWhiteSpace(options.WebAuthnRpId) ? options.WebAuthnRpId.Trim() : configured?.Host ?? http.Request.Host.Host;
IEnumerable<string> origins = !string.IsNullOrWhiteSpace(options.WebAuthnOrigins)
? options.WebAuthnOrigins.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(o => o.TrimEnd('/'))
: [configured is null ? own : configured.GetLeftPart(UriPartial.Authority)];
return new PasskeyContext(rpId, new HashSet<string>(origins, StringComparer.Ordinal));
}
/// <summary>限流。<paramref name="scope"/> 把「取挑战」和「提交登录」分成两个桶:页面加载取挑战不该占用登录失败的额度。</summary>
private static IResult Limited(HttpContext http, LoginRateLimiter limiter, out bool ok, string scope = "", int? limit = null)
{
string source = scope + (http.ClientAddress());
ok = limiter.TryAcquire(source, out int retryAfter, limit);
return ok ? TypedResults.NoContent() : Problems.RetryAfter(http, new FleetError(MoShiroErrors.TooManyRequests, "too many sign-in attempts"), retryAfter);
}
/// <summary>取挑战只是页面加载和点按钮,不是猜密码:额度比登录失败宽松,挑战总量另有上限(淘汰最旧的)。</summary>
private const int PasskeyOptionsPerMinute = 60;
private static IResult PasskeyLoginOptions(HttpContext http, PasskeyService passkeys, LoginRateLimiter limiter, BackendOptions options)
{
IResult limit = Limited(http, limiter, out bool ok, "passkey-options:", limit: PasskeyOptionsPerMinute);
if (!ok)
{
return limit;
}
RequestOptions o = passkeys.BeginLogin(PasskeyContextOf(http, options));
http.Response.Headers.CacheControl = "no-store";
return TypedResults.Json(new PasskeyRequestOptionsDto(o.Challenge, o.RpId, o.TimeoutMs), ApiJsonContext.Default.PasskeyRequestOptionsDto);
}
private static async Task<IResult> PasskeyLoginAsync(HttpContext http, PasskeyLoginRequest request, PasskeyService passkeys, AuditService audit, LoginRateLimiter limiter, BackendOptions options)
{
IResult limit = Limited(http, limiter, out bool ok);
if (!ok)
{
return limit;
}
Result<LoginResult> result = await passkeys.LoginAsync(
new PasskeyAssertion(request.CredentialId ?? "", request.AuthenticatorData ?? "", request.ClientDataJson ?? "", request.Signature ?? ""), PasskeyContextOf(http, options), http.RequestAborted);
string? remote = http.ClientAddress();
if (!result.TryGet(out LoginResult? login, out FleetError? error))
{
await audit.RecordAsync(null, "?", AuthMethods.Passkey, "auth.login", null, null, remote, false, http.RequestAborted);
return Problems.From(http, error);
}
await audit.RecordAsync(login.UserId, login.Username, AuthMethods.Passkey, "auth.login", null, null, remote, true, http.RequestAborted);
return IssueSession(http, login, request.ReturnSession == true, options);
}
private static async Task<IResult> ListPasskeysAsync(HttpContext http, PasskeyService passkeys)
{
string userId = http.GetPrincipal().UserId ?? "";
IReadOnlyList<PasskeyRecord> list = userId.Length == 0 ? [] : await passkeys.ListAsync(userId, http.RequestAborted);
return TypedResults.Json(new PageDto<PasskeyDto>([.. list.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoPasskeyDto);
}
private static async Task<IResult> PasskeyCreationOptionsAsync(HttpContext http, PasskeyService passkeys, BackendOptions options)
{
Result<CreationOptions> result = await passkeys.BeginRegistrationAsync(http.GetPrincipal(), PasskeyContextOf(http, options), http.RequestAborted);
if (!result.TryGet(out CreationOptions? o, out FleetError? error))
{
return Problems.From(http, error);
}
http.Response.Headers.CacheControl = "no-store";
return TypedResults.Json(new PasskeyCreationOptionsDto(o.Challenge, o.RpId, o.RpName, o.UserId, o.UserName, o.ExcludeCredentials, o.TimeoutMs), ApiJsonContext.Default.PasskeyCreationOptionsDto);
}
private static async Task<IResult> RegisterPasskeyAsync(HttpContext http, RegisterPasskeyRequest request, PasskeyService passkeys, BackendOptions options)
{
Result<PasskeyRecord> result = await passkeys.RegisterAsync(
http.GetPrincipal(), new PasskeyRegistration(request.Name ?? "", request.CredentialId ?? "", request.AttestationObject ?? "", request.ClientDataJson ?? "", request.Transports), PasskeyContextOf(http, options), http.RequestAborted);
return result.TryGet(out PasskeyRecord? record, out FleetError? error)
? TypedResults.Json(Mappers.ToDto(record), ApiJsonContext.Default.PasskeyDto, statusCode: StatusCodes.Status201Created)
: Problems.From(http, error);
}
private static async Task<IResult> DeletePasskeyAsync(HttpContext http, PasskeyService passkeys, string id)
{
return http.GetPrincipal().UserId is { } userId && await passkeys.DeleteAsync(userId, id, http.RequestAborted)
? TypedResults.NoContent()
: Problems.Create(http, MoShiroErrors.NotFound, "no such passkey");
}
private static async Task<IResult> LogoutAsync(HttpContext http, AuthService auth)
{
string? bearer = PrincipalResolver.BearerOf(http);
@@ -85,7 +245,7 @@ public static class AuthEndpoints
{
Principal principal = http.GetPrincipal();
return TypedResults.Json(
new MeDto(principal.Kind.ToString().ToLowerInvariant(), principal.Scope.ToString().ToLowerInvariant(), principal.UserId, principal.Username, principal.CsrfToken),
new MeDto(principal.Kind.ToString().ToLowerInvariant(), [.. principal.Capabilities.Order(StringComparer.Ordinal)], principal.UserId, principal.Username, principal.CsrfToken, principal.AuthMethod.Length > 0 ? principal.AuthMethod : null),
ApiJsonContext.Default.MeDto);
}
}
@@ -17,17 +17,26 @@ public static class BuildEndpoints
{
group.MapGet("/build-info", GetAsync)
.Api("build_info_get", "Version, tag, commit, build time and update state of the backend and every agent and probe", Tag)
.Returns<BuildInfoDto>().ProducesProblems(401, 403);
.Returns<BuildInfoDto>().ProducesProblems(401, 403)
.Requires(Capability.UpdateRead);
group.MapGet("/releases", ListReleasesAsync)
.Api("releases_list", "The latest signed release manifest of each component", Tag).Returns<PageDto<ReleaseDto>>().ProducesProblems(401, 403);
.Api("releases_list", "The latest signed release manifest of each component", Tag).Returns<PageDto<ReleaseDto>>().ProducesProblems(401, 403)
.Requires(Capability.UpdateRead);
group.MapPost("/nodes/{id}/update", RequestUpdateAsync)
.Api("nodes_update", "Ask an online agent or probe to install the release it has been offered", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 409);
.Api("nodes_update", "Ask an online agent or probe to install the release it has been offered", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403, 409)
.Requires(Capability.UpdateConfirm);
group.MapDelete("/nodes/{id}/binary", ClearBinaryAsync)
.Api("nodes_binary_clear", "Accept the node's current binary: clear its binding so the next handshake binds it again", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
.Requires(Capability.FleetWrite);
group.MapGet("/backend/update", BackendUpdateAsync)
.Api("backend_update_get", "Current channel and host-verified backend update", Tag).Returns<BackendUpdateDto>().ProducesProblems(401, 403);
.Api("backend_update_get", "Current channel and host-verified backend update", Tag).Returns<BackendUpdateDto>().ProducesProblems(401, 403)
.Requires(Capability.UpdateRead);
group.MapPost("/backend/update", ConfirmBackendUpdateAsync)
.Api("backend_update_confirm", "Confirm one verified backend release before the host restarts it", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403, 412);
.Api("backend_update_confirm", "Confirm one verified backend release before the host restarts it", Tag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403, 412)
.Requires(Capability.UpdateConfirm);
group.MapGet("/backend/update/live", LiveBackendUpdateAsync)
.Api("backend_update_live", "Server-sent backend update notifications", Tag).Produces(StatusCodes.Status200OK, contentType: "text/event-stream").ProducesProblems(401, 403);
.Api("backend_update_live", "Server-sent backend update notifications", Tag).Produces(StatusCodes.Status200OK, contentType: "text/event-stream").ProducesProblems(401, 403)
.Requires(Capability.UpdateRead);
}
/// <summary>登记发布清单:挂在 <see cref="ReleaseGuard"/> 下,除了管理员,CI 用的 <c>release</c> 令牌也能调用(它只能做这一件事)。</summary>
@@ -35,15 +44,43 @@ public static class BuildEndpoints
{
group.MapPost("/releases", PublishReleaseAsync)
.Api("releases_publish", "Register a CI-signed release manifest; online nodes get it right away (their updaters verify the signature). Admin or release tokens", Tag)
.Returns<ReleaseDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409);
.Returns<ReleaseDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
.Requires(Capability.ReleasePublish);
}
private static async Task<IResult> GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, CancellationToken ct)
/// <summary>
/// CI 登记清单的入口,<b>不需要令牌</b>:请求里的清单必须带有效的 CI 发布签名(用 Backend 内嵌的发布公钥验)才收下。
/// 这样登记不依赖面板里的任何凭证(清库、重装、还没有 passkey 的时候照常工作),也没有一把能被偷的长期令牌;
/// 旧清单重放由 <c>seq</c> 递增挡住,节点的更新器还会用本机公钥再验一次。
/// </summary>
public static void MapOffer(IEndpointRouteBuilder app)
{
app.MapPost("/api/release/offer", OfferReleaseAsync)
.Api("release_offer", "Register a release manifest that carries a valid CI signature (no token needed)", "release")
.Returns<ReleaseDto>(StatusCodes.Status201Created).ProducesProblems(400, 403, 409);
}
private static async Task<IResult> OfferReleaseAsync(HttpContext http, ReleaseService releases, PublishReleaseRequest request)
{
string manifest = request.Manifest ?? "";
string signature = request.Signature ?? "";
if (manifest.Length is 0 or > ReleaseService.MaxManifestBytes || signature.Length is 0 or > ReleaseService.MaxSignatureBytes
|| !ReleaseSignature.Verify(InstallScripts.ReleaseKey(), manifest, signature))
{
return Problems.Create(http, MoShiroErrors.Forbidden, "the manifest does not carry a valid release signature");
}
return await PublishReleaseAsync(http, releases, request);
}
private static async Task<IResult> GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, BinaryBindingService bindings, NodeOwnerService owners, CancellationToken ct)
{
IReadOnlyDictionary<string, IReadOnlyList<NodeOwner>> ownerOf = await owners.ListAsync(ct);
Dictionary<string, NodeBinding> bound = (await bindings.ListAsync(ct)).ToDictionary(b => b.NodeId, StringComparer.Ordinal);
var items = (await nodes.ListAsync(ct))
.OrderBy(n => n.Kind, StringComparer.Ordinal)
.ThenBy(n => n.NodeId, StringComparer.Ordinal)
.Select(n => new NodeBuildDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Version, n.Tag, n.Commit, n.BuiltAt, ToDto(releases.StatusOf(new NodeId(n.NodeId)))))
.Select(n => new NodeBuildDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Version, n.Tag, n.Commit, n.BuiltAt, ToDto(releases.StatusOf(new NodeId(n.NodeId))), bound.TryGetValue(n.NodeId, out NodeBinding? binding) ? ToDto(binding) : null, ownerOf.TryGetValue(n.NodeId, out IReadOnlyList<NodeOwner>? list) ? [.. list.Select(o => new NodeOwnerDto(o.CredentialId, o.PasskeyName, o.Username))] : null))
.ToList();
http.Response.Headers.CacheControl = "no-store";
return TypedResults.Json(new BuildInfoDto(NodeEndpoints.Backend(withCommit: true), items, ToDto(await backendUpdates.GetAsync(ct))), ApiJsonContext.Default.BuildInfoDto);
@@ -101,6 +138,17 @@ public static class BuildEndpoints
}
}
private static NodeBinaryDto ToDto(NodeBinding b) =>
new(b.Sha256, b.Signed, b.Seq, DateTimeOffset.FromUnixTimeMilliseconds(b.BoundAtMs), b.Violation, b.ViolationAtMs is { } at ? DateTimeOffset.FromUnixTimeMilliseconds(at) : null);
/// <summary>管理员确认新的二进制是自己要的:清除绑定,节点下一次握手重新绑定(没有绑定可清也返回 204,幂等)。</summary>
private static async Task<IResult> ClearBinaryAsync(HttpContext http, BinaryBindingService bindings, string id)
{
http.SetDetail("binary binding cleared");
await bindings.ClearAsync(id, http.RequestAborted);
return TypedResults.NoContent();
}
private static NodeUpdateDto? ToDto(UpdateStatus? status) =>
status is null ? null : new NodeUpdateDto(status.State, status.Current, status.Available?.Tag, status.Available?.Version, status.Error, status.At > 0 ? DateTimeOffset.FromUnixTimeSeconds(status.At) : null);
}
@@ -16,14 +16,18 @@ public static class DataEndpoints
public static void Map(RouteGroupBuilder group)
{
group.MapGet("/raw/chunks", ListChunksAsync)
.Api("raw_chunks_list", "Raw chunk metadata", Tag).Returns<PageDto<RawChunkMetaDto>>().ProducesProblems(400, 401, 403);
.Api("raw_chunks_list", "Raw chunk metadata", Tag).Returns<PageDto<RawChunkMetaDto>>().ProducesProblems(400, 401, 403)
.Requires(Capability.DataRead);
group.MapGet("/raw/chunks/{chunk_id}", DownloadChunkAsync)
.Api("raw_chunks_get", "Download a raw chunk (application/vnd.moshiro.rawchunk)", Tag)
.Produces(StatusCodes.Status200OK, contentType: "application/vnd.moshiro.rawchunk").ProducesProblems(401, 403, 404, 410);
.Produces(StatusCodes.Status200OK, contentType: "application/vnd.moshiro.rawchunk").ProducesProblems(401, 403, 404, 410)
.Requires(Capability.DataRead);
group.MapGet("/events", ListEventsAsync)
.Api("events_list", "Agent events", Tag).Returns<PageDto<EventDto>>().ProducesProblems(400, 401, 403);
.Api("events_list", "Agent events", Tag).Returns<PageDto<EventDto>>().ProducesProblems(400, 401, 403)
.Requires(Capability.EventsRead);
group.MapGet("/analysis/recompute", RecomputeAsync)
.Api("analysis_recompute_get", "Progress of the metric backfill", Tag).Returns<RecomputeDto>().ProducesProblems(401, 403);
.Api("analysis_recompute_get", "Progress of the metric backfill", Tag).Returns<RecomputeDto>().ProducesProblems(401, 403)
.Requires(Capability.DataWrite);
}
private static async Task<IResult> ListChunksAsync(
@@ -15,9 +15,11 @@ public static class LinkEndpoints
public static void Map(RouteGroupBuilder group)
{
group.MapGet("/links", ListAsync)
.Api("links_list", "Every probe/agent pair: whether it is linked, the manual override and whether it is connected", Tag).Returns<LinkMatrixDto>().ProducesProblems(401, 403);
.Api("links_list", "Every probe/agent pair: whether it is linked, the manual override and whether it is connected", Tag).Returns<LinkMatrixDto>().ProducesProblems(401, 403)
.Requires(Capability.FleetRead);
group.MapPut("/links/{probe_id}/{agent_id}", SetAsync)
.Api("links_set", "Force a link on or off, or give it back to the automatic setting (enabled: null)", Tag).Returns<LinkDto>().ProducesProblems(400, 401, 403, 404);
.Api("links_set", "Force a link on or off, or give it back to the automatic setting (enabled: null)", Tag).Returns<LinkDto>().ProducesProblems(400, 401, 403, 404)
.Requires(Capability.FleetWrite);
}
private static async Task<IResult> ListAsync(AgentService agents, ProbeService probes, LinkService links, CancellationToken ct)
@@ -18,33 +18,54 @@ public static class SystemEndpoints
public static void Map(RouteGroupBuilder group)
{
group.MapGet("/api-tokens", ListTokensAsync)
.Api("api_tokens_list", "API tokens", TokensTag).Returns<PageDto<ApiTokenDto>>().ProducesProblems(401, 403);
.Api("api_tokens_list", "API tokens", TokensTag).Returns<PageDto<ApiTokenDto>>().ProducesProblems(401, 403)
.Requires(Capability.KeysRead);
group.MapPost("/api-tokens", CreateTokenAsync)
.Api("api_tokens_create", "Create an API token (scope read or admin); the plaintext is returned only once", TokensTag)
.Returns<CreatedApiTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403);
.Api("api_tokens_create", "Create an API token with the chosen capabilities (a subset of your own); the plaintext is returned only once", TokensTag)
.Returns<CreatedApiTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403)
.Requires(Capability.KeysManage);
group.MapDelete("/api-tokens/{id}", RevokeTokenAsync)
.Api("api_tokens_revoke", "Revoke an API token", TokensTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
.Api("api_tokens_revoke", "Revoke an API token", TokensTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
.Requires(Capability.KeysManage);
group.MapGet("/capabilities", CapabilitiesAsync)
.Api("capabilities_list", "The capability catalog: groups, which ones change trust, which are reserved, and the presets", TokensTag)
.Returns<CapabilityCatalogDto>().ProducesProblems(401, 403)
.Requires(Capability.KeysRead);
group.MapGet("/settings", ListSettingsAsync)
.Api("settings_list", "Site settings and retention periods", SettingsTag).Returns<PageDto<SettingDto>>().ProducesProblems(401, 403);
.Api("settings_list", "Site settings and retention periods", SettingsTag).Returns<PageDto<SettingDto>>().ProducesProblems(401, 403)
.Requires(Capability.SettingsRead);
group.MapPatch("/settings", PatchSettingsAsync)
.Accepts<Dictionary<string, JsonElement>>("application/merge-patch+json", "application/json")
.Api("settings_update", "Change settings (merge-patch)", SettingsTag).Returns<PageDto<SettingDto>>().ProducesProblems(400, 401, 403);
.Api("settings_update", "Change settings (merge-patch)", SettingsTag).Returns<PageDto<SettingDto>>().ProducesProblems(400, 401, 403)
.Requires(Capability.SettingsWrite);
group.MapGet("/users", ListUsersAsync)
.Api("users_list", "Panel users", UsersTag).Returns<PageDto<UserDto>>().ProducesProblems(401, 403);
.Api("users_list", "Panel users", UsersTag).Returns<PageDto<UserDto>>().ProducesProblems(401, 403)
.Requires(Capability.AccountsRead);
group.MapPost("/users", CreateUserAsync)
.Api("users_create", "Create a panel user", UsersTag).Returns<UserDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409);
.Api("users_create", "Create a panel user", UsersTag).Returns<UserDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
.Requires(Capability.AccountsManage);
group.MapPatch("/users/{id}", ChangePasswordAsync)
.Api("users_update", "Change a user's password", UsersTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403);
.Api("users_update", "Change a user's password", UsersTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(400, 401, 403)
.Requires(Capability.AccountsManage);
}
private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<CapabilityCatalogDto> CapabilitiesAsync() =>
TypedResults.Json(
new CapabilityCatalogDto(
[.. CapabilityCatalog.Groups.Select(g => new CapabilityGroupDto(g, [.. CapabilityCatalog.All.Where(c => c.Group == g).Select(c => new CapabilityDto(c.Id, c.Trust, c.Reserved, c.Implies))]))],
[.. CapabilityCatalog.Presets.Select(p => new CapabilityPresetDto(p.Id, p.Capabilities))]),
ApiJsonContext.Default.CapabilityCatalogDto);
private static async Task<IResult> ListTokensAsync(AuthService auth, CancellationToken ct) =>
TypedResults.Json(new PageDto<ApiTokenDto>([.. (await auth.ListApiTokensAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoApiTokenDto);
private static async Task<IResult> CreateTokenAsync(HttpContext http, AuthService auth, CreateApiTokenRequest request)
{
Result<CreatedApiToken> result = await auth.CreateApiTokenAsync(request.Name ?? "", request.Scope ?? "", http.RequestAborted);
http.SetDetail($"{request.Name}: {string.Join(',', request.Capabilities ?? [])}");
Result<CreatedApiToken> result = await auth.CreateApiTokenAsync(request.Name ?? "", request.Capabilities ?? [], request.ExpiresInDays, http.GetPrincipal(), http.RequestAborted);
if (!result.TryGet(out CreatedApiToken? created, out FleetError? error))
{
return Problems.From(http, error);
@@ -63,9 +84,14 @@ public static class SystemEndpoints
private static async Task<IResult> ListSettingsAsync(SettingsService settings, CancellationToken ct) =>
TypedResults.Json(new PageDto<SettingDto>([.. (await settings.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoSettingDto);
private static async Task<IResult> PatchSettingsAsync(HttpContext http, SettingsService settings, AgentService agents, Dictionary<string, JsonElement> changes)
private static async Task<IResult> PatchSettingsAsync(HttpContext http, SettingsService settings, AgentService agents, ScheduleService schedule, Dictionary<string, JsonElement> changes)
{
Result<IReadOnlyList<SettingView>> result = await settings.PatchAsync(changes, http.RequestAborted);
if (result.IsSuccess && changes.Keys.Any(k => k.StartsWith("fleet.restart_", StringComparison.Ordinal)))
{
await schedule.SendToAllAsync(http.RequestAborted); // 定时重启设置变了:马上通知所有在线节点
}
if (result.IsSuccess && changes.ContainsKey(SettingsService.AutoLink))
{
await agents.AfterAgentSetChangedAsync(http.RequestAborted); // 自动连接开关变了:重新下发各 Probe 的 Agent 列表和各 Agent 允许的 Probe
@@ -16,26 +16,34 @@ public static class TaskEndpoints
public static void Map(RouteGroupBuilder group)
{
group.MapGet("/tasks", ListAsync)
.Api("tasks_list", "Tasks: line templates, per-agent overrides and target tasks", TasksTag).Returns<PageDto<TaskDto>>().ProducesProblems(401, 403);
.Api("tasks_list", "Tasks: line templates, per-agent overrides and target tasks", TasksTag).Returns<PageDto<TaskDto>>().ProducesProblems(401, 403)
.Requires(Capability.TasksRead);
group.MapPost("/tasks", CreateAsync)
.Api("tasks_create", "Create a task; params are validated against the kind", TasksTag)
.Returns<TaskDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 404);
.Returns<TaskDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 404)
.Requires(Capability.TasksWrite);
group.MapGet("/tasks/{id}", GetAsync)
.Api("tasks_get", "One task", TasksTag).Returns<TaskDto>().ProducesProblems(401, 403, 404);
.Api("tasks_get", "One task", TasksTag).Returns<TaskDto>().ProducesProblems(401, 403, 404)
.Requires(Capability.TasksRead);
group.MapPatch("/tasks/{id}", PatchAsync)
.Accepts<PatchTaskRequest>("application/merge-patch+json", "application/json")
.Api("tasks_update", "Change params, schedule, target or enabled (merge-patch, If-Match supported)", TasksTag)
.Returns<TaskDto>().ProducesProblems(400, 401, 403, 404, 412);
.Returns<TaskDto>().ProducesProblems(400, 401, 403, 404, 412)
.Requires(Capability.TasksWrite);
group.MapDelete("/tasks/{id}", DeleteAsync)
.Api("tasks_delete", "Delete a task", TasksTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403);
.Api("tasks_delete", "Delete a task", TasksTag).Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401, 403)
.Requires(Capability.TasksWrite);
group.MapPost("/lines/{line_id}/tests", StartTestAsync)
.Api("tests_start", "Start an on-demand test on a line", TestsTag)
.Returns<TestDto>(StatusCodes.Status202Accepted).ProducesProblems(400, 401, 403, 404, 409);
.Returns<TestDto>(StatusCodes.Status202Accepted).ProducesProblems(400, 401, 403, 404, 409)
.Requires(Capability.TasksWrite);
group.MapGet("/lines/{line_id}/tests", ListTestsAsync)
.Api("tests_list", "Recent tests of a line", TestsTag).Returns<PageDto<TestDto>>().ProducesProblems(401, 403);
.Api("tests_list", "Recent tests of a line", TestsTag).Returns<PageDto<TestDto>>().ProducesProblems(401, 403)
.Requires(Capability.TasksRead);
group.MapPost("/tests/{id}/cancel", CancelAsync)
.Api("tests_cancel", "Cancel a running test", TestsTag).Returns<TestDto>().ProducesProblems(401, 403, 404);
.Api("tests_cancel", "Cancel a running test", TestsTag).Returns<TestDto>().ProducesProblems(401, 403, 404)
.Requires(Capability.TasksWrite);
}
private static async Task<IResult> ListAsync(TaskService tasks, string? agent_id, string? scope, CancellationToken ct)
@@ -0,0 +1,72 @@
using Fleet.Core;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using MoShiro.Backend.Application;
using MoShiro.Protocol;
namespace MoShiro.Backend.Api;
/// <summary>
/// 信任根(docs/access.md 第 7 节):操作员集合由 passkey 签名,节点从安装命令里固定的创世指纹出发验证它;
/// 带操作员委托的加入码也在这里创建——委托是 passkey 对「这个码」签的,面板自己签不出来。
/// </summary>
public static class TrustEndpoints
{
private const string Tag = "admin-trust";
public static void Map(RouteGroupBuilder guarded)
{
guarded.MapGet("/trust", StateAsync)
.Api("trust_get", "The operator trust root: genesis fingerprint, version, whether it matches the registered passkeys, and the next version to sign", Tag)
.Returns<TrustStateDto>().ProducesProblems(401, 403)
.Requires(Capability.FleetRead);
guarded.MapPost("/trust", CommitAsync)
.Api("trust_commit", "Store the next operator set, signed with a passkey (the first one sets the genesis)", Tag)
.Returns<TrustStateDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
.Requires(Capability.AccountsManage);
}
private static async Task<IResult> StateAsync(HttpContext http, TrustService trust, ReleaseService releases, BackendOptions options)
{
TrustState state = await trust.StateAsync(AuthEndpoints.PasskeyContextOf(http, options), http.RequestAborted);
http.Response.Headers.CacheControl = "no-store";
string sh = InstallScripts.Sha256(InstallScripts.Sh);
string ps1 = InstallScripts.Sha256(InstallScripts.Ps1);
return TypedResults.Json(Mappers.ToDto(state, options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}", sh, ps1, await MatchesReleaseAsync(releases, sh, ps1, http.RequestAborted)), ApiJsonContext.Default.TrustStateDto);
}
/// <summary>面板提供的脚本和 CI 签名的发布清单里记的哈希是否一致(清单由 CI 签名,节点和浏览器之外的人改不了)。没有清单或没记哈希时返回 null。</summary>
private static async Task<bool?> MatchesReleaseAsync(ReleaseService releases, string sh, string ps1, CancellationToken ct)
{
foreach (string component in new[] { JoinTokenService.AgentRole, JoinTokenService.ProbeRole })
{
if (await releases.GetAsync(component, ct) is not { } offer)
{
continue;
}
using System.Text.Json.JsonDocument document = System.Text.Json.JsonDocument.Parse(offer.Manifest);
if (document.RootElement.TryGetProperty("install_sh", out System.Text.Json.JsonElement recordedSh) && document.RootElement.TryGetProperty("install_ps1", out System.Text.Json.JsonElement recordedPs1))
{
return string.Equals(recordedSh.GetString(), sh, StringComparison.Ordinal) && string.Equals(recordedPs1.GetString(), ps1, StringComparison.Ordinal);
}
}
return null;
}
private static async Task<IResult> CommitAsync(HttpContext http, SignedStatementDto signed, TrustService trust, AgentService agents, BackendOptions options)
{
PasskeyContext context = AuthEndpoints.PasskeyContextOf(http, options);
Result<TrustState> result = await trust.CommitAsync(signed, context, http.RequestAborted);
if (!result.TryGet(out TrustState? state, out FleetError? error))
{
return Problems.From(http, error);
}
await agents.AfterAgentSetChangedAsync(http.RequestAborted); // 新的操作员集合要随名册下发给节点
http.SetDetail($"operator set v{state.Version}");
return TypedResults.Json(Mappers.ToDto(state), ApiJsonContext.Default.TrustStateDto, statusCode: StatusCodes.Status201Created);
}
}
+8 -2
View File
@@ -18,6 +18,8 @@ public static class ApiModule
public static IServiceCollection AddMoShiroApi(this IServiceCollection services)
{
services.AddSingleton<SseLimiter>();
services.AddSingleton<MoShiro.Backend.Application.IReleaseKey, EmbeddedReleaseKey>();
services.AddSingleton<ClientAddressResolver>();
services.Configure<JsonOptions>(options =>
{
JsonSerializerOptions json = options.SerializerOptions;
@@ -103,19 +105,23 @@ public static class ApiModule
RouteGroupBuilder admin = app.MapGroup("/api/admin");
AuthEndpoints.MapLogin(admin);
BuildEndpoints.MapPublish(admin.MapGroup(string.Empty).AddEndpointFilter(ReleaseGuard.InvokeAsync));
BuildEndpoints.MapPublish(admin.MapGroup(string.Empty).AddEndpointFilter(CapabilityGuard.InvokeAsync));
RouteGroupBuilder guarded = admin.MapGroup(string.Empty).AddEndpointFilter(AdminGuard.InvokeAsync);
RouteGroupBuilder guarded = admin.MapGroup(string.Empty).AddEndpointFilter(CapabilityGuard.InvokeAsync);
AuthEndpoints.MapSession(guarded);
AgentEndpoints.Map(guarded);
TaskEndpoints.Map(guarded);
DataEndpoints.Map(guarded);
AuditEndpoints.Map(guarded);
TrustEndpoints.Map(guarded);
SystemEndpoints.Map(guarded);
BuildEndpoints.Map(guarded);
LinkEndpoints.Map(guarded);
OpsEndpoints.Map(app);
BuildEndpoints.MapOffer(app);
app.MapOpenApi("/openapi/v1.json");
app.MapStaticSite();
return app;
}
@@ -0,0 +1,80 @@
using System.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using MoShiro.Backend.Application;
namespace MoShiro.Backend.Api;
/// <summary>
/// 请求的真实来源地址。后端前面通常有反向代理(Cloudflare、frp、Caddy),套接字上看到的是最近一跳;
/// 只有配置了 <see cref="BackendOptions.TrustedProxies"/>,并且连进来的正是其中之一,才相信转发头,
/// 且从右往左跳过所有可信代理,取第一个不可信的地址(客户端自己伪造的在最左边,不会被采信)。
/// 没配置就一律用套接字地址:宁可限流和审计里是代理的地址,也不让客户端自己报地址。
/// </summary>
public sealed class ClientAddressResolver
{
private readonly IPNetwork[] _trusted;
private readonly string _header;
public ClientAddressResolver(BackendOptions options)
{
_header = string.IsNullOrWhiteSpace(options.TrustedProxyHeader) ? "X-Forwarded-For" : options.TrustedProxyHeader.Trim();
_trusted = [.. (options.TrustedProxies ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(ParseNetwork)];
}
private static IPNetwork ParseNetwork(string text)
{
if (IPNetwork.TryParse(text, out IPNetwork network))
{
return network;
}
if (IPAddress.TryParse(text, out IPAddress? single))
{
return new IPNetwork(single, single.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork ? 32 : 128);
}
throw new InvalidOperationException($"TrustedProxies: '{text}' is not an IP address or CIDR range");
}
private bool IsTrusted(IPAddress address)
{
IPAddress normalized = address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address;
return _trusted.Any(n => n.Contains(normalized));
}
public string Resolve(HttpContext http)
{
IPAddress? remote = http.Connection.RemoteIpAddress;
if (remote is null)
{
return "unknown";
}
if (_trusted.Length == 0 || !IsTrusted(remote))
{
return remote.ToString();
}
string[] chain = [.. http.Request.Headers[_header].SelectMany(v => (v ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))];
for (int i = chain.Length - 1; i >= 0; i--)
{
if (!IPAddress.TryParse(chain[i], out IPAddress? hop))
{
break; // 不是地址就不信这条头
}
if (!IsTrusted(hop))
{
return hop.ToString();
}
}
return remote.ToString();
}
}
public static class ClientAddressExtensions
{
public static string ClientAddress(this HttpContext http) => http.RequestServices.GetRequiredService<ClientAddressResolver>().Resolve(http);
}
@@ -9,6 +9,19 @@ public static class EndpointConventions
public static RouteHandlerBuilder Api(this RouteHandlerBuilder builder, string operationId, string summary, string tag) =>
builder.WithName(operationId).WithSummary(summary).WithTags(tag);
/// <summary>声明端点需要的能力(授权的唯一依据);OpenAPI 里带上 <c>x-moshiro-capability</c>。</summary>
public static RouteHandlerBuilder Requires(this RouteHandlerBuilder builder, string capability) =>
builder.WithMetadata(new CapabilityRequirement(capability)).AddOpenApiOperationTransformer((operation, _, _) =>
{
operation.Extensions ??= new Dictionary<string, Microsoft.OpenApi.IOpenApiExtension>();
operation.Extensions["x-moshiro-capability"] = new Microsoft.OpenApi.JsonNodeExtension(System.Text.Json.Nodes.JsonValue.Create(capability));
return Task.CompletedTask;
});
/// <summary>匿名可访问的管理端点(登录):显式声明,对账测试才能区分「忘了声明」。</summary>
public static RouteHandlerBuilder AllowsAnonymous(this RouteHandlerBuilder builder) =>
builder.WithMetadata(new CapabilityRequirement(null));
public static RouteHandlerBuilder Returns<T>(this RouteHandlerBuilder builder, int status = StatusCodes.Status200OK) =>
builder.Produces<T>(status);
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using MoShiro.Backend.Application;
using MoShiro.Protocol;
@@ -53,17 +54,29 @@ public static class PrincipalResolver
}
}
/// <summary>管理层守卫:需要 admin 作用域;Cookie 会话的非 GET 请求必须带 <c>X-CSRF-Token</c>。</summary>
public static class AdminGuard
/// <summary>
/// 端点声明自己需要的能力(<see cref="EndpointConventions.Requires"/>)。<c>Capability == null</c> 表示匿名可访问(登录)。
/// 没有声明的 <c>/api/admin</c> 端点一律拒绝(并有对账测试保证不存在)。
/// </summary>
public sealed record CapabilityRequirement(string? Capability);
/// <summary>管理层守卫:按端点声明的能力授权;Cookie 会话的非 GET 请求必须带 <c>X-CSRF-Token</c>。</summary>
public static class CapabilityGuard
{
public static async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
HttpContext http = context.HttpContext;
Principal principal = http.GetPrincipal();
if (!principal.IsAdmin)
CapabilityRequirement? requirement = http.GetEndpoint()?.Metadata.GetMetadata<CapabilityRequirement>();
if (requirement is null)
{
return principal.Scope != ApiScope.None
? Problems.Create(http, MoShiroErrors.Forbidden, "the token scope does not allow this operation")
return Problems.Create(http, MoShiroErrors.Forbidden, "this endpoint has no capability declaration");
}
Principal principal = http.GetPrincipal();
if (requirement.Capability is { } capability && !principal.Has(capability))
{
return principal.Kind != PrincipalKind.Anonymous
? Problems.Create(http, MoShiroErrors.Forbidden, "the credential lacks the capability: " + capability)
: Problems.Create(http, MoShiroErrors.Unauthorized, "authentication is required");
}
@@ -76,24 +89,35 @@ public static class AdminGuard
}
}
if (principal.Kind != PrincipalKind.Anonymous && !HttpMethods.IsGet(http.Request.Method) && !HttpMethods.IsHead(http.Request.Method) && !HttpMethods.IsOptions(http.Request.Method))
{
// 写操作都记审计:响应发完之后才知道结果,所以放在 OnCompleted 里
string action = http.GetEndpoint()?.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName ?? http.Request.Path.Value ?? "";
if (action.EndsWith("_options", StringComparison.Ordinal))
{
return await next(context); // 取挑战不是操作
}
string? target = http.Request.RouteValues.Count == 0 ? null : string.Join(' ', http.Request.RouteValues.Where(v => v.Value is not null).Select(v => v.Key + "=" + v.Value));
http.Response.OnCompleted(() =>
{
http.Items.TryGetValue(AuditEndpoints.DetailItem, out object? detail);
return http.RequestServices.GetRequiredService<AuditService>()
.RecordAsync(principal, action, target, detail as string, http.ClientAddress(), http.Response.StatusCode < 400).AsTask();
});
}
return await next(context);
}
}
/// <summary>登记发布清单的守卫:管理员(会话要带 CSRF)或 release 令牌;release 令牌只能做这一件事。</summary>
public static class ReleaseGuard
{
public static async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) =>
context.HttpContext.GetPrincipal().Scope == ApiScope.Release ? await next(context) : await AdminGuard.InvokeAsync(context, next);
}
/// <summary>展示层守卫:站点公开时匿名可读;私有时需要登录或只读令牌。</summary>
public static class PublicGuard
{
public static async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
HttpContext http = context.HttpContext;
if (!http.GetPrincipal().CanRead && !await http.RequestServices.GetRequiredService<SettingsService>().IsSitePublicAsync(http.RequestAborted))
if (!http.GetPrincipal().Has(Capability.PublicRead) && !await http.RequestServices.GetRequiredService<SettingsService>().IsSitePublicAsync(http.RequestAborted))
{
return Problems.Create(http, MoShiroErrors.SitePrivate, "this site is private; sign in or use a read-only token");
}
@@ -102,5 +126,5 @@ public static class PublicGuard
}
/// <summary>被隐藏(public_visible = false)的线路只对已认证的访问者可见。</summary>
public static bool CanSeeHidden(HttpContext http) => http.GetPrincipal().CanRead;
public static bool CanSeeHidden(HttpContext http) => http.GetPrincipal().Has(Capability.PublicRead);
}
@@ -41,7 +41,7 @@ public static class Mappers
public static ProbeDto ToDto(ProbeView v) =>
new(
v.Probe.Id, v.Probe.Name, Time(v.Probe.CreatedAtMs), Time(v.Probe.RevokedAtMs), v.Revoked, v.Online,
[.. v.Connections.Select(c => new ProbeConnectionDto(c.AgentId.Value, c.AgentName, c.AgentOnline, c.Version, c.Remote, Time(c.SinceMs), c.IpVersion))], ProbeIpv6Of(v.Probe));
[.. v.Connections.Select(c => new ProbeConnectionDto(c.AgentId.Value, c.AgentName, c.AgentOnline, c.Version, c.Remote, Time(c.SinceMs), c.IpVersion))], ProbeIpv6Of(v.Probe), v.Probe.AddedBy);
public static SeriesDto ToDto(SeriesResult r, bool live) =>
new(
@@ -103,7 +103,7 @@ public static class Mappers
: null;
return new AgentDto(
a.Id.Value, a.Name, StatusName(a.Status), view.Online, view.ProbeConnected, [.. view.Probes.Select(ToDto)], view.Host is null ? null : ToDto(view.Host), a.PublicHost, a.ProbeTcpPort, a.ProbeUdpPorts, a.Labels, a.PublicVisible,
a.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, Time(a.CreatedAtMs), Time(a.LastSeenAtMs), a.Ipv6Host, a.Ipv6Supported, Ipv6Of(view));
a.Kinds, a.AgentVersion, limits, a.TaskVersion, a.AppliedTaskVersion, rejected, heartbeat, view.EndpointChanges, Time(a.CreatedAtMs), Time(a.LastSeenAtMs), a.Ipv6Host, a.Ipv6Supported, Ipv6Of(view), !a.PublicHostManual, !a.Ipv6HostManual, a.AddedBy);
}
public static Ipv6DiagnosticDto Ipv6Of(AgentView view)
@@ -154,9 +154,16 @@ public static class Mappers
new(t.Id.Value, t.Scope, t.AgentId?.Value, t.BaseTaskId?.Value, t.Kind, t.Target, t.Params, t.Schedule, t.Enabled, Time(t.UpdatedAtMs));
public static JoinTokenDto ToDto(JoinTokenInfo t) =>
new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs));
new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind), t.CreatedBy);
public static ApiTokenDto ToDto(ApiTokenRecord t) => new(t.Id, t.Name, t.Scope, Time(t.CreatedAtMs), Time(t.LastUsedAtMs), Time(t.RevokedAtMs));
public static TrustStateDto ToDto(TrustState s, string installUrl = "", string installSh = "", string installPs1 = "", bool? matches = null) => new(s.Initialized, s.Genesis, s.Version, s.InSync, s.Proposal is null ? null : new TrustProposalDto(s.Proposal.Statement, s.Proposal.Version, s.Proposal.Challenge, [.. s.Proposal.Added.Select(a => new ProposedCredentialDto(a.Id, a.Name, Time(a.CreatedAtMs)))], s.Proposal.Removed, s.Proposal.ServerKeyChanged), installUrl, s.Operators ?? [], installSh, installPs1, matches, s.ServerKey, s.ServerKeyListed, [.. (s.OperatorDetails ?? []).Select(o => new NodeOwnerDto(o.CredentialId, o.PasskeyName, o.Username))]);
public static AuditDto ToDto(AuditRecord a) => new(a.Id, Time(a.AtMs), a.Actor, a.Method, a.Action, a.Target, a.Detail, a.Remote, a.Success);
public static PasskeyDto ToDto(PasskeyRecord p) => new(p.Id, p.Name, Time(p.CreatedAtMs), Time(p.LastUsedAtMs), p.Transports);
public static ApiTokenDto ToDto(ApiTokenRecord t) => new(t.Id, t.Name, t.Capabilities, Time(t.CreatedAtMs), Time(t.LastUsedAtMs), Time(t.ExpiresAtMs), Time(t.RevokedAtMs));
public static RawChunkMetaDto ToDto(RawChunkMeta m) =>
new(m.ChunkId.ToString(), m.AgentId.Value, m.Series, m.Kind, m.TaskId?.Value, m.TestId?.Value, m.Phase, Time(m.StartTs), m.DurationMs, m.Partial, m.Length);
@@ -1,3 +1,4 @@
using Fleet.Core;
using System.Text.Json;
namespace MoShiro.Backend.Api;
@@ -5,9 +6,24 @@ namespace MoShiro.Backend.Api;
/// <summary><c>return_session</c>:跨域的前端用——不设 Cookie,把会话令牌放在响应里,之后用 <c>Authorization: Bearer</c> 带上。</summary>
public sealed record LoginRequest(string Username, string Password, bool? ReturnSession);
/// <summary>浏览器 <c>navigator.credentials.create</c> 要的参数;二进制字段都是 base64url。</summary>
public sealed record PasskeyCreationOptionsDto(string Challenge, string RpId, string RpName, string UserId, string UserName, IReadOnlyList<string> ExcludeCredentials, int TimeoutMs);
/// <summary>浏览器 <c>navigator.credentials.get</c> 要的参数(可发现凭证:不需要先输用户名)。</summary>
/// <param name="AllowCredentials">列出时限定可选的 passkey(凭证 id);登录的挑战不带。</param>
public sealed record PasskeyRequestOptionsDto(string Challenge, string RpId, int TimeoutMs, IReadOnlyList<string>? AllowCredentials = null);
/// <param name="Class"><c>enroll</c>(加节点)、<c>revoke</c>(吊销和删除)、<c>credential</c>(改凭证,用一次就没有)。</param>
public sealed record RegisterPasskeyRequest(string Name, string CredentialId, string AttestationObject, string ClientDataJson, IReadOnlyList<string>? Transports);
public sealed record PasskeyLoginRequest(string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature, bool? ReturnSession);
public sealed record PasskeyDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? LastUsedAt, IReadOnlyList<string> Transports);
public sealed record LoginResponse(string UserId, string Username, string CsrfToken, DateTimeOffset ExpiresAt, string? SessionToken);
public sealed record MeDto(string Kind, string Scope, string? UserId, string? Username, string? CsrfToken);
/// <param name="AuthMethod">会话是怎么登录的:<c>password</c> 或 <c>passkey</c>(能力相同,信任类操作都要提权窗口);API 令牌为空。</param>
public sealed record MeDto(string Kind, IReadOnlyList<string> Capabilities, string? UserId, string? Username, string? CsrfToken, string? AuthMethod = null);
public sealed record LocalLimitsDto(double MaxTotalMbps, long MaxTotalPps, IReadOnlyList<string> AllowedTargetCidrs, int MaxConcurrentTests);
@@ -40,21 +56,50 @@ public sealed record AgentDto(
DateTimeOffset? LastSeenAt,
string Ipv6Host = "",
bool Ipv6Supported = false,
Ipv6DiagnosticDto? Ipv6 = null);
Ipv6DiagnosticDto? Ipv6 = null,
bool PublicHostAuto = true,
bool Ipv6HostAuto = true,
string? AddedBy = null);
public sealed record Ipv6DiagnosticDto(bool? AddressConfigured, bool SocketSupported, bool? Listening, bool? HasGlobalAddress, bool? HasDefaultRoute, string State,
string NetworkScope = "unknown", bool? HostHasGlobalAddress = null, bool? HostHasDefaultRoute = null);
/// <summary>merge-patch:缺省或为 null 的字段不修改。</summary>
public sealed record AgentPatchRequest(string? Name, IReadOnlyList<string>? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null);
/// <param name="PublicHostAuto">true:撤销手填,回到自动识别(<c>ipv6_host_auto</c> 同理)。手填 <c>public_host</c> 或 <c>ipv6_host</c> 就是手动,自动识别不再覆盖。</param>
public sealed record AgentPatchRequest(string? Name, IReadOnlyList<string>? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null, bool? PublicHostAuto = null, bool? Ipv6HostAuto = null);
public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string> Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt);
/// <param name="Role"><c>agent</c> 或 <c>probe</c>:用这个令牌注册出来的是哪种节点。</param>
public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string> Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string Role = "agent", string? CreatedBy = null);
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours);
/// <param name="Controllers">属主:允许授权这个节点 extension 会话的 passkey 凭证 id(必须是操作员集合里的);缺省是集合里的全部。没有建立信任根时忽略。</param>
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null, IReadOnlyList<string>? Controllers = null);
/// <summary>令牌明文只在创建的响应里出现一次。</summary>
/// <summary>面板是不是还没有任何用户(需要首次初始化)。</summary>
public sealed record SetupStatusDto(bool NeedsSetup);
/// <param name="ClaimCode">首次启动时写在后端日志里的认领码(或者部署时指定的 <c>MoShiro__ClaimCode</c>)。</param>
public sealed record SetupRequest(string? ClaimCode, string? Username, string? Password, bool? ReturnSession);
public sealed record InstallCommandsDto(string Linux, string Macos, string Windows);
/// <summary>下一版里新增的 passkey。</summary>
public sealed record ProposedCredentialDto(string Id, string Name, DateTimeOffset CreatedAt);
/// <param name="Added">相对当前生效的集合新增的 passkey;签名之前必须看一眼是不是自己加的。</param>
/// <param name="Removed">相对当前生效的集合移除的 passkey 凭证 id。</param>
/// <param name="ServerKeyChanged">服务器密钥有变化(换了新的)。</param>
public sealed record TrustProposalDto(string Statement, int Version, string Challenge, IReadOnlyList<ProposedCredentialDto> Added, IReadOnlyList<string> Removed, bool ServerKeyChanged);
/// <param name="Genesis">创世版本的指纹:安装命令里固定到节点上的就是它。还没建立信任根时为空。</param>
/// <param name="InSync">当前生效的集合和已注册的 passkey、域名一致;不一致(比如新加了 passkey)就要签一个新版本。</param>
/// <param name="Proposal">下一个要签的版本(<c>challenge</c> 就是 WebAuthn 的挑战)。</param>
/// <param name="Operators">当前操作员集合里的 passkey 凭证 id:加入码的 <c>controllers</c> 只能从这里选。</param>
/// <param name="OperatorDetails">每个操作员是哪个账户的哪把 passkey(对不上已注册的 passkey 时名称和账户为空)。</param>
/// <param name="InstallShSha256">面板提供的安装脚本的 SHA-256(命令里核对的就是它)。</param>
/// <param name="InstallMatchesRelease">和 CI 签名的发布清单里记的哈希是否一致;没有清单或清单里没记时为空。不一致说明这个面板提供的脚本不是 CI 构建出来的那份。</param>
public sealed record TrustStateDto(bool Initialized, string? Genesis, int Version, bool InSync, TrustProposalDto? Proposal, string InstallUrl = "", IReadOnlyList<string>? Operators = null, string InstallShSha256 = "", string InstallPs1Sha256 = "", bool? InstallMatchesRelease = null, string ServerKey = "", bool ServerKeyListed = false, IReadOnlyList<NodeOwnerDto>? OperatorDetails = null);
public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands);
/// <summary>Agent 眼中连着的一个 Probe(Agent 上报)。</summary>
@@ -63,7 +108,7 @@ public sealed record ConnectedProbeDto(string ProbeId, string? Version, string?
/// <summary>某个 Probe 现在连着的一个 Agent。</summary>
public sealed record ProbeConnectionDto(string AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4");
public sealed record ProbeDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? RevokedAt, bool Revoked, bool Online, IReadOnlyList<ProbeConnectionDto> Connections, Ipv6DiagnosticDto? Ipv6 = null);
public sealed record ProbeDto(string Id, string Name, DateTimeOffset CreatedAt, DateTimeOffset? RevokedAt, bool Revoked, bool Online, IReadOnlyList<ProbeConnectionDto> Connections, Ipv6DiagnosticDto? Ipv6 = null, string? AddedBy = null);
/// <summary>一对(Probe,Agent)的线路开关:<c>linked</c> 是最终结果,<c>override</c> 是手动设置(<c>null</c> = 跟随自动连接),<c>connected</c> 是现在有没有连着。</summary>
public sealed record LinkDto(string ProbeId, string ProbeName, string AgentId, string AgentName, bool Linked, bool? Override, bool Connected);
@@ -72,13 +117,6 @@ public sealed record LinkMatrixDto(bool AutoLink, IReadOnlyList<LinkDto> Items);
public sealed record SetLinkRequest(bool? Enabled);
public sealed record ProbeLimitsDto(double MaxTestMbps, long MaxTotalPps);
/// <summary><c>probe.json</c>:含私钥,只在生成时返回一次。</summary>
public sealed record ProbeIdentityDto(string ProbeId, string PrivateKey, string Server, ProbeLimitsDto Limits);
public sealed record CreateProbeRequest(string? Name);
public sealed record PatchProbeRequest(string? Name);
public sealed record TaskDto(
@@ -120,9 +158,19 @@ public sealed record RawChunkMetaDto(
bool Partial,
int Bytes);
public sealed record ApiTokenDto(string Id, string Name, string Scope, DateTimeOffset CreatedAt, DateTimeOffset? LastUsedAt, DateTimeOffset? RevokedAt);
public sealed record ApiTokenDto(string Id, string Name, IReadOnlyList<string> Capabilities, DateTimeOffset CreatedAt, DateTimeOffset? LastUsedAt, DateTimeOffset? ExpiresAt, DateTimeOffset? RevokedAt);
public sealed record CreateApiTokenRequest(string Name, string Scope);
/// <param name="Capabilities">能力列表(见 /api/admin/capabilities);写蕴含读,只能是创建者自己拥有能力的子集。</param>
/// <param name="ExpiresInDays">有效天数;缺省不过期。</param>
public sealed record CreateApiTokenRequest(string Name, IReadOnlyList<string> Capabilities, int? ExpiresInDays = null);
public sealed record CapabilityDto(string Id, bool Trust, bool Reserved, IReadOnlyList<string> Implies);
public sealed record CapabilityGroupDto(string Id, IReadOnlyList<CapabilityDto> Capabilities);
public sealed record CapabilityPresetDto(string Id, IReadOnlyList<string> Capabilities);
public sealed record CapabilityCatalogDto(IReadOnlyList<CapabilityGroupDto> Groups, IReadOnlyList<CapabilityPresetDto> Presets);
public sealed record CreatedApiTokenDto(ApiTokenDto Token, string Plaintext);
@@ -137,7 +185,13 @@ public sealed record ChangePasswordRequest(string Password);
public sealed record RecomputeDto(string State, IReadOnlyList<string> Metrics, long WindowsDone, long WindowsTotal, DateTimeOffset? From, DateTimeOffset? To, int QueueLength);
/// <summary>管理端看的构建信息:每个节点自报的版本、tag、提交和构建时间,以及系统。</summary>
public sealed record NodeBuildDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Version, string? Tag, string? Commit, string? BuiltAt, NodeUpdateDto? Update = null);
/// <summary>节点二进制的绑定:第一次握手成功时绑定的 SHA-256。<c>Signed</c> 表示它出现在一份 CI 签名的清单里(否则只是首次握手时记下的);<c>Violation</c> 是最近一次不符的原因,下一次握手符合了就清掉。</summary>
public sealed record NodeBinaryDto(string Sha256, bool Signed, long Seq, DateTimeOffset BoundAt, string? Violation, DateTimeOffset? ViolationAt);
/// <summary>节点的一个属主:节点报上来的(安装时固定在节点本地的)passkey 凭证 id,以及它在面板上对应的 passkey 名称和账户(对不上任何已注册的 passkey 时为空)。</summary>
public sealed record NodeOwnerDto(string CredentialId, string? Passkey, string? Account);
public sealed record NodeBuildDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Version, string? Tag, string? Commit, string? BuiltAt, NodeUpdateDto? Update = null, NodeBinaryDto? Binary = null, IReadOnlyList<NodeOwnerDto>? Owners = null);
/// <summary>节点宿主机上的更新器报告的状态(<c>up_to_date</c>、<c>available</c>、<c>applying</c>、<c>failed</c>、<c>rolled_back</c>、<c>invalid</c>);没装更新器的节点没有这个字段。</summary>
public sealed record NodeUpdateDto(string State, string? CurrentDigest, string? AvailableTag, string? AvailableVersion, string? Error, DateTimeOffset? At);
Loaded 100 of 243 files, more files were not shown because too many files have changed in this diff. Show more