Compare commits
21
Commits
0012df7b91
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2f88a344b | ||
|
|
9bb6841dce | ||
|
|
fc2bbdcc6d | ||
|
|
321cacac32 | ||
|
|
36f9816f40 | ||
|
|
310b2b5b19 | ||
|
|
a19e6548d3 | ||
|
|
ff45ad086e | ||
|
|
1379b8c7cb | ||
|
|
359313a57b | ||
|
|
6385c1b9a3 | ||
|
|
ddb6608e32 | ||
|
|
55b80f0a97 | ||
|
|
af2962fc58 | ||
|
|
5528753d45 | ||
|
|
b99b95c738 | ||
|
|
f03988d68b | ||
|
|
9966ea5d63 | ||
|
|
b4b50c636f | ||
|
|
a9f562441f | ||
|
|
1ff847e45a |
No files matched your search
@@ -3,3 +3,7 @@ ci/index-backend-image.sh text eol=lf
|
|||||||
ci/publish-release-offer.sh text eol=lf
|
ci/publish-release-offer.sh text eol=lf
|
||||||
ci/release-linux.sh text eol=lf
|
ci/release-linux.sh text eol=lf
|
||||||
packaging/update/*.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
|
||||||
@@ -20,7 +20,7 @@ MoShiro 是一个自托管的**网络线路质量观测工具**。它在若干 P
|
|||||||
## 信任关系
|
## 信任关系
|
||||||
|
|
||||||
```text
|
```text
|
||||||
管理员 ── 账号密码(Cookie 会话 + CSRF)或 API 令牌(read · admin · release)──▶ Backend
|
管理员 ── 账号密码(Cookie 会话 + CSRF)或 API 令牌(按能力授权)──▶ Backend
|
||||||
访客 ── 匿名只读,站点设为公开时才可用(没有 IP、城市、ASN)──────────────▶ Backend 公开接口
|
访客 ── 匿名只读,站点设为公开时才可用(没有 IP、城市、ASN)──────────────▶ Backend 公开接口
|
||||||
|
|
||||||
节点 ── 加入令牌一次性注册,之后用自己的 ECDSA P-256 密钥签名认证 ───────▶ Backend(只存公钥)
|
节点 ── 加入令牌一次性注册,之后用自己的 ECDSA P-256 密钥签名认证 ───────▶ Backend(只存公钥)
|
||||||
@@ -97,6 +97,8 @@ Backend、Agent、Probe 以 OCI 镜像发布(Linux amd64、arm64),Agent
|
|||||||
| 文档 | 内容 |
|
| 文档 | 内容 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [`docs/architecture.md`](docs/architecture.md) | 架构、数据链路、安全边界、设计决策 |
|
| [`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/protocol.md`](docs/protocol.md)、[`backend/fleet/README.md`](backend/fleet/README.md) | 线协议、Fleet 节点框架(身份、注册、连接) |
|
||||||
| [`docs/enrollment.md`](docs/enrollment.md) | 注册、身份、Probe 与 Agent 的接入 |
|
| [`docs/enrollment.md`](docs/enrollment.md) | 注册、身份、Probe 与 Agent 的接入 |
|
||||||
| [`docs/measurement.md`](docs/measurement.md)、[`docs/analysis.md`](docs/analysis.md) | 采集引擎与任务模型、分析层与指标 |
|
| [`docs/measurement.md`](docs/measurement.md)、[`docs/analysis.md`](docs/analysis.md) | 采集引擎与任务模型、分析层与指标 |
|
||||||
|
|||||||
@@ -12,12 +12,12 @@
|
|||||||
## 任务与面板
|
## 任务与面板
|
||||||
|
|
||||||
- 单个 Agent 的任务(目前是线路模板加按 Agent 的覆盖,没有只属于某一个 Agent 的独立任务)
|
- 单个 Agent 的任务(目前是线路模板加按 Agent 的覆盖,没有只属于某一个 Agent 的独立任务)
|
||||||
- 原生 webhook 投递;告警(基于 webhook)
|
- 原生 webhook 投递;告警(基于 webhook)。实现之前先定约束(只允许 HTTPS、禁止私网和回环、不跟随重定向、限制超时和响应大小、解析后再检查地址),见 [`docs/security.md`](docs/security.md) 第 9.2 节
|
||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|
||||||
- 后端自动下载前端资源:用 Podman、Docker 部署时,默认拉取最新的前端资源(现在前端要自己构建,镜像里没有)。
|
- 后端自动下载前端资源:用 Podman、Docker 部署时,默认拉取最新的前端资源(现在前端要自己构建,镜像里没有)。
|
||||||
|
[security.md](docs/security.md)
|
||||||
## 访客页面
|
## 访客页面
|
||||||
|
|
||||||
- 人机验证:访客页面要通过验证才能拉数据,前端访问时静默触发。候选 tiago2/cap 和 Cloudflare Turnstile。
|
- 人机验证:访客页面要通过验证才能拉数据,前端访问时静默触发。候选 tiago2/cap 和 Cloudflare Turnstile。
|
||||||
@@ -38,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) 的项目边界。
|
- **extension**(Web SSH、TTY、更新遥测,敏感 operator):设计时必须保住「节点是纯采集器」这条边界,见 [`README.md`](README.md) 的项目边界。
|
||||||
- 对 UDP 包、控制帧、RawChunk、JSON-RPC 做模糊测试;伪造签名和重放的专项测试。
|
- 对 UDP 包、控制帧、RawChunk、JSON-RPC 做模糊测试;伪造签名和重放的专项测试。
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<UseArtifactsOutput>true</UseArtifactsOutput>
|
<UseArtifactsOutput>true</UseArtifactsOutput>
|
||||||
<ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath>
|
<ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath>
|
||||||
<Deterministic>true</Deterministic>
|
<Deterministic>true</Deterministic>
|
||||||
<Version>0.1.1</Version>
|
<Version>0.2.0</Version>
|
||||||
<NuGetAudit>false</NuGetAudit>
|
<NuGetAudit>false</NuGetAudit>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Fleet.Identity;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Fleet.Core;
|
using Fleet.Core;
|
||||||
@@ -16,7 +17,10 @@ public sealed class AgentApp : IDisposable
|
|||||||
|
|
||||||
private readonly AgentConfig _config;
|
private readonly AgentConfig _config;
|
||||||
private readonly bool _ipv6Supported;
|
private readonly bool _ipv6Supported;
|
||||||
private readonly RestartController? _restart;
|
private readonly KnockGate _knock;
|
||||||
|
private readonly TrustAnchor _trust;
|
||||||
|
private readonly RestartController _restart;
|
||||||
|
private readonly RestartController? _ownedRestart;
|
||||||
private readonly LoadedNodeIdentity _identity;
|
private readonly LoadedNodeIdentity _identity;
|
||||||
private readonly TimeProvider _time;
|
private readonly TimeProvider _time;
|
||||||
private readonly INodeLog _log;
|
private readonly INodeLog _log;
|
||||||
@@ -40,7 +44,8 @@ public sealed class AgentApp : IDisposable
|
|||||||
/// <param name="dataDirectory">配置目录(容器里的 <c>/etc/moshiro</c>):宿主机更新器和节点在这里交换清单、请求和状态。</param>
|
/// <param name="dataDirectory">配置目录(容器里的 <c>/etc/moshiro</c>):宿主机更新器和节点在这里交换清单、请求和状态。</param>
|
||||||
public AgentApp(AgentConfig config, LoadedNodeIdentity identity, string dataDirectory, TimeProvider time, INodeLog log, RestartController? restart = null)
|
public AgentApp(AgentConfig config, LoadedNodeIdentity identity, string dataDirectory, TimeProvider time, INodeLog log, RestartController? restart = null)
|
||||||
{
|
{
|
||||||
_restart = restart;
|
_ownedRestart = restart is null ? new RestartController() : null;
|
||||||
|
_restart = restart ?? _ownedRestart!;
|
||||||
_update = new UpdateBridge(dataDirectory, new ConfigStore(dataDirectory).Write, log);
|
_update = new UpdateBridge(dataDirectory, new ConfigStore(dataDirectory).Write, log);
|
||||||
_config = config;
|
_config = config;
|
||||||
_identity = identity;
|
_identity = identity;
|
||||||
@@ -58,7 +63,10 @@ public sealed class AgentApp : IDisposable
|
|||||||
_ipv6Supported = bind.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6;
|
_ipv6Supported = bind.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6;
|
||||||
_admission = new AgentAdmission(_streams);
|
_admission = new AgentAdmission(_streams);
|
||||||
_udp = new UdpMux(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.UdpPorts);
|
_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 监听)
|
// 监听方积木的环境:和 Probe 的拨号方共用同一套积木,区别只在这里装配的部分(准入策略、TCP 监听)
|
||||||
var env = new BlockEnv
|
var env = new BlockEnv
|
||||||
@@ -75,7 +83,7 @@ public sealed class AgentApp : IDisposable
|
|||||||
};
|
};
|
||||||
var chunks = new ChunkAssembler(_streams, _clock, _counters, _outbox, identity.NodeId, time, log);
|
var chunks = new ChunkAssembler(_streams, _clock, _counters, _outbox, identity.NodeId, time, log);
|
||||||
var targets = new TargetRunners(chunks, identity.NodeId, _clock, 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);
|
_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);
|
_tests = new TestRunner(_tasks, _sessions, _streams, env, _uplink, config.Limits.MaxConcurrentTests, transport => transport == "udp" ? _udp.BoundPorts : _tcp.BoundPorts, time, log);
|
||||||
|
|
||||||
@@ -90,6 +98,7 @@ public sealed class AgentApp : IDisposable
|
|||||||
Listen = new IPEndPoint(bind, config.Listen.ControlPort),
|
Listen = new IPEndPoint(bind, config.Listen.ControlPort),
|
||||||
Certificate = identity.Certificate,
|
Certificate = identity.Certificate,
|
||||||
LookupPeerKey = _tasks.LookupPeerKey,
|
LookupPeerKey = _tasks.LookupPeerKey,
|
||||||
|
Knock = _knock,
|
||||||
},
|
},
|
||||||
_hub.OnSessionAsync,
|
_hub.OnSessionAsync,
|
||||||
log,
|
log,
|
||||||
@@ -102,7 +111,11 @@ public sealed class AgentApp : IDisposable
|
|||||||
/// <summary>数据面和控制端口都开始监听之后完成(配置里的端口为 0 时,这里能读到实际端口)。</summary>
|
/// <summary>数据面和控制端口都开始监听之后完成(配置里的端口为 0 时,这里能读到实际端口)。</summary>
|
||||||
public Task Ready => _ready.Task;
|
public Task Ready => _ready.Task;
|
||||||
|
|
||||||
public void Dispose() => _udp.Dispose();
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_udp.Dispose();
|
||||||
|
_ownedRestart?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
internal StreamRegistry Streams => _streams;
|
internal StreamRegistry Streams => _streams;
|
||||||
|
|
||||||
@@ -112,10 +125,16 @@ public sealed class AgentApp : IDisposable
|
|||||||
|
|
||||||
public int ControlPort => _peers.Port;
|
public int ControlPort => _peers.Port;
|
||||||
|
|
||||||
|
/// <summary>节点当前采用的操作员集合版本(没有固定信任根时是 0)。</summary>
|
||||||
|
public int TrustVersion => _trust.Version;
|
||||||
|
|
||||||
public AgentCounters Counters => _counters;
|
public AgentCounters Counters => _counters;
|
||||||
|
|
||||||
public FleetLink Link => _link;
|
public FleetLink Link => _link;
|
||||||
|
|
||||||
|
/// <summary>对等面(Probe 连进来的会话)的路由器,测试用。</summary>
|
||||||
|
public RpcRouter<PeerContext> PeerRouter => _hub.Router;
|
||||||
|
|
||||||
/// <summary>期望状态里是否已经有这个 Probe 的公钥(测试用:握手会按它校验)。</summary>
|
/// <summary>期望状态里是否已经有这个 Probe 的公钥(测试用:握手会按它校验)。</summary>
|
||||||
public bool IsPeerKnown(string probeId, string publicKeyBase64) =>
|
public bool IsPeerKnown(string probeId, string publicKeyBase64) =>
|
||||||
_tasks.LookupPeerKey(probeId) is { } key && Convert.ToBase64String(key) == publicKeyBase64;
|
_tasks.LookupPeerKey(probeId) is { } key && Convert.ToBase64String(key) == publicKeyBase64;
|
||||||
@@ -190,7 +209,7 @@ public sealed class AgentApp : IDisposable
|
|||||||
|
|
||||||
private FleetLink BuildLink()
|
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;
|
HashSet<string>? allowed = _config.Policy.AllowMethods is { } list ? [.. list] : null;
|
||||||
bool Allow(string method) => allowed is null || allowed.Contains(method);
|
bool Allow(string method) => allowed is null || allowed.Contains(method);
|
||||||
|
|
||||||
@@ -221,7 +240,7 @@ public sealed class AgentApp : IDisposable
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Allow(MoShiroMethods.Schedule) && _restart is not null)
|
if (Allow(MoShiroMethods.Schedule))
|
||||||
{
|
{
|
||||||
router.OnNotify(MoShiroMethods.Schedule, MoShiroJsonContext.Default.ScheduleParams, (_, schedule, _) =>
|
router.OnNotify(MoShiroMethods.Schedule, MoShiroJsonContext.Default.ScheduleParams, (_, schedule, _) =>
|
||||||
{
|
{
|
||||||
@@ -256,6 +275,7 @@ public sealed class AgentApp : IDisposable
|
|||||||
{
|
{
|
||||||
Server = new Uri(_config.Fleet.Server),
|
Server = new Uri(_config.Fleet.Server),
|
||||||
Identity = _identity.Identity,
|
Identity = _identity.Identity,
|
||||||
|
ServerVerifier = _trust,
|
||||||
NodeId = _identity.NodeId,
|
NodeId = _identity.NodeId,
|
||||||
NodeVersion = Version,
|
NodeVersion = Version,
|
||||||
Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', BlockCatalog.AgentKinds)],
|
Capabilities = [MoShiroCapabilities.Base, MoShiroCapabilities.KindsPrefix + string.Join(',', BlockCatalog.AgentKinds)],
|
||||||
@@ -275,14 +295,18 @@ public sealed class AgentApp : IDisposable
|
|||||||
return link;
|
return link;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>敲门关卡的名册:期望状态里的 peers(任务管理器在构造之后才有)。</summary>
|
||||||
|
private IReadOnlyList<byte[]> RosterKeys() => _tasks.PeerKeys();
|
||||||
|
|
||||||
private JsonElement? HelloMeta()
|
private JsonElement? HelloMeta()
|
||||||
{
|
{
|
||||||
var meta = new AgentHelloMeta
|
var meta = new AgentHelloMeta
|
||||||
{
|
{
|
||||||
Ipv6 = Ipv6Diagnostics.Sample(_ipv6Supported, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")),
|
Ipv6 = Ipv6Diagnostics.Sample(_ipv6Supported, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")),
|
||||||
Ipv6Supported = _ipv6Supported,
|
Ipv6Supported = _ipv6Supported,
|
||||||
|
Addresses = [.. LocalAddresses.Sample()],
|
||||||
Platform = MoShiro.Measurement.PlatformDetector.Detect(),
|
Platform = MoShiro.Measurement.PlatformDetector.Detect(),
|
||||||
TlsSpki = _identity.File.TlsSpki,
|
TlsPublicKey = Convert.ToBase64String(_identity.Certificate.PublicKey.ExportSubjectPublicKeyInfo()),
|
||||||
ProbeTcpPort = ControlPort,
|
ProbeTcpPort = ControlPort,
|
||||||
ProbeUdpPorts = [.. UdpPorts],
|
ProbeUdpPorts = [.. UdpPorts],
|
||||||
LocalLimits = new LocalLimits
|
LocalLimits = new LocalLimits
|
||||||
|
|||||||
@@ -90,6 +90,9 @@ public sealed class PeerHub
|
|||||||
private readonly TimeProvider _time;
|
private readonly TimeProvider _time;
|
||||||
private readonly INodeLog _log;
|
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)
|
public PeerHub(SessionRegistry sessions, MonoClock clock, ChunkAssembler chunks, TaskManager tasks, BackendUplink uplink, TimeProvider time, INodeLog log)
|
||||||
{
|
{
|
||||||
_router = BuildRouter();
|
_router = BuildRouter();
|
||||||
@@ -272,7 +275,7 @@ public sealed class PeerHub
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static RpcRouter<PeerContext> BuildRouter() =>
|
private static RpcRouter<PeerContext> BuildRouter() =>
|
||||||
new RpcRouter<PeerContext>()
|
new RpcRouter<PeerContext>(RpcSide.Listener, MoShiroMethodTable.Table)
|
||||||
.On(
|
.On(
|
||||||
MoShiroMethods.Clock,
|
MoShiroMethods.Clock,
|
||||||
MoShiroJsonContext.Default.ClockParams,
|
MoShiroJsonContext.Default.ClockParams,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Fleet.Hosting;
|
||||||
using Fleet.Core;
|
using Fleet.Core;
|
||||||
using Fleet.Rpc;
|
using Fleet.Rpc;
|
||||||
using MoShiro.Measurement;
|
using MoShiro.Measurement;
|
||||||
@@ -25,14 +26,16 @@ public sealed class TaskManager
|
|||||||
private readonly TargetRunners _targets;
|
private readonly TargetRunners _targets;
|
||||||
private readonly Func<IReadOnlyList<int>> _udpPorts;
|
private readonly Func<IReadOnlyList<int>> _udpPorts;
|
||||||
private readonly Func<IReadOnlyList<int>> _tcpPorts;
|
private readonly Func<IReadOnlyList<int>> _tcpPorts;
|
||||||
|
private readonly TrustAnchor _trust;
|
||||||
private readonly Dictionary<ProbeSession, Dictionary<string, LineEntry>> _sessionLines = [];
|
private readonly Dictionary<ProbeSession, Dictionary<string, LineEntry>> _sessionLines = [];
|
||||||
private volatile Dictionary<string, byte[]> _peers = new(StringComparer.Ordinal);
|
private volatile Dictionary<string, byte[]> _peers = new(StringComparer.Ordinal);
|
||||||
private Dictionary<string, (TaskSpec Task, TaskDecision Decision)> _lineSpecs = [];
|
private Dictionary<string, (TaskSpec Task, TaskDecision Decision)> _lineSpecs = [];
|
||||||
private long _version;
|
private long _version;
|
||||||
private BudgetGuard? _guard;
|
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;
|
_guardFactory = guardFactory;
|
||||||
_streams = streams;
|
_streams = streams;
|
||||||
_env = env;
|
_env = env;
|
||||||
@@ -52,6 +55,9 @@ public sealed class TaskManager
|
|||||||
/// <summary>点对点握手时查找 Probe 的公钥(SPKI)。</summary>
|
/// <summary>点对点握手时查找 Probe 的公钥(SPKI)。</summary>
|
||||||
public byte[]? LookupPeerKey(string peerId) => _peers.TryGetValue(peerId, out byte[]? key) ? key : null;
|
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>
|
/// <summary>一个新的 Probe 会话开始:为它登记全部线路任务的流并启动监听方积木,返回要下发给它的线路任务集。</summary>
|
||||||
public LineTaskSet OpenSession(ProbeSession probe)
|
public LineTaskSet OpenSession(ProbeSession probe)
|
||||||
{
|
{
|
||||||
@@ -95,6 +101,8 @@ public sealed class TaskManager
|
|||||||
_ = ct;
|
_ = ct;
|
||||||
var result = new StateSyncResult { AppliedVersion = version };
|
var result = new StateSyncResult { AppliedVersion = version };
|
||||||
|
|
||||||
|
// 固定了操作员创世的节点:面板带来的新版本操作员集合,从固定的创世验过才采用(名册本身由面板决定)
|
||||||
|
_trust.Update(set.TrustChain);
|
||||||
var peers = new Dictionary<string, byte[]>(StringComparer.Ordinal);
|
var peers = new Dictionary<string, byte[]>(StringComparer.Ordinal);
|
||||||
foreach (PeerSpec peer in set.Peers)
|
foreach (PeerSpec peer in set.Peers)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
- JSON-RPC 2.0;方法名 `领域.动作`,`fleet.*` 保留给框架,产品用自己的前缀(MoShiro 是 `moshiro.*`)。
|
- JSON-RPC 2.0;方法名 `领域.动作`,`fleet.*` 保留给框架,产品用自己的前缀(MoShiro 是 `moshiro.*`)。
|
||||||
- 二进制帧 `u8 channel | 正文`:`0`–`15` 框架保留,产品从 `16` 开始。
|
- 二进制帧 `u8 channel | 正文`:`0`–`15` 框架保留,产品从 `16` 开始。
|
||||||
- 错误:`error.data.code` 为 snake_case,框架的以 `fleet_` 开头,产品可以扩展。
|
- 错误:`error.data.code` 为 snake_case,框架的以 `fleet_` 开头,产品可以扩展。
|
||||||
|
- **方法必须声明**:每个方法在声明表(`MethodTable`)里写明发起方、请求还是通知、哪些角色可以参与;不存在双向方法,同一个方法名在一个平面里只有一个发起方。`RpcRouter` 在注册、分发和发送三处核对,违规回 `fleet_protocol_violation`,超过上限断开连接。详见 [`docs/security.md`](../../docs/security.md) 第 8 节。
|
||||||
- 与传输无关:同一套路由跑在 wss 上,也跑在 TLS 帧(`u32 长度 | u8 类型 | 正文`)上。
|
- 与传输无关:同一套路由跑在 wss 上,也跑在 TLS 帧(`u32 长度 | u8 类型 | 正文`)上。
|
||||||
|
|
||||||
| 保留方法 | 方向 | 说明 |
|
| 保留方法 | 方向 | 说明 |
|
||||||
@@ -85,11 +86,13 @@ connect(wss) → fleet.challenge → fleet.auth → fleet.hello → 状态同步
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
节点 A(发起方,如 Probe)── TLS(按 SPKI 校验证书)──> 节点 B(监听方,如 Agent)
|
节点 A(发起方,如 Probe)── TLS(按 SPKI 校验证书)──> 节点 B(监听方,如 Agent)
|
||||||
<── fleet.challenge ──
|
<── fleet.peer.challenge ──
|
||||||
── fleet.auth(peer 用途,A 的私钥签名)──>
|
── fleet.peer.auth(peer 用途,A 的私钥签名)──>
|
||||||
B 用服务端下发的对端公钥列表验签(经 state.sync 下发),之后复用同一套 Rpc:路由、二进制通道、发件箱
|
B 用服务端下发的对端公钥列表验签(经 state.sync 下发),之后复用同一套 Rpc:路由、二进制通道、发件箱
|
||||||
```
|
```
|
||||||
|
|
||||||
|
握手用两个方法:`fleet.peer.challenge`(监听方→拨号方,通知)和 `fleet.peer.auth`(拨号方→监听方,请求);握手完成后,两侧各自只能发起声明表里属于自己这一侧的方法。
|
||||||
|
|
||||||
对端公钥和允许连接的节点由产品通过期望状态下发给监听方,Fleet 只管认证和会话,拓扑由产品决定;UDP、原始 TCP 数据面由产品自己实现,Fleet 只提供会话密钥派生和首帧 MAC 的辅助函数。
|
对端公钥和允许连接的节点由产品通过期望状态下发给监听方,Fleet 只管认证和会话,拓扑由产品决定;UDP、原始 TCP 数据面由产品自己实现,Fleet 只提供会话密钥派生和首帧 MAC 的辅助函数。
|
||||||
|
|
||||||
## 宿主与服务端抽象
|
## 宿主与服务端抽象
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ public static class FleetErrors
|
|||||||
public static readonly ErrorCode Unauthorized = new("fleet_unauthorized");
|
public static readonly ErrorCode Unauthorized = new("fleet_unauthorized");
|
||||||
public static readonly ErrorCode Forbidden = new("fleet_forbidden");
|
public static readonly ErrorCode Forbidden = new("fleet_forbidden");
|
||||||
public static readonly ErrorCode Unsupported = new("fleet_unsupported");
|
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 InvalidParams = new("fleet_invalid_params");
|
||||||
public static readonly ErrorCode NotFound = new("fleet_not_found");
|
public static readonly ErrorCode NotFound = new("fleet_not_found");
|
||||||
public static readonly ErrorCode Internal = new("fleet_internal");
|
public static readonly ErrorCode Internal = new("fleet_internal");
|
||||||
|
|||||||
@@ -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;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using Fleet.Core;
|
using Fleet.Core;
|
||||||
using Fleet.Hosting;
|
using Fleet.Hosting;
|
||||||
using Fleet.Identity;
|
using Fleet.Identity;
|
||||||
|
|
||||||
namespace Fleet.Enrollment.Client;
|
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>
|
/// <summary>
|
||||||
/// 用加入令牌注册(供 <c>fleet-enroll</c> 使用,也可以嵌入产品自己的安装工具):
|
/// 用加入令牌注册(供 <c>fleet-enroll</c> 使用,也可以嵌入产品自己的安装工具):
|
||||||
@@ -18,7 +22,8 @@ public sealed record EnrollmentResult(string NodeId, string Status, string Ident
|
|||||||
public static class EnrollmentClient
|
public static class EnrollmentClient
|
||||||
{
|
{
|
||||||
public static async Task<Result<EnrollmentResult>> EnrollAsync(
|
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);
|
string nodeFile = Path.Combine(identityDirectory, LoadedNodeIdentity.NodeFileName);
|
||||||
if (File.Exists(nodeFile))
|
if (File.Exists(nodeFile))
|
||||||
@@ -29,9 +34,23 @@ public static class EnrollmentClient
|
|||||||
using NodeIdentity identity = NodeIdentity.Generate();
|
using NodeIdentity identity = NodeIdentity.Generate();
|
||||||
byte[] pfx = NodeCertificate.CreateSelfSignedPfx(name, TimeSpan.FromDays(3650), time);
|
byte[] pfx = NodeCertificate.CreateSelfSignedPfx(name, TimeSpan.FromDays(3650), time);
|
||||||
byte[] spki;
|
byte[] spki;
|
||||||
|
byte[] tlsPublicKey;
|
||||||
using (var certificate = NodeCertificate.LoadPfx(pfx))
|
using (var certificate = NodeCertificate.LoadPfx(pfx))
|
||||||
{
|
{
|
||||||
spki = NodeCertificate.SpkiFingerprint(certificate);
|
spki = NodeCertificate.SpkiFingerprint(certificate);
|
||||||
|
tlsPublicKey = certificate.PublicKey.ExportSubjectPublicKeyInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 安装命令里带了创世指纹(--genesis)和属主(--controllers):先取操作员集合链,从固定的创世验起,
|
||||||
|
// 属主必须是集合里的凭证;对不上就不注册,也不把自己的密钥交出去。通过之后固定到本地 trust.json,Backend 之后改不了。
|
||||||
|
TrustBundle? bundle = null;
|
||||||
|
if (genesis is not null)
|
||||||
|
{
|
||||||
|
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);
|
string pubkey = Convert.ToBase64String(identity.PublicKeySpki);
|
||||||
@@ -42,6 +61,7 @@ public static class EnrollmentClient
|
|||||||
["name"] = name,
|
["name"] = name,
|
||||||
["pubkey"] = pubkey,
|
["pubkey"] = pubkey,
|
||||||
["tls_spki"] = Convert.ToBase64String(spki),
|
["tls_spki"] = Convert.ToBase64String(spki),
|
||||||
|
["tls_pubkey"] = Convert.ToBase64String(tlsPublicKey),
|
||||||
["proof"] = Convert.ToBase64String(identity.Sign(SignaturePurpose.Enroll, tokenHash, pubkey)),
|
["proof"] = Convert.ToBase64String(identity.Sign(SignaturePurpose.Enroll, tokenHash, pubkey)),
|
||||||
["node_version"] = nodeVersion,
|
["node_version"] = nodeVersion,
|
||||||
["meta"] = productMeta,
|
["meta"] = productMeta,
|
||||||
@@ -73,10 +93,46 @@ public static class EnrollmentClient
|
|||||||
var store = new ConfigStore(identityDirectory);
|
var store = new ConfigStore(identityDirectory);
|
||||||
store.Write(LoadedNodeIdentity.KeyFileName, identity.ExportPkcs8());
|
store.Write(LoadedNodeIdentity.KeyFileName, identity.ExportPkcs8());
|
||||||
store.Write(LoadedNodeIdentity.TlsFileName, pfx);
|
store.Write(LoadedNodeIdentity.TlsFileName, pfx);
|
||||||
|
if (bundle is not null)
|
||||||
|
{
|
||||||
|
TrustAnchor.Pin(identityDirectory, genesis!, bundle.Chain, controllers ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
// node.json 最后写:它存在就表示注册完整完成
|
// node.json 最后写:它存在就表示注册完整完成
|
||||||
store.Write(
|
store.Write(
|
||||||
LoadedNodeIdentity.NodeFileName,
|
LoadedNodeIdentity.NodeFileName,
|
||||||
JsonSerializer.SerializeToUtf8Bytes(new NodeFile { NodeId = nodeId, Server = server.ToString().TrimEnd('/'), TlsSpki = Convert.ToBase64String(spki) }, NodeFileContext.Default.NodeFile));
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -331,6 +331,15 @@ public sealed class LoadedNodeIdentity : IDisposable
|
|||||||
|
|
||||||
public X509Certificate2 Certificate { get; }
|
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)
|
public static LoadedNodeIdentity Load(string directory)
|
||||||
{
|
{
|
||||||
if (!System.IO.File.Exists(Path.Combine(directory, KeyFileName)) || !System.IO.File.Exists(Path.Combine(directory, TlsFileName)))
|
if (!System.IO.File.Exists(Path.Combine(directory, KeyFileName)) || !System.IO.File.Exists(Path.Combine(directory, TlsFileName)))
|
||||||
@@ -347,6 +356,7 @@ public sealed class LoadedNodeIdentity : IDisposable
|
|||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
Identity.Dispose();
|
Identity.Dispose();
|
||||||
|
_peerIdentity?.Dispose();
|
||||||
Certificate.Dispose();
|
Certificate.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
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>
|
/// <summary>证书公钥(SPKI)的 SHA-256:对端用它来固定这张证书。</summary>
|
||||||
public static byte[] SpkiFingerprint(X509Certificate2 certificate) =>
|
public static byte[] SpkiFingerprint(X509Certificate2 certificate) =>
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ namespace Fleet.Identity;
|
|||||||
public static class SignaturePurpose
|
public static class SignaturePurpose
|
||||||
{
|
{
|
||||||
public const string Auth = "auth";
|
public const string Auth = "auth";
|
||||||
|
|
||||||
|
/// <summary>安全链路的认证:签名同时覆盖服务器和节点的临时公钥,把这次认证和这条加密链路绑在一起。</summary>
|
||||||
|
public const string AuthSecure = "auth2";
|
||||||
public const string Peer = "peer";
|
public const string Peer = "peer";
|
||||||
public const string Enroll = "enroll";
|
public const string Enroll = "enroll";
|
||||||
}
|
}
|
||||||
@@ -41,6 +44,16 @@ public sealed class NodeIdentity : IDisposable
|
|||||||
|
|
||||||
public byte[] ExportPkcs8() => _key.ExportPkcs8PrivateKey();
|
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) =>
|
public byte[] Sign(string purpose, params string[] fields) =>
|
||||||
_key.SignData(Encoding.UTF8.GetBytes(SignatureString.Build(purpose, fields)), HashAlgorithmName.SHA256,
|
_key.SignData(Encoding.UTF8.GetBytes(SignatureString.Build(purpose, fields)), HashAlgorithmName.SHA256,
|
||||||
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
|
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();
|
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>
|
/// <summary>
|
||||||
/// 与对端的临时公钥协商,再经 HKDF-SHA256 派生会话凭据。
|
/// 与对端的临时公钥协商,再经 HKDF-SHA256 派生会话凭据。
|
||||||
/// 盐是服务端的随机数,info 绑定双方的临时公钥,防止凭据被挪用到别的握手上。
|
/// 盐是服务端的随机数,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,6 +66,12 @@ public sealed class FleetLinkOptions
|
|||||||
public long MaxInflightBytes { get; init; } = 4 << 20;
|
public long MaxInflightBytes { get; init; } = 4 << 20;
|
||||||
|
|
||||||
public Backoff Backoff { get; init; } = new();
|
public Backoff Backoff { get; init; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 校验服务器身份:<see cref="IServerVerifier.Required"/> 为真(节点固定的操作员集合里登记了服务器密钥)时,
|
||||||
|
/// 服务器必须在挑战里证明自己,之后这条链路用应用层加密;否则按旧的方式(没有固定信任根的节点)。
|
||||||
|
/// </summary>
|
||||||
|
public IServerVerifier? ServerVerifier { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -77,6 +83,7 @@ public sealed class FleetLink
|
|||||||
private const string RpcPath = "/api/agent/v1/rpc";
|
private const string RpcPath = "/api/agent/v1/rpc";
|
||||||
|
|
||||||
private readonly FleetLinkOptions _options;
|
private readonly FleetLinkOptions _options;
|
||||||
|
private volatile bool _secure;
|
||||||
private readonly RpcRouter<LinkContext> _router;
|
private readonly RpcRouter<LinkContext> _router;
|
||||||
private readonly Dictionary<string, IStateHandler> _handlers;
|
private readonly Dictionary<string, IStateHandler> _handlers;
|
||||||
private readonly TimeProvider _time;
|
private readonly TimeProvider _time;
|
||||||
@@ -105,6 +112,9 @@ public sealed class FleetLink
|
|||||||
|
|
||||||
public RpcSession<LinkContext>? CurrentSession => _session;
|
public RpcSession<LinkContext>? CurrentSession => _session;
|
||||||
|
|
||||||
|
/// <summary>入站方法的路由器(只读,测试用它核对声明表和实际注册的处理函数)。</summary>
|
||||||
|
public RpcRouter<LinkContext> Router => _router;
|
||||||
|
|
||||||
/// <summary>被服务端撤销或禁用:不会再重连。</summary>
|
/// <summary>被服务端撤销或禁用:不会再重连。</summary>
|
||||||
public bool Revoked => _stopped;
|
public bool Revoked => _stopped;
|
||||||
|
|
||||||
@@ -150,6 +160,9 @@ public sealed class FleetLink
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>当前这条链路是不是应用层加密的(服务器证明了身份)。</summary>
|
||||||
|
public bool IsSecure => _secure && IsConnected;
|
||||||
|
|
||||||
private async Task<string> RunOnceAsync(CancellationToken ct)
|
private async Task<string> RunOnceAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
using var socket = new ClientWebSocket();
|
using var socket = new ClientWebSocket();
|
||||||
@@ -157,7 +170,7 @@ public sealed class FleetLink
|
|||||||
socket.Options.KeepAliveTimeout = TimeSpan.FromSeconds(20); // 对端 20 秒没有回 Pong 就判定连接已死
|
socket.Options.KeepAliveTimeout = TimeSpan.FromSeconds(20); // 对端 20 秒没有回 Pong 就判定连接已死
|
||||||
var builder = new UriBuilder(_options.Server) { Scheme = _options.Server.Scheme == "https" ? "wss" : "ws", Path = RpcPath };
|
var builder = new UriBuilder(_options.Server) { Scheme = _options.Server.Scheme == "https" ? "wss" : "ws", Path = RpcPath };
|
||||||
await socket.ConnectAsync(builder.Uri, ct);
|
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);
|
string? failure = await AuthenticateAsync(transport, ct);
|
||||||
if (failure is not null)
|
if (failure is not null)
|
||||||
@@ -165,6 +178,8 @@ public sealed class FleetLink
|
|||||||
return failure;
|
return failure;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_secure = transport.Secure;
|
||||||
|
|
||||||
var session = new RpcSession<LinkContext>(transport, _router, new LinkContext(this), _time);
|
var session = new RpcSession<LinkContext>(transport, _router, new LinkContext(this), _time);
|
||||||
using CancellationTokenSource sessionCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
using CancellationTokenSource sessionCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
Task run = session.RunAsync(sessionCts.Token);
|
Task run = session.RunAsync(sessionCts.Token);
|
||||||
@@ -208,7 +223,7 @@ public sealed class FleetLink
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>挑战 → 签名 → 认证结果。失败返回原因;服务端禁用该节点时不再重试。</summary>
|
/// <summary>挑战 → 签名 → 认证结果。失败返回原因;服务端禁用该节点时不再重试。</summary>
|
||||||
private async Task<string?> AuthenticateAsync(WsTransport transport, CancellationToken ct)
|
private async Task<string?> AuthenticateAsync(SecureTransport transport, CancellationToken ct)
|
||||||
{
|
{
|
||||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15), _time);
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15), _time);
|
||||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
||||||
@@ -219,9 +234,44 @@ public sealed class FleetLink
|
|||||||
}
|
}
|
||||||
|
|
||||||
ChallengeParams nonce = challenge.Params.Value.Deserialize(FleetRpcContext.Default.ChallengeParams)!;
|
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);
|
// 固定了操作员集合、而集合里登记了服务器密钥:服务器必须证明自己,否则不继续,也不交出任何东西
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
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)) };
|
||||||
|
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);
|
await transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(request, FleetRpcContext.Default.RpcMessage)), linked.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ephemeral?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
@@ -256,7 +306,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);
|
RpcFrame? frame = await transport.ReceiveAsync(ct);
|
||||||
return frame is { Kind: FrameKind.Text } f ? JsonSerializer.Deserialize(f.Data.Span, FleetRpcContext.Default.RpcMessage) : null;
|
return frame is { Kind: FrameKind.Text } f ? JsonSerializer.Deserialize(f.Data.Span, FleetRpcContext.Default.RpcMessage) : null;
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ public sealed class PeerListenerOptions
|
|||||||
/// <summary>按对端 ID 查找允许连接的公钥(SPKI)。不在列表里的对端一律拒绝(期望状态里的 peers)。</summary>
|
/// <summary>按对端 ID 查找允许连接的公钥(SPKI)。不在列表里的对端一律拒绝(期望状态里的 peers)。</summary>
|
||||||
public required Func<string, byte[]?> LookupPeerKey { get; init; }
|
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 MaxFrameBytes { get; init; } = 8 << 20;
|
||||||
|
|
||||||
public int MaxSessions { get; init; } = 8;
|
public int MaxSessions { get; init; } = 8;
|
||||||
@@ -98,7 +101,13 @@ public sealed class PeerListener
|
|||||||
PeerSockets.EnableKeepAlive(client.Client);
|
PeerSockets.EnableKeepAlive(client.Client);
|
||||||
using var timeout = new CancellationTokenSource(_options.HandshakeTimeout, _time);
|
using var timeout = new CancellationTokenSource(_options.HandshakeTimeout, _time);
|
||||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
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(
|
await tls.AuthenticateAsServerAsync(
|
||||||
new SslServerAuthenticationOptions { ServerCertificate = _options.Certificate, ClientCertificateRequired = false, EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13 },
|
new SslServerAuthenticationOptions { ServerCertificate = _options.Certificate, ClientCertificateRequired = false, EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13 },
|
||||||
linked.Token);
|
linked.Token);
|
||||||
@@ -222,8 +231,10 @@ internal static class PeerSockets
|
|||||||
public static class PeerConnector
|
public static class PeerConnector
|
||||||
{
|
{
|
||||||
public static async Task<(PeerSessionInfo Info, TlsFrameTransport Transport)> ConnectAsync(
|
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();
|
TcpClient client = IPAddress.TryParse(host, out IPAddress? address) ? new TcpClient(address.AddressFamily) : new TcpClient();
|
||||||
client.NoDelay = true;
|
client.NoDelay = true;
|
||||||
PeerSockets.EnableKeepAlive(client.Client);
|
PeerSockets.EnableKeepAlive(client.Client);
|
||||||
@@ -232,8 +243,10 @@ public static class PeerConnector
|
|||||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10), time);
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10), time);
|
||||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
||||||
await client.ConnectAsync(host, port, linked.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(
|
var tls = new SslStream(
|
||||||
client.GetStream(), leaveInnerStreamOpen: false,
|
stream, leaveInnerStreamOpen: false,
|
||||||
(_, certificate, _, _) => certificate is not null && SpkiMatches(certificate, expectedSpkiSha256));
|
(_, certificate, _, _) => certificate is not null && SpkiMatches(certificate, expectedSpkiSha256));
|
||||||
await tls.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = host }, linked.Token);
|
await tls.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = host }, linked.Token);
|
||||||
var transport = new TlsFrameTransport(tls, maxFrameBytes);
|
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";
|
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>
|
/// <summary>二进制通道:0–15 保留给框架,产品从 16 开始。</summary>
|
||||||
public static class FleetChannels
|
public static class FleetChannels
|
||||||
{
|
{
|
||||||
@@ -28,6 +53,14 @@ public sealed class ChallengeParams
|
|||||||
|
|
||||||
[JsonPropertyName("expires_at_ms")]
|
[JsonPropertyName("expires_at_ms")]
|
||||||
public long ExpiresAtMs { get; set; }
|
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
|
public sealed class AuthParams
|
||||||
@@ -38,6 +71,10 @@ public sealed class AuthParams
|
|||||||
/// <summary>对 <c>fleet|v1|auth|{node_id}|{nonce}</c> 的签名(base64)。</summary>
|
/// <summary>对 <c>fleet|v1|auth|{node_id}|{nonce}</c> 的签名(base64)。</summary>
|
||||||
[JsonPropertyName("signature")]
|
[JsonPropertyName("signature")]
|
||||||
public string Signature { get; set; } = "";
|
public string Signature { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>节点的临时 ECDH 公钥(SPKI,base64):有它就是安全链路,签名覆盖双方的临时公钥,认证结果起所有帧都是加密的。</summary>
|
||||||
|
[JsonPropertyName("node_eph")]
|
||||||
|
public string? NodeEphemeral { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class AuthResult
|
public sealed class AuthResult
|
||||||
|
|||||||
@@ -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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,17 @@ using Fleet.Core;
|
|||||||
|
|
||||||
namespace Fleet.Rpc;
|
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>
|
public sealed class RpcRouter<TContext>
|
||||||
{
|
{
|
||||||
private delegate ValueTask<Result<JsonElement?>> Route(TContext context, JsonElement? parameters, CancellationToken ct);
|
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 Dictionary<string, Route> _routes = new(StringComparer.Ordinal);
|
||||||
private readonly Action<string, Exception>? _onUnhandled;
|
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;
|
_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 bool Has(string method) => _routes.ContainsKey(method);
|
||||||
|
|
||||||
public IReadOnlyCollection<string> Methods => _routes.Keys;
|
public IReadOnlyCollection<string> Methods => _routes.Keys;
|
||||||
@@ -29,6 +55,7 @@ public sealed class RpcRouter<TContext>
|
|||||||
JsonTypeInfo<TResult> resultInfo,
|
JsonTypeInfo<TResult> resultInfo,
|
||||||
Func<TContext, TParams, CancellationToken, ValueTask<Result<TResult>>> handler)
|
Func<TContext, TParams, CancellationToken, ValueTask<Result<TResult>>> handler)
|
||||||
{
|
{
|
||||||
|
Declare(method, MethodKind.Request);
|
||||||
_routes[method] = async (context, raw, ct) =>
|
_routes[method] = async (context, raw, ct) =>
|
||||||
{
|
{
|
||||||
if (!TryDeserialize(raw, paramsInfo, out TParams? p))
|
if (!TryDeserialize(raw, paramsInfo, out TParams? p))
|
||||||
@@ -49,6 +76,7 @@ public sealed class RpcRouter<TContext>
|
|||||||
JsonTypeInfo<TParams> paramsInfo,
|
JsonTypeInfo<TParams> paramsInfo,
|
||||||
Func<TContext, TParams, CancellationToken, ValueTask> handler)
|
Func<TContext, TParams, CancellationToken, ValueTask> handler)
|
||||||
{
|
{
|
||||||
|
Declare(method, MethodKind.Notification);
|
||||||
_routes[method] = async (context, raw, ct) =>
|
_routes[method] = async (context, raw, ct) =>
|
||||||
{
|
{
|
||||||
if (!TryDeserialize(raw, paramsInfo, out TParams? p))
|
if (!TryDeserialize(raw, paramsInfo, out TParams? p))
|
||||||
@@ -66,12 +94,26 @@ public sealed class RpcRouter<TContext>
|
|||||||
/// 处理一条请求或通知。请求返回响应;通知返回 null(失败时只丢弃)。
|
/// 处理一条请求或通知。请求返回响应;通知返回 null(失败时只丢弃)。
|
||||||
/// 处理函数里的异常属于程序缺陷:在这个边界捕获,回复 internal 错误,并交给 <c>onUnhandled</c> 记录。
|
/// 处理函数里的异常属于程序缺陷:在这个边界捕获,回复 internal 错误,并交给 <c>onUnhandled</c> 记录。
|
||||||
/// </summary>
|
/// </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 ?? "";
|
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))
|
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;
|
Result<JsonElement?> result;
|
||||||
@@ -91,12 +133,90 @@ public sealed class RpcRouter<TContext>
|
|||||||
|
|
||||||
if (message.Id is null)
|
if (message.Id is null)
|
||||||
{
|
{
|
||||||
|
return new RpcDispatch(null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.TryGet(out JsonElement? value, out FleetError? error)
|
/// <summary>
|
||||||
? new RpcMessage { Id = message.Id, Result = value ?? s_emptyObject }
|
/// 发送前检查:本侧有没有资格发起这个方法、类型对不对、对端的角色允不允许(服务端发给节点时传入节点的角色)。
|
||||||
: RpcMessage.Failure(message.Id, error);
|
/// 违反是程序缺陷,抛 <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)
|
private static bool TryDeserialize<T>(JsonElement? raw, JsonTypeInfo<T> info, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out T? value)
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ public sealed class RpcSessionOptions
|
|||||||
|
|
||||||
/// <summary>待处理的入站请求上限;超过时接收循环等待(背压)。</summary>
|
/// <summary>待处理的入站请求上限;超过时接收循环等待(背压)。</summary>
|
||||||
public int InboundQueue { get; set; } = 256;
|
public int InboundQueue { get; set; } = 256;
|
||||||
|
|
||||||
|
/// <summary>对端违反协议(方法没有声明、方向、类型或角色不对)的次数上限;超过就断开会话。</summary>
|
||||||
|
public int MaxViolations { get; set; } = 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -53,6 +56,8 @@ public sealed class RpcSession<TContext> : IAsyncDisposable
|
|||||||
private readonly CancellationTokenSource _stop = new();
|
private readonly CancellationTokenSource _stop = new();
|
||||||
private long _nextId;
|
private long _nextId;
|
||||||
private long _lastReceive;
|
private long _lastReceive;
|
||||||
|
private int _violations;
|
||||||
|
private bool _violated;
|
||||||
|
|
||||||
public RpcSession(IRpcTransport transport, RpcRouter<TContext> router, TContext context, TimeProvider time, RpcSessionOptions? options = null)
|
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)
|
while (!linked.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
RpcFrame? frame = await _transport.ReceiveAsync(linked.Token);
|
RpcFrame? frame = await _transport.ReceiveAsync(linked.Token);
|
||||||
if (frame is null)
|
if (frame is null || Volatile.Read(ref _violated))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -184,8 +189,16 @@ public sealed class RpcSession<TContext> : IAsyncDisposable
|
|||||||
public ValueTask NotifyAsync<TParams>(string method, TParams parameters, JsonTypeInfo<TParams> paramsInfo, CancellationToken ct) =>
|
public ValueTask NotifyAsync<TParams>(string method, TParams parameters, JsonTypeInfo<TParams> paramsInfo, CancellationToken ct) =>
|
||||||
SendAsync(RpcMessage.Notification(method, parameters, paramsInfo), ct);
|
SendAsync(RpcMessage.Notification(method, parameters, paramsInfo), ct);
|
||||||
|
|
||||||
public ValueTask SendAsync(RpcMessage message, CancellationToken ct) =>
|
public ValueTask SendAsync(RpcMessage message, CancellationToken ct)
|
||||||
_transport.SendAsync(new RpcFrame(FrameKind.Text, JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage)), 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) =>
|
public ValueTask SendBinaryAsync(ReadOnlyMemory<byte> frame, CancellationToken ct) =>
|
||||||
_transport.SendAsync(new RpcFrame(FrameKind.Binary, frame), 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))
|
await foreach (RpcMessage message in _inbound.Reader.ReadAllAsync(ct))
|
||||||
{
|
{
|
||||||
RpcMessage? response = await _router.DispatchAsync(_context, message, ct);
|
RpcDispatch outcome = await _router.DispatchCheckedAsync(_context, message, ct);
|
||||||
if (response is not null)
|
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>
|
/// <summary>节点 ID 的类型前缀,MoShiro 使用 <c>agt</c>。</summary>
|
||||||
public string NodeIdPrefix { get; set; } = "node";
|
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 int MaxFrameBytes { get; set; } = 16 << 20;
|
||||||
|
|
||||||
public TimeSpan ChallengeTtl { get; set; } = TimeSpan.FromSeconds(30);
|
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 HeartbeatTimeout { get; set; } = TimeSpan.FromSeconds(45);
|
||||||
|
|
||||||
public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(15);
|
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,
|
IReadOnlyList<string> Labels,
|
||||||
string? MetaJson,
|
string? MetaJson,
|
||||||
string? NodeVersion,
|
string? NodeVersion,
|
||||||
long NowMs);
|
long NowMs,
|
||||||
|
string Kind = "",
|
||||||
|
byte[]? TlsPublicKey = null,
|
||||||
|
string? AddedBy = null);
|
||||||
|
|
||||||
public sealed record JoinTokenRecord(
|
public sealed record JoinTokenRecord(
|
||||||
string Id,
|
string Id,
|
||||||
@@ -43,6 +46,8 @@ public sealed record JoinTokenRecord(
|
|||||||
int Uses,
|
int Uses,
|
||||||
long ExpiresAtMs,
|
long ExpiresAtMs,
|
||||||
long? RevokedAtMs,
|
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);
|
public sealed record Presence(bool Online, long AtMs, string? NodeVersion, IReadOnlyList<string> Capabilities, string? HelloMetaJson);
|
||||||
@@ -6,6 +6,24 @@ namespace Fleet.Server;
|
|||||||
|
|
||||||
public sealed record EnrollmentContext(string? RemoteIp, JsonElement? ProductMeta);
|
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>
|
||||||
|
public interface IOperatorTrustSource
|
||||||
|
{
|
||||||
|
ValueTask<IReadOnlyList<SignedStatementDto>> ChainAsync(CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>产品挂钩:节点注册成功之后调用。</summary>
|
/// <summary>产品挂钩:节点注册成功之后调用。</summary>
|
||||||
public interface IEnrollmentHook
|
public interface IEnrollmentHook
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ public sealed class EnrollRequest
|
|||||||
[JsonPropertyName("tls_spki")]
|
[JsonPropertyName("tls_spki")]
|
||||||
public string TlsSpki { get; set; } = "";
|
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>
|
/// <summary>持有证明:对 <c>fleet|v1|enroll|{sha256_hex(join_token)}|{pubkey}</c> 的签名(base64)。</summary>
|
||||||
[JsonPropertyName("proof")]
|
[JsonPropertyName("proof")]
|
||||||
public string Proof { get; set; } = "";
|
public string Proof { get; set; } = "";
|
||||||
@@ -30,6 +37,12 @@ public sealed class EnrollRequest
|
|||||||
public JsonElement? Meta { get; set; }
|
public JsonElement? Meta { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class TrustRequest
|
||||||
|
{
|
||||||
|
[JsonPropertyName("join_token")]
|
||||||
|
public string JoinToken { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class EnrollResponse
|
public sealed class EnrollResponse
|
||||||
{
|
{
|
||||||
[JsonPropertyName("node_id")]
|
[JsonPropertyName("node_id")]
|
||||||
@@ -62,5 +75,7 @@ public sealed class FleetProblem
|
|||||||
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
[JsonSerializable(typeof(EnrollRequest))]
|
[JsonSerializable(typeof(EnrollRequest))]
|
||||||
[JsonSerializable(typeof(EnrollResponse))]
|
[JsonSerializable(typeof(EnrollResponse))]
|
||||||
|
[JsonSerializable(typeof(TrustRequest))]
|
||||||
|
[JsonSerializable(typeof(Fleet.Core.TrustBundle))]
|
||||||
[JsonSerializable(typeof(FleetProblem))]
|
[JsonSerializable(typeof(FleetProblem))]
|
||||||
internal sealed partial class EnrollmentJsonContext : JsonSerializerContext;
|
internal sealed partial class EnrollmentJsonContext : JsonSerializerContext;
|
||||||
@@ -20,9 +20,42 @@ public static class FleetEnrollmentEndpoints
|
|||||||
.WithTags("agent")
|
.WithTags("agent")
|
||||||
.WithSummary("Enroll a node with a join token")
|
.WithSummary("Enroll a node with a join token")
|
||||||
.DisableAntiforgery();
|
.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;
|
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)
|
private static async Task<IResult> HandleAsync(HttpContext http, FleetEnrollmentService service)
|
||||||
{
|
{
|
||||||
EnrollRequest? request;
|
EnrollRequest? request;
|
||||||
|
|||||||
@@ -9,13 +9,15 @@ public sealed partial class FleetEnrollmentService
|
|||||||
{
|
{
|
||||||
private readonly IFleetStore _store;
|
private readonly IFleetStore _store;
|
||||||
private readonly IEnumerable<IEnrollmentHook> _hooks;
|
private readonly IEnumerable<IEnrollmentHook> _hooks;
|
||||||
|
private readonly IEnumerable<IOperatorTrustSource> _trust;
|
||||||
private readonly FleetServerOptions _options;
|
private readonly FleetServerOptions _options;
|
||||||
private readonly TimeProvider _time;
|
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;
|
_store = store;
|
||||||
_hooks = hooks;
|
_hooks = hooks;
|
||||||
|
_trust = trust;
|
||||||
_options = options;
|
_options = options;
|
||||||
_time = time;
|
_time = time;
|
||||||
}
|
}
|
||||||
@@ -29,9 +31,16 @@ public sealed partial class FleetEnrollmentService
|
|||||||
|
|
||||||
if (!TryDecode(request.PublicKey, out byte[] publicKey) || !NodeIdentity.IsValidPublicKey(publicKey)
|
if (!TryDecode(request.PublicKey, out byte[] publicKey) || !NodeIdentity.IsValidPublicKey(publicKey)
|
||||||
|| !TryDecode(request.TlsSpki, out byte[] tlsSpki) || tlsSpki.Length != 32
|
|| !TryDecode(request.TlsSpki, out byte[] tlsSpki) || tlsSpki.Length != 32
|
||||||
|
|| !TryDecode(request.TlsPublicKey, out byte[] tlsPublicKey) || !NodeIdentity.IsValidPublicKey(tlsPublicKey)
|
||||||
|| !TryDecode(request.Proof, out byte[] proof))
|
|| !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);
|
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;
|
bool nameTaken = await _store.FindEstablishedNodeByNameAsync(request.Name, ct) is not null;
|
||||||
NodeStatus status = token.AutoApprove && !nameTaken ? NodeStatus.Active : NodeStatus.Pending;
|
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(
|
var node = new NewNode(
|
||||||
new NodeId(Ulid.NewPrefixed(_options.NodeIdPrefix, _time)),
|
new NodeId(Ulid.NewPrefixed(prefix, _time)),
|
||||||
request.Name,
|
request.Name,
|
||||||
status,
|
status,
|
||||||
publicKey,
|
publicKey,
|
||||||
@@ -70,7 +80,10 @@ public sealed partial class FleetEnrollmentService
|
|||||||
token.Labels,
|
token.Labels,
|
||||||
request.Meta?.GetRawText(),
|
request.Meta?.GetRawText(),
|
||||||
request.NodeVersion,
|
request.NodeVersion,
|
||||||
nowMs);
|
nowMs,
|
||||||
|
token.Kind,
|
||||||
|
tlsPublicKey,
|
||||||
|
token.CreatedBy);
|
||||||
|
|
||||||
Result<NodeRecord> created = await _store.CreateNodeAsync(node, ct);
|
Result<NodeRecord> created = await _store.CreateNodeAsync(node, ct);
|
||||||
if (!created.TryGet(out NodeRecord? record, out FleetError? error))
|
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)
|
private static bool TryDecode(string text, out byte[] data)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
|
|||||||
IEnumerable<IStateProvider> stateProviders,
|
IEnumerable<IStateProvider> stateProviders,
|
||||||
IEnumerable<IInbox> inboxes,
|
IEnumerable<IInbox> inboxes,
|
||||||
IEnumerable<INodeEventSink> sinks,
|
IEnumerable<INodeEventSink> sinks,
|
||||||
FleetRouter router)
|
FleetRouter router,
|
||||||
|
IServerIdentity? serverIdentity = null)
|
||||||
{
|
{
|
||||||
|
ServerIdentity = serverIdentity;
|
||||||
Store = store;
|
Store = store;
|
||||||
Options = options;
|
Options = options;
|
||||||
Time = time;
|
Time = time;
|
||||||
@@ -33,10 +35,14 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
|
|||||||
Inboxes = inboxes.ToDictionary(i => i.Channel);
|
Inboxes = inboxes.ToDictionary(i => i.Channel);
|
||||||
Sinks = [.. sinks];
|
Sinks = [.. sinks];
|
||||||
Router = router.Inner;
|
Router = router.Inner;
|
||||||
|
RoleOf = router.RoleOf;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal IFleetStore Store { get; }
|
internal IFleetStore Store { get; }
|
||||||
|
|
||||||
|
/// <summary>服务器的身份密钥;没有时节点连接用旧的明文认证。</summary>
|
||||||
|
internal IServerIdentity? ServerIdentity { get; }
|
||||||
|
|
||||||
internal FleetServerOptions Options { get; }
|
internal FleetServerOptions Options { get; }
|
||||||
|
|
||||||
internal TimeProvider Time { get; }
|
internal TimeProvider Time { get; }
|
||||||
@@ -51,6 +57,8 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
|
|||||||
|
|
||||||
internal RpcRouter<NodeCallContext> Router { get; }
|
internal RpcRouter<NodeCallContext> Router { get; }
|
||||||
|
|
||||||
|
internal Func<NodeRecord, string?>? RoleOf { get; }
|
||||||
|
|
||||||
public IReadOnlyCollection<NodeId> OnlineNodes => [.. _connections.Keys];
|
public IReadOnlyCollection<NodeId> OnlineNodes => [.. _connections.Keys];
|
||||||
|
|
||||||
public bool IsOnline(NodeId node) => _connections.ContainsKey(node);
|
public bool IsOnline(NodeId node) => _connections.ContainsKey(node);
|
||||||
@@ -161,10 +169,18 @@ public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
|
|||||||
/// <summary>产品注册的 RPC 方法路由。</summary>
|
/// <summary>产品注册的 RPC 方法路由。</summary>
|
||||||
public sealed class FleetRouter
|
public sealed class FleetRouter
|
||||||
{
|
{
|
||||||
public FleetRouter(RpcRouter<NodeCallContext> inner)
|
public FleetRouter(RpcRouter<NodeCallContext> inner, Func<NodeRecord, string?>? roleOf)
|
||||||
{
|
{
|
||||||
Inner = inner;
|
Inner = inner;
|
||||||
|
RoleOf = roleOf;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal RpcRouter<NodeCallContext> Inner { get; }
|
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}")]
|
[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);
|
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}")]
|
[LoggerMessage(Level = LogLevel.Error, Message = "unhandled error in handler method={Method}")]
|
||||||
public static partial void HandlerFailed(ILogger logger, Exception exception, string method);
|
public static partial void HandlerFailed(ILogger logger, Exception exception, string method);
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ namespace Fleet.Server;
|
|||||||
public sealed class FleetServerBuilder
|
public sealed class FleetServerBuilder
|
||||||
{
|
{
|
||||||
private readonly List<Action<RpcRouter<NodeCallContext>, IServiceProvider>> _routeConfigurators = [];
|
private readonly List<Action<RpcRouter<NodeCallContext>, IServiceProvider>> _routeConfigurators = [];
|
||||||
|
private MethodTable? _methods;
|
||||||
|
private Func<NodeRecord, string?>? _roleOf;
|
||||||
|
|
||||||
internal FleetServerBuilder(IServiceCollection services)
|
internal FleetServerBuilder(IServiceCollection services)
|
||||||
{
|
{
|
||||||
@@ -22,6 +24,21 @@ public sealed class FleetServerBuilder
|
|||||||
|
|
||||||
internal IReadOnlyList<Action<RpcRouter<NodeCallContext>, IServiceProvider>> RouteConfigurators => _routeConfigurators;
|
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>()
|
public FleetServerBuilder AddState<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TProvider>()
|
||||||
where TProvider : class, IStateProvider
|
where TProvider : class, IStateProvider
|
||||||
{
|
{
|
||||||
@@ -71,13 +88,18 @@ public static class FleetServerExtensions
|
|||||||
services.AddSingleton(provider =>
|
services.AddSingleton(provider =>
|
||||||
{
|
{
|
||||||
ILogger logger = provider.GetRequiredService<ILoggerFactory>().CreateLogger("Fleet.Rpc");
|
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)
|
foreach (Action<RpcRouter<NodeCallContext>, IServiceProvider> configurator in builder.RouteConfigurators)
|
||||||
{
|
{
|
||||||
configurator(router, provider);
|
configurator(router, provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new FleetRouter(router);
|
return new FleetRouter(router, roleOf);
|
||||||
});
|
});
|
||||||
services.AddSingleton(provider => new FleetLinkHost(
|
services.AddSingleton(provider => new FleetLinkHost(
|
||||||
provider.GetRequiredService<IFleetStore>(),
|
provider.GetRequiredService<IFleetStore>(),
|
||||||
@@ -87,7 +109,8 @@ public static class FleetServerExtensions
|
|||||||
provider.GetServices<IStateProvider>(),
|
provider.GetServices<IStateProvider>(),
|
||||||
provider.GetServices<IInbox>(),
|
provider.GetServices<IInbox>(),
|
||||||
provider.GetServices<INodeEventSink>(),
|
provider.GetServices<INodeEventSink>(),
|
||||||
provider.GetRequiredService<FleetRouter>()));
|
provider.GetRequiredService<FleetRouter>(),
|
||||||
|
provider.GetService<IServerIdentity>()));
|
||||||
services.AddSingleton<IFleetConnections>(provider => provider.GetRequiredService<FleetLinkHost>());
|
services.AddSingleton<IFleetConnections>(provider => provider.GetRequiredService<FleetLinkHost>());
|
||||||
services.AddSingleton<IStateNotifier>(provider => provider.GetRequiredService<FleetLinkHost>());
|
services.AddSingleton<IStateNotifier>(provider => provider.GetRequiredService<FleetLinkHost>());
|
||||||
return builder;
|
return builder;
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
_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.Collections.Concurrent;
|
||||||
using System.Net.WebSockets;
|
using System.Net.WebSockets;
|
||||||
using System.Security.Cryptography;
|
|
||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using System.Text.Json;
|
|
||||||
using Fleet.Core;
|
using Fleet.Core;
|
||||||
using Fleet.Identity;
|
using Fleet.Identity;
|
||||||
using Fleet.Rpc;
|
using Fleet.Rpc;
|
||||||
@@ -14,7 +11,7 @@ namespace Fleet.Server;
|
|||||||
/// 一个节点的 WebSocket 会话:挑战 → 签名认证 → hello → 状态推送 / 收件箱 / 产品 RPC。
|
/// 一个节点的 WebSocket 会话:挑战 → 签名认证 → hello → 状态推送 / 收件箱 / 产品 RPC。
|
||||||
/// 收到的消息按顺序处理,所以产品的处理函数不能等待“向同一个节点发起的请求”,否则会死锁。
|
/// 收到的消息按顺序处理,所以产品的处理函数不能等待“向同一个节点发起的请求”,否则会死锁。
|
||||||
/// </summary>
|
/// </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_authTimeout = TimeSpan.FromSeconds(15);
|
||||||
private static readonly TimeSpan s_presenceRefresh = TimeSpan.FromSeconds(60);
|
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 Channel<bool> _stateSignal = Channel.CreateBounded<bool>(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite });
|
||||||
private readonly Dictionary<string, long> _lastPushedVersion = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, long> _lastPushedVersion = new(StringComparer.Ordinal);
|
||||||
private readonly CancellationTokenSource _lifetime = new();
|
private readonly CancellationTokenSource _lifetime = new();
|
||||||
|
private LinkCipher? _cipher;
|
||||||
private long _nextRequestId;
|
private long _nextRequestId;
|
||||||
private long _lastPresenceTicks;
|
private long _lastPresenceTicks;
|
||||||
private NodeRecord? _node;
|
private NodeRecord? _node;
|
||||||
private NodeSessionInfo? _session;
|
private NodeSessionInfo? _session;
|
||||||
private string? _closeReason;
|
private string? _closeReason;
|
||||||
private bool _helloDone;
|
private bool _helloDone;
|
||||||
|
private int _violations;
|
||||||
|
|
||||||
public NodeConnection(FleetLinkHost host, WebSocket socket)
|
public NodeConnection(FleetLinkHost host, WebSocket socket)
|
||||||
{
|
{
|
||||||
@@ -137,593 +136,8 @@ internal sealed class NodeConnection : IDisposable
|
|||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
_cipher?.Dispose();
|
||||||
_lifetime.Dispose();
|
_lifetime.Dispose();
|
||||||
_sendLock.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;
|
|
||||||
}
|
}
|
||||||
@@ -67,7 +67,7 @@ public sealed class IdentityAndRpcTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Router_DispatchesTypedRequests_AndReportsStructuredErrors()
|
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,
|
.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 })))
|
(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"));
|
.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()
|
public async Task Router_HandlerExceptions_AreContained_AndNeverLeakDetails()
|
||||||
{
|
{
|
||||||
string? logged = null;
|
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"));
|
.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))!;
|
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()
|
public async Task Router_Notifications_ProduceNoResponse_EvenOnFailure()
|
||||||
{
|
{
|
||||||
int calls = 0;
|
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++;
|
calls++;
|
||||||
return ValueTask.CompletedTask;
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -227,8 +227,9 @@ public sealed class LinkTests
|
|||||||
{
|
{
|
||||||
(TlsFrameTransport left, TlsFrameTransport right) = await PairAsync();
|
(TlsFrameTransport left, TlsFrameTransport right) = await PairAsync();
|
||||||
RpcSession<string>? rightSession = null;
|
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 })));
|
MethodTable table = TestTables.Of(TestTables.Req("left.double", RpcSide.Listener), TestTables.Req("right.relay"), TestTables.Req("right.unhandled"));
|
||||||
var rightRouter = new RpcRouter<string>().On(
|
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,
|
"right.relay", LinkJson.Default.Number, LinkJson.Default.Number,
|
||||||
async (_, p, ct) =>
|
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);
|
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);
|
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);
|
Assert.Equal("fleet_unsupported", unknown.Error.Code.Value);
|
||||||
|
|
||||||
await leftSession.CloseAsync("bye", default);
|
await leftSession.CloseAsync("bye", default);
|
||||||
@@ -262,13 +263,13 @@ public sealed class LinkTests
|
|||||||
{
|
{
|
||||||
(TlsFrameTransport left, TlsFrameTransport right) = await PairAsync();
|
(TlsFrameTransport left, TlsFrameTransport right) = await PairAsync();
|
||||||
await using var silent = right; // 对端不读也不回
|
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);
|
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);
|
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);
|
await silent.CloseAsync("gone", default);
|
||||||
Assert.Equal(FleetErrors.Timeout, (await pending.WaitAsync(TimeSpan.FromSeconds(5))).Error.Code);
|
Assert.Equal(FleetErrors.Timeout, (await pending.WaitAsync(TimeSpan.FromSeconds(5))).Error.Code);
|
||||||
await run.WaitAsync(TimeSpan.FromSeconds(5));
|
await run.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
@@ -279,12 +280,20 @@ public sealed class LinkTests
|
|||||||
private sealed class PeerFixture : IAsyncDisposable
|
private sealed class PeerFixture : IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly CancellationTokenSource _stop = new();
|
private readonly CancellationTokenSource _stop = new();
|
||||||
|
private readonly NodeIdentity _identity;
|
||||||
|
|
||||||
public PeerFixture(Func<string, byte[]?> lookup, Func<PeerSessionInfo, IRpcTransport, CancellationToken, Task>? onSession = null)
|
public PeerFixture(Func<string, byte[]?> lookup, Func<PeerSessionInfo, IRpcTransport, CancellationToken, Task>? onSession = null)
|
||||||
{
|
{
|
||||||
Certificate = NodeCertificate.LoadPfx(NodeCertificate.CreateSelfSignedPfx("agent", TimeSpan.FromDays(30), TimeProvider.System));
|
Certificate = NodeCertificate.LoadPfx(NodeCertificate.CreateSelfSignedPfx("agent", TimeSpan.FromDays(30), TimeProvider.System));
|
||||||
|
_identity = NodeIdentity.FromPkcs8(Certificate.GetECDsaPrivateKey()!.ExportPkcs8PrivateKey());
|
||||||
Listener = new PeerListener(
|
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)),
|
onSession ?? ((_, transport, ct) => KeepOpenAsync(transport, ct)),
|
||||||
NullNodeLog.Instance, TimeProvider.System);
|
NullNodeLog.Instance, TimeProvider.System);
|
||||||
Task = Listener.RunAsync(_stop.Token);
|
Task = Listener.RunAsync(_stop.Token);
|
||||||
@@ -298,10 +307,17 @@ public sealed class LinkTests
|
|||||||
|
|
||||||
public byte[] Spki => NodeCertificate.SpkiFingerprint(Certificate);
|
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()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
await _stop.CancelAsync();
|
await _stop.CancelAsync();
|
||||||
await Task;
|
await Task;
|
||||||
|
_identity.Dispose();
|
||||||
Certificate.Dispose();
|
Certificate.Dispose();
|
||||||
_stop.Dispose();
|
_stop.Dispose();
|
||||||
}
|
}
|
||||||
@@ -324,9 +340,10 @@ public sealed class LinkTests
|
|||||||
seen.TrySetResult(info);
|
seen.TrySetResult(info);
|
||||||
return Task.Delay(Timeout.Infinite, ct).ContinueWith(_ => { }, CancellationToken.None);
|
return Task.Delay(Timeout.Infinite, ct).ContinueWith(_ => { }, CancellationToken.None);
|
||||||
});
|
});
|
||||||
|
agent.KnockPeers.Add(probe.PublicKeySpki);
|
||||||
await agent.Listener.Started.Task;
|
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)
|
await using (transport)
|
||||||
{
|
{
|
||||||
PeerSessionInfo server = await seen.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
PeerSessionInfo server = await seen.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
@@ -342,17 +359,18 @@ public sealed class LinkTests
|
|||||||
using NodeIdentity probe = NodeIdentity.Generate();
|
using NodeIdentity probe = NodeIdentity.Generate();
|
||||||
using NodeIdentity impostor = NodeIdentity.Generate();
|
using NodeIdentity impostor = NodeIdentity.Generate();
|
||||||
await using var agent = new PeerFixture(id => id == "prb_1" ? probe.PublicKeySpki : null);
|
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;
|
await agent.Listener.Started.Task;
|
||||||
|
|
||||||
InvalidOperationException unknown = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
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 () =>
|
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.Equal(unknown.Message, forged.Message);
|
||||||
Assert.Contains("fleet_unauthorized", 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();
|
await ok.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,18 +379,21 @@ public sealed class LinkTests
|
|||||||
{
|
{
|
||||||
using NodeIdentity probe = NodeIdentity.Generate();
|
using NodeIdentity probe = NodeIdentity.Generate();
|
||||||
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
|
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
|
||||||
|
agent.KnockPeers.Add(probe.PublicKeySpki);
|
||||||
await agent.Listener.Started.Task;
|
await agent.Listener.Started.Task;
|
||||||
|
|
||||||
byte[] wrong = SHA256.HashData("another certificate"u8);
|
// 公钥不对:敲门密钥对不上,Agent 直接断开(拿到的不是证书,连不是它的证书都看不到)
|
||||||
await Assert.ThrowsAnyAsync<AuthenticationException>(async () =>
|
using NodeIdentity another = NodeIdentity.Generate();
|
||||||
await PeerConnector.ConnectAsync("127.0.0.1", agent.Listener.Port, wrong, probe, "prb_1", null, 1 << 20, TimeProvider.System, default));
|
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]
|
[Fact]
|
||||||
public async Task PeerSession_CarriesRpcBothWays_AfterTheHandshake()
|
public async Task PeerSession_CarriesRpcBothWays_AfterTheHandshake()
|
||||||
{
|
{
|
||||||
using NodeIdentity probe = NodeIdentity.Generate();
|
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(
|
await using var agent = new PeerFixture(
|
||||||
_ => probe.PublicKeySpki,
|
_ => probe.PublicKeySpki,
|
||||||
async (info, transport, ct) =>
|
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 using var session = new RpcSession<string>(transport, router, info.PeerId, TimeProvider.System);
|
||||||
await session.RunAsync(ct);
|
await session.RunAsync(ct);
|
||||||
});
|
});
|
||||||
|
agent.KnockPeers.Add(probe.PublicKeySpki);
|
||||||
await agent.Listener.Started.Task;
|
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);
|
(_, 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>(), "probe", TimeProvider.System);
|
await using var client = new RpcSession<string>(transport, new RpcRouter<string>(RpcSide.Dialer, table), "probe", TimeProvider.System);
|
||||||
Task run = client.RunAsync(default);
|
Task run = client.RunAsync(default);
|
||||||
Result<Number> result = await client.RequestAsync("agent.echo", new Number { Value = 41 }, LinkJson.Default.Number, LinkJson.Default.Number, 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);
|
Assert.Equal(42, result.Value.Value);
|
||||||
@@ -396,6 +418,7 @@ public sealed class LinkTests
|
|||||||
{
|
{
|
||||||
using NodeIdentity probe = NodeIdentity.Generate();
|
using NodeIdentity probe = NodeIdentity.Generate();
|
||||||
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
|
await using var agent = new PeerFixture(_ => probe.PublicKeySpki);
|
||||||
|
agent.KnockPeers.Add(probe.PublicKeySpki);
|
||||||
await agent.Listener.Started.Task;
|
await agent.Listener.Started.Task;
|
||||||
|
|
||||||
// 不说 TLS 的连接:握手失败,监听继续
|
// 不说 TLS 的连接:握手失败,监听继续
|
||||||
@@ -405,7 +428,45 @@ public sealed class LinkTests
|
|||||||
await garbage.GetStream().WriteAsync("GET / HTTP/1.1\r\n\r\n"u8.ToArray());
|
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();
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -131,7 +131,13 @@ public sealed class RestartScheduleTests
|
|||||||
Assert.DoesNotContain(log.Lines, l => l.Contains("scheduled_restart", StringComparison.Ordinal));
|
Assert.DoesNotContain(log.Lines, l => l.Contains("scheduled_restart", StringComparison.Ordinal));
|
||||||
// 清理:把进程停掉
|
// 清理:把进程停掉
|
||||||
Assert.True(controller.SetRemote("04:30+08:00"));
|
Assert.True(controller.SetRemote("04:30+08:00"));
|
||||||
|
// 新的计时器是异步注册的:反复推进,直到进程退出
|
||||||
|
for (int i = 0; i < 100 && !shell.IsCompleted; i++)
|
||||||
|
{
|
||||||
time.Advance();
|
time.Advance();
|
||||||
|
await Task.Delay(50);
|
||||||
|
}
|
||||||
|
|
||||||
await shell.WaitAsync(TimeSpan.FromSeconds(10));
|
await shell.WaitAsync(TimeSpan.FromSeconds(10));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +12,7 @@ string? name = Arg("--name");
|
|||||||
string? directory = Arg("--identity-dir");
|
string? directory = Arg("--identity-dir");
|
||||||
if (server is null || join is null || name is null || directory is null)
|
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;
|
return 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,14 +30,17 @@ if (Arg("--meta") is { } metaText)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
string? genesis = Arg("--genesis");
|
||||||
|
string[] controllers = (Arg("--controllers") ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
|
||||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
|
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
|
||||||
Result<EnrollmentResult> result = await EnrollmentClient.EnrollAsync(
|
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))
|
if (!result.TryGet(out EnrollmentResult? enrolled, out FleetError? error))
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"enrollment failed ({error.Code}): {error.Message}");
|
Console.Error.WriteLine($"enrollment failed ({error.Code}): {error.Message}");
|
||||||
return 1;
|
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;
|
return 0;
|
||||||
+1548
-266
File diff suppressed because it is too large.
Load diff
@@ -11,7 +11,7 @@ Backend ──任务──> Agent <──测量会话──> Probe
|
|||||||
└─────Agent 列表、状态(Fleet 长连接)───────────┘
|
└─────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 运行。
|
容器节点的运行方式(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
|
```sh
|
||||||
moshiro-probe --config /etc/moshiro/probe.json
|
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 TimeSpan s_clockInterval = TimeSpan.FromSeconds(30);
|
||||||
private static readonly RpcRouter<ConnectionSession> s_router = BuildRouter();
|
private static readonly RpcRouter<ConnectionSession> s_router = BuildRouter();
|
||||||
|
|
||||||
|
/// <summary>对等面入站方法的路由器(只读,测试用)。</summary>
|
||||||
|
public static RpcRouter<ConnectionSession> Router => s_router;
|
||||||
|
|
||||||
private readonly ConnectionEnv _env;
|
private readonly ConnectionEnv _env;
|
||||||
private volatile ConnectionSession? _session;
|
private volatile ConnectionSession? _session;
|
||||||
|
|
||||||
@@ -86,8 +89,9 @@ public sealed class AgentConnection
|
|||||||
throw new SocketException((int)SocketError.HostNotFound);
|
throw new SocketException((int)SocketError.HostNotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
byte[] agentKey = Convert.FromBase64String(Endpoint.PublicKey);
|
||||||
(PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync(
|
(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 remote = (IPEndPoint)info.Remote!;
|
||||||
var data = new DataSession(info.Credentials.SessionIdValue, info.Credentials.Key);
|
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, []);
|
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,
|
Session = () => data,
|
||||||
Udp = udp,
|
Udp = udp,
|
||||||
PeerAddress = remote.Address,
|
PeerAddress = remote.Address,
|
||||||
PeerSpkiSha256 = Convert.FromBase64String(Endpoint.Spki),
|
PeerSpkiSha256 = NodeIdentity.Fingerprint(agentKey),
|
||||||
|
KnockKey = Knock.DeriveKey(_env.Identity, agentKey),
|
||||||
Fragments = shipper,
|
Fragments = shipper,
|
||||||
MaxPacketsPerSecond = _env.Limits.MaxTotalPps,
|
MaxPacketsPerSecond = _env.Limits.MaxTotalPps,
|
||||||
MaxBulkMbps = _env.Limits.MaxTestMbps,
|
MaxBulkMbps = _env.Limits.MaxTestMbps,
|
||||||
@@ -191,7 +196,7 @@ public sealed class AgentConnection
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static RpcRouter<ConnectionSession> BuildRouter() =>
|
private static RpcRouter<ConnectionSession> BuildRouter() =>
|
||||||
new RpcRouter<ConnectionSession>()
|
new RpcRouter<ConnectionSession>(RpcSide.Dialer, MoShiroMethodTable.Table)
|
||||||
.On(
|
.On(
|
||||||
FleetMethods.StateSync,
|
FleetMethods.StateSync,
|
||||||
FleetRpcContext.Default.StateSyncParams,
|
FleetRpcContext.Default.StateSyncParams,
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ public sealed class AgentDirectory
|
|||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
private readonly ConfigStore _store;
|
private readonly ConfigStore _store;
|
||||||
private readonly INodeLog _log;
|
private readonly INodeLog _log;
|
||||||
|
private readonly TrustAnchor _trust;
|
||||||
private List<KnownAgent> _agents = [];
|
private List<KnownAgent> _agents = [];
|
||||||
private long _version;
|
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;
|
_store = store;
|
||||||
_log = log;
|
_log = log;
|
||||||
string cache = Path.Combine(configDirectory, FileName);
|
string cache = Path.Combine(configDirectory, FileName);
|
||||||
@@ -68,16 +70,17 @@ public sealed class AgentDirectory
|
|||||||
public void Apply(ProbeAgentList result)
|
public void Apply(ProbeAgentList result)
|
||||||
{
|
{
|
||||||
var next = new List<KnownAgent>();
|
var next = new List<KnownAgent>();
|
||||||
|
_trust.Update(result.TrustChain);
|
||||||
foreach (AgentEndpoint agent in result.Agents)
|
foreach (AgentEndpoint agent in result.Agents)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(agent.Host))
|
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))
|
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)
|
lock (_gate)
|
||||||
@@ -108,5 +111,5 @@ public sealed class AgentDirectory
|
|||||||
a.Count == b.Count && a.Zip(b).All(p => Equal(p.First, p.Second));
|
a.Count == b.Count && a.Zip(b).All(p => Equal(p.First, p.Second));
|
||||||
|
|
||||||
public static bool Equal(KnownAgent a, KnownAgent b) =>
|
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;
|
||||||
}
|
}
|
||||||
@@ -23,20 +23,22 @@ public sealed class ProbeApp : IDisposable
|
|||||||
private readonly INodeLog _log;
|
private readonly INodeLog _log;
|
||||||
private readonly FleetLink _link;
|
private readonly FleetLink _link;
|
||||||
|
|
||||||
public ProbeApp(ProbeFile file, string configDirectory, TimeProvider time, INodeLog log, RestartController? restart = null)
|
/// <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;
|
_log = log;
|
||||||
var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log);
|
var trust = TrustAnchor.Load(file.Fleet.IdentityDir, log);
|
||||||
NodeIdentity identity = NodeIdentity.FromPkcs8(Convert.FromBase64String(file.PrivateKey));
|
var directory = new AgentDirectory(new ConfigStore(configDirectory), configDirectory, log, trust);
|
||||||
_env = new ConnectionEnv(identity, file.ProbeId, file.Limits, directory, new MonoClock(time), time, log);
|
_env = new ConnectionEnv(identity.PeerIdentity, identity.NodeId.Value, file.Limits, directory, new MonoClock(time), time, log);
|
||||||
directory.Changed += () => _changed.Release();
|
directory.Changed += () => _changed.Release();
|
||||||
|
|
||||||
var update = new UpdateBridge(configDirectory, new ConfigStore(configDirectory).Write, log);
|
var update = new UpdateBridge(configDirectory, new ConfigStore(configDirectory).Write, log);
|
||||||
var options = new FleetLinkOptions
|
var options = new FleetLinkOptions
|
||||||
{
|
{
|
||||||
Server = new Uri(file.Server),
|
Server = new Uri(file.Fleet.Server),
|
||||||
Identity = identity,
|
Identity = identity.Identity,
|
||||||
NodeId = new NodeId(file.ProbeId),
|
ServerVerifier = trust,
|
||||||
|
NodeId = identity.NodeId,
|
||||||
NodeVersion = Version,
|
NodeVersion = Version,
|
||||||
Capabilities = [MoShiroCapabilities.Base],
|
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),
|
HeartbeatExtra = () => JsonSerializer.SerializeToElement(new ProbeHeartbeatExtra { Update = update.ReadStatus(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHeartbeatExtra),
|
||||||
@@ -50,7 +52,7 @@ public sealed class ProbeApp : IDisposable
|
|||||||
directory.Apply(doc);
|
directory.Apply(doc);
|
||||||
return ValueTask.FromResult(new StateSyncResult { AppliedVersion = version });
|
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, _) =>
|
.OnNotify(MoShiroMethods.UpdateOffer, MoShiroJsonContext.Default.UpdateOfferParams, (_, offer, _) =>
|
||||||
{
|
{
|
||||||
update.WriteOffer(offer);
|
update.WriteOffer(offer);
|
||||||
|
|||||||
@@ -4,21 +4,13 @@ using MoShiro.Protocol;
|
|||||||
namespace MoShiro.Probe;
|
namespace MoShiro.Probe;
|
||||||
|
|
||||||
/// <summary>
|
/// <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 成为瓶颈、反过来影响测量。
|
/// <c>limits</c> 是本地硬上限,面板无法修改;在路由器上建议调低,避免 Probe 自己的 CPU 成为瓶颈、反过来影响测量。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ProbeFile
|
public sealed class ProbeFile
|
||||||
{
|
{
|
||||||
[JsonPropertyName("probe_id")]
|
[JsonPropertyName("fleet")]
|
||||||
public string ProbeId { get; set; } = "";
|
public ProbeFleetSection Fleet { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>PKCS#8,base64。</summary>
|
|
||||||
[JsonPropertyName("private_key")]
|
|
||||||
public string PrivateKey { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>面板的地址(http 或 https)。Probe 直接连它:身份、Agent 列表、更新都从这里来。</summary>
|
|
||||||
[JsonPropertyName("server")]
|
|
||||||
public string Server { get; set; } = "";
|
|
||||||
|
|
||||||
/// <summary>每天定时重启一次,例如 <c>04:00+08:00</c>(不写偏移按 UTC);不写就不重启。环境变量 <c>MOSHIRO_RESTART_AT</c> 优先;这里是本地配置,从面板重新下载 probe.json 会丢掉它,所以容器里推荐用环境变量。</summary>
|
/// <summary>每天定时重启一次,例如 <c>04:00+08:00</c>(不写偏移按 UTC);不写就不重启。环境变量 <c>MOSHIRO_RESTART_AT</c> 优先;这里是本地配置,从面板重新下载 probe.json 会丢掉它,所以容器里推荐用环境变量。</summary>
|
||||||
[JsonPropertyName("restart_at")]
|
[JsonPropertyName("restart_at")]
|
||||||
@@ -28,6 +20,17 @@ public sealed class ProbeFile
|
|||||||
public ProbeLimits Limits { get; set; } = new();
|
public ProbeLimits Limits { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class ProbeFleetSection
|
||||||
|
{
|
||||||
|
/// <summary>面板的地址(http 或 https)。Probe 直接连它:身份、Agent 列表、更新都从这里来。</summary>
|
||||||
|
[JsonPropertyName("server")]
|
||||||
|
public string Server { get; set; } = "";
|
||||||
|
|
||||||
|
/// <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";
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class KnownAgent
|
public sealed class KnownAgent
|
||||||
{
|
{
|
||||||
[JsonPropertyName("ip_version")]
|
[JsonPropertyName("ip_version")]
|
||||||
@@ -45,9 +48,9 @@ public sealed class KnownAgent
|
|||||||
[JsonPropertyName("tcp_port")]
|
[JsonPropertyName("tcp_port")]
|
||||||
public int TcpPort { get; set; }
|
public int TcpPort { get; set; }
|
||||||
|
|
||||||
/// <summary>Agent 的 TLS 证书指纹(SHA-256,base64)。</summary>
|
/// <summary>Agent 的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它派生。</summary>
|
||||||
[JsonPropertyName("spki")]
|
[JsonPropertyName("public_key")]
|
||||||
public string Spki { get; set; } = "";
|
public string PublicKey { get; set; } = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ProbeLimits
|
public sealed class ProbeLimits
|
||||||
|
|||||||
@@ -21,17 +21,25 @@ for (int i = 0; i < args.Length - 1; i++)
|
|||||||
}
|
}
|
||||||
|
|
||||||
ProbeFile file;
|
ProbeFile file;
|
||||||
|
LoadedNodeIdentity identity;
|
||||||
RestartController restart;
|
RestartController restart;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
file = NodeConfig.Load(configPath, ProbeConfigContext.Default.ProbeFile);
|
file = NodeConfig.Load(configPath, ProbeConfigContext.Default.ProbeFile);
|
||||||
restart = new RestartController(RestartSchedule.Resolve(file.RestartAt));
|
restart = new RestartController(RestartSchedule.Resolve(file.RestartAt));
|
||||||
if (file.ProbeId.Length == 0 || file.PrivateKey.Length == 0 || !Uri.TryCreate(file.Server, UriKind.Absolute, out _))
|
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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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));
|
log.Error("startup failed", ("err", ex.Message));
|
||||||
return 1;
|
return 1;
|
||||||
@@ -39,15 +47,10 @@ catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidO
|
|||||||
|
|
||||||
using var restartScope = restart;
|
using var restartScope = restart;
|
||||||
string directory = Path.GetDirectoryName(Path.GetFullPath(configPath))!;
|
string directory = Path.GetDirectoryName(Path.GetFullPath(configPath))!;
|
||||||
try
|
using (identity)
|
||||||
{
|
{
|
||||||
using var app = new ProbeApp(file, directory, time, log, restart);
|
using var app = new ProbeApp(file, identity, directory, time, log, restart);
|
||||||
await ServiceShell.RunAsync(app.RunAsync, log, restart, time);
|
await ServiceShell.RunAsync(app.RunAsync, log, restart, time);
|
||||||
}
|
}
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
@@ -58,7 +58,7 @@ public sealed class ProbeTests
|
|||||||
private static ProbeAgentList List(long version, params string[] ids) => new()
|
private static ProbeAgentList List(long version, params string[] ids) => new()
|
||||||
{
|
{
|
||||||
Version = version,
|
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]
|
[Fact]
|
||||||
|
|||||||
@@ -217,6 +217,9 @@ public sealed class BlockEnv
|
|||||||
/// <summary>拨号方:对端 TLS 证书的 SPKI 指纹;null 表示不校验证书(目标任务,目标是任意地址)。</summary>
|
/// <summary>拨号方:对端 TLS 证书的 SPKI 指纹;null 表示不校验证书(目标任务,目标是任意地址)。</summary>
|
||||||
public byte[]? PeerSpkiSha256 { get; init; }
|
public byte[]? PeerSpkiSha256 { get; init; }
|
||||||
|
|
||||||
|
/// <summary>拨号方:和对端(Agent)的配对敲门密钥;每条 TCP 连接建立后先发敲门包。null 表示对端不要求敲门(目标任务的任意地址)。</summary>
|
||||||
|
public byte[]? KnockKey { get; init; }
|
||||||
|
|
||||||
/// <summary>拨号方:列块出口。</summary>
|
/// <summary>拨号方:列块出口。</summary>
|
||||||
public IFragmentSink? Fragments { get; init; }
|
public IFragmentSink? Fragments { get; init; }
|
||||||
|
|
||||||
@@ -241,6 +244,7 @@ public sealed class BlockEnv
|
|||||||
TcpListen = TcpListen,
|
TcpListen = TcpListen,
|
||||||
PeerAddress = PeerAddress,
|
PeerAddress = PeerAddress,
|
||||||
PeerSpkiSha256 = PeerSpkiSha256,
|
PeerSpkiSha256 = PeerSpkiSha256,
|
||||||
|
KnockKey = KnockKey,
|
||||||
Fragments = Fragments,
|
Fragments = Fragments,
|
||||||
MaxBulkMbps = MaxBulkMbps,
|
MaxBulkMbps = MaxBulkMbps,
|
||||||
MaxPacketsPerSecond = MaxPacketsPerSecond,
|
MaxPacketsPerSecond = MaxPacketsPerSecond,
|
||||||
|
|||||||
@@ -6,5 +6,6 @@
|
|||||||
<InternalsVisibleTo Include="MoShiro.Measurement.Tests" />
|
<InternalsVisibleTo Include="MoShiro.Measurement.Tests" />
|
||||||
<AdditionalFiles Include="$(MSBuildThisFileDirectory)../../BannedSymbols.Node.txt" />
|
<AdditionalFiles Include="$(MSBuildThisFileDirectory)../../BannedSymbols.Node.txt" />
|
||||||
<ProjectReference Include="..\MoShiro.Protocol\MoShiro.Protocol.csproj" />
|
<ProjectReference Include="..\MoShiro.Protocol\MoShiro.Protocol.csproj" />
|
||||||
|
<ProjectReference Include="..\..\fleet\src\Fleet.Identity\Fleet.Identity.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -5,6 +5,7 @@ using System.Net.Sockets;
|
|||||||
using System.Security.Authentication;
|
using System.Security.Authentication;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using Fleet.Identity;
|
||||||
using MoShiro.Protocol;
|
using MoShiro.Protocol;
|
||||||
|
|
||||||
namespace MoShiro.Measurement;
|
namespace MoShiro.Measurement;
|
||||||
@@ -46,6 +47,12 @@ public sealed class TcpLink : IDisposable
|
|||||||
await client.ConnectAsync(env.PeerAddress!, port, ct);
|
await client.ConnectAsync(env.PeerAddress!, port, ct);
|
||||||
long tcpUs = env.Clock.NowUs() - start;
|
long tcpUs = env.Clock.NowUs() - start;
|
||||||
Stream stream = client.GetStream();
|
Stream stream = client.GetStream();
|
||||||
|
if (env.KnockKey is { } knockKey)
|
||||||
|
{
|
||||||
|
// 预握手敲门:监听方不认就不会回任何字节
|
||||||
|
await stream.WriteAsync(Knock.Build(knockKey, env.Time.GetUtcNow().ToUnixTimeMilliseconds()), ct);
|
||||||
|
}
|
||||||
|
|
||||||
long? tlsUs = null;
|
long? tlsUs = null;
|
||||||
if (tls)
|
if (tls)
|
||||||
{
|
{
|
||||||
@@ -115,7 +122,7 @@ public sealed class TcpConnection
|
|||||||
public delegate Task TcpResponder(TcpConnection connection, CancellationToken ct);
|
public delegate Task TcpResponder(TcpConnection connection, CancellationToken ct);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 监听方的 TCP 数据面:每个端口一个监听器。连接的第一个字节是 0x16 就先做 TLS(节点自己的证书),否则直接是明文。
|
/// 监听方的 TCP 数据面:每个端口一个监听器。连接先过预握手敲门,然后第一个字节是 0x16 就先做 TLS(节点自己的证书),否则直接是明文。
|
||||||
/// 随后是认证首帧(<c>"MN" | ver | session_id | stream_id | nonce | mac</c>,随机数只能用一次),通过之后交给按 <c>stream_id</c> 登记的处理函数。
|
/// 随后是认证首帧(<c>"MN" | ver | session_id | stream_id | nonce | mac</c>,随机数只能用一次),通过之后交给按 <c>stream_id</c> 登记的处理函数。
|
||||||
/// 首帧在限定时间内没有到达或者校验失败,直接关闭连接并计数。
|
/// 首帧在限定时间内没有到达或者校验失败,直接关闭连接并计数。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -128,11 +135,13 @@ public sealed class TcpAcceptor
|
|||||||
private readonly X509Certificate2 _certificate;
|
private readonly X509Certificate2 _certificate;
|
||||||
private readonly IDataSessions _sessions;
|
private readonly IDataSessions _sessions;
|
||||||
private readonly DataPlaneCounters _counters;
|
private readonly DataPlaneCounters _counters;
|
||||||
|
private readonly KnockGate _knock;
|
||||||
private readonly ConcurrentDictionary<uint, TcpResponder> _handlers = new();
|
private readonly ConcurrentDictionary<uint, TcpResponder> _handlers = new();
|
||||||
private readonly List<TcpListener> _listeners = [];
|
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;
|
_bind = bind;
|
||||||
_ports = ports;
|
_ports = ports;
|
||||||
_certificate = certificate;
|
_certificate = certificate;
|
||||||
@@ -214,6 +223,13 @@ public sealed class TcpAcceptor
|
|||||||
using var authCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
using var authCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
authCts.CancelAfter(s_authTimeout);
|
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];
|
byte[] first = new byte[1];
|
||||||
if (await stream.ReadAsync(first, authCts.Token) != 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ public static class MoShiroErrors
|
|||||||
public static readonly ErrorCode CsrfFailed = new("csrf_failed");
|
public static readonly ErrorCode CsrfFailed = new("csrf_failed");
|
||||||
public static readonly ErrorCode Forbidden = new("forbidden");
|
public static readonly ErrorCode Forbidden = new("forbidden");
|
||||||
public static readonly ErrorCode SitePrivate = new("site_private");
|
public static readonly ErrorCode SitePrivate = new("site_private");
|
||||||
|
public static readonly ErrorCode ElevationRequired = new("elevation_required");
|
||||||
public static readonly ErrorCode NotFound = new("not_found");
|
public static readonly ErrorCode NotFound = new("not_found");
|
||||||
public static readonly ErrorCode AgentNotFound = new("agent_not_found");
|
public static readonly ErrorCode AgentNotFound = new("agent_not_found");
|
||||||
public static readonly ErrorCode ProbeNotFound = new("probe_not_found");
|
public static readonly ErrorCode ProbeNotFound = new("probe_not_found");
|
||||||
|
|||||||
@@ -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),
|
||||||
|
]));
|
||||||
|
}
|
||||||
@@ -149,9 +149,9 @@ public sealed class AgentEndpoint
|
|||||||
[JsonPropertyName("udp_ports")]
|
[JsonPropertyName("udp_ports")]
|
||||||
public List<int> UdpPorts { get; set; } = [];
|
public List<int> UdpPorts { get; set; } = [];
|
||||||
|
|
||||||
/// <summary>Agent 面向 Probe 的 TLS 证书指纹(SHA-256,base64)。</summary>
|
/// <summary>Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它和 Probe 的身份密钥派生。</summary>
|
||||||
[JsonPropertyName("spki")]
|
[JsonPropertyName("public_key")]
|
||||||
public string Spki { get; set; } = "";
|
public string PublicKey { get; set; } = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>期望状态文档(Fleet state type = <c>probe_agents</c>):Backend 直接下发给每个 Probe 的 Agent 列表。</summary>
|
/// <summary>期望状态文档(Fleet state type = <c>probe_agents</c>):Backend 直接下发给每个 Probe 的 Agent 列表。</summary>
|
||||||
@@ -164,6 +164,9 @@ public sealed class ProbeAgentList
|
|||||||
|
|
||||||
[JsonPropertyName("agents")]
|
[JsonPropertyName("agents")]
|
||||||
public List<AgentEndpoint> Agents { get; set; } = [];
|
public List<AgentEndpoint> Agents { get; set; } = [];
|
||||||
|
|
||||||
|
[JsonPropertyName("trust_chain")]
|
||||||
|
public List<SignedStatementDto>? TrustChain { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>注册元数据(MoShiro 定义,经 Fleet 的 <c>meta</c> 字段带上来)。</summary>
|
/// <summary>注册元数据(MoShiro 定义,经 Fleet 的 <c>meta</c> 字段带上来)。</summary>
|
||||||
@@ -323,8 +326,13 @@ public sealed class AgentHelloMeta
|
|||||||
[JsonPropertyName("platform")]
|
[JsonPropertyName("platform")]
|
||||||
public PlatformInfo? Platform { get; set; }
|
public PlatformInfo? Platform { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("tls_spki")]
|
/// <summary>Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64);面板据此得到证书指纹,并下发给 Probe 用来固定证书和派生敲门密钥。</summary>
|
||||||
public string? TlsSpki { get; set; }
|
[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")]
|
[JsonPropertyName("probe_tcp_port")]
|
||||||
public int? ProbeTcpPort { get; set; }
|
public int? ProbeTcpPort { get; set; }
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ public sealed class TaskSet
|
|||||||
/// <summary>允许连接到这个 Agent 的 Probe 公钥。</summary>
|
/// <summary>允许连接到这个 Agent 的 Probe 公钥。</summary>
|
||||||
[JsonPropertyName("peers")]
|
[JsonPropertyName("peers")]
|
||||||
public List<PeerSpec> Peers { get; set; } = [];
|
public List<PeerSpec> Peers { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>操作员集合的链(passkey 签的,创世在前):节点从固定的创世指纹验过才采用。</summary>
|
||||||
|
[JsonPropertyName("trust_chain")]
|
||||||
|
public List<SignedStatementDto>? TrustChain { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class TaskSchedule
|
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.Core;
|
||||||
|
using Fleet.Identity;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
@@ -17,56 +18,67 @@ public static class AgentEndpoints
|
|||||||
public static void Map(RouteGroupBuilder group)
|
public static void Map(RouteGroupBuilder group)
|
||||||
{
|
{
|
||||||
group.MapGet("/agents", ListAsync)
|
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)
|
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)
|
group.MapPatch("/agents/{id}", PatchAsync)
|
||||||
.Accepts<AgentPatchRequest>("application/merge-patch+json", "application/json")
|
.Accepts<AgentPatchRequest>("application/merge-patch+json", "application/json")
|
||||||
.Api("agents_update", "Update name, labels, public host or visibility (merge-patch, If-Match supported)", AgentsTag)
|
.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)))
|
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)))
|
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)))
|
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)))
|
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)
|
.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)
|
group.MapDelete("/agents/{id}", DeleteAsync)
|
||||||
.Api("agents_delete", "Delete an agent; history expires with the retention policy", AgentsTag)
|
.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)
|
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)
|
group.MapPost("/join-tokens", CreateTokenAsync)
|
||||||
.Api("join_tokens_create", "Create a join token; the plaintext is returned only once", TokensTag)
|
.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);
|
.Returns<CreatedJoinTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403)
|
||||||
|
.Requires(Capability.FleetEnroll);
|
||||||
group.MapDelete("/join-tokens/{id}", RevokeTokenAsync)
|
group.MapDelete("/join-tokens/{id}", RevokeTokenAsync)
|
||||||
.Api("join_tokens_revoke", "Revoke a join token; with purge=true delete the record for good", TokensTag)
|
.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)
|
group.MapGet("/probes", ListProbesAsync)
|
||||||
.Api("probes_list", "All probes, with the agents each one is connected to", ProbeTag).Returns<PageDto<ProbeDto>>().ProducesProblems(401, 403);
|
.Api("probes_list", "All probes, with the agents each one is connected to", ProbeTag).Returns<PageDto<ProbeDto>>().ProducesProblems(401, 403)
|
||||||
group.MapPost("/probes", CreateProbeAsync)
|
.Requires(Capability.FleetRead);
|
||||||
.Api("probes_create", "Create a probe identity; the private key is returned only once", ProbeTag)
|
|
||||||
.Returns<ProbeIdentityDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409);
|
|
||||||
group.MapGet("/probes/{id}", GetProbeAsync)
|
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)
|
group.MapPatch("/probes/{id}", PatchProbeAsync)
|
||||||
.Api("probes_update", "Rename a probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(400, 401, 403, 404, 409);
|
.Api("probes_update", "Rename a probe", ProbeTag).Returns<ProbeDto>().ProducesProblems(400, 401, 403, 404, 409)
|
||||||
group.MapPost("/probes/{id}/rotate", RotateProbeAsync)
|
.Requires(Capability.FleetWrite);
|
||||||
.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);
|
|
||||||
group.MapPost("/probes/{id}/revoke", RevokeProbeAsync)
|
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)
|
.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)
|
group.MapDelete("/probes/{id}", DeleteProbeAsync)
|
||||||
.Api("probes_delete", "Delete a probe and its lines; stored data expires with the retention policy", ProbeTag)
|
.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)
|
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)
|
.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) =>
|
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)
|
private static async Task<IResult> PatchAsync(HttpContext http, AgentService agents, AgentPatchRequest request, string id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
Result<AgentView> result = await agents.PatchAsync(
|
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))
|
if (!result.TryGet(out AgentView? view, out FleetError? error))
|
||||||
{
|
{
|
||||||
return Problems.From(http, error);
|
return Problems.From(http, error);
|
||||||
@@ -118,11 +130,23 @@ public static class AgentEndpoints
|
|||||||
private static async Task<IResult> ListTokensAsync(JoinTokenService tokens, CancellationToken ct) =>
|
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);
|
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}";
|
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;
|
||||||
|
string[] controllers = set is null ? [] : [.. request.Controllers ?? [.. set.Keys.Select(k => k.CredentialId)]];
|
||||||
|
if (set is not null && controllers.Any(c => set.Keys.All(k => k.CredentialId != c)))
|
||||||
|
{
|
||||||
|
return Problems.From(http, ValidationError.Single("controllers", "must be passkeys of the operator set"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
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))
|
if (!result.TryGet(out CreatedJoinToken? created, out FleetError? error))
|
||||||
{
|
{
|
||||||
return Problems.From(http, error);
|
return Problems.From(http, error);
|
||||||
@@ -150,19 +174,6 @@ public static class AgentEndpoints
|
|||||||
private static async Task<IResult> ListProbesAsync(ProbeService probes, CancellationToken ct) =>
|
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);
|
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) =>
|
private static async Task<IResult> GetProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct) =>
|
||||||
await probes.GetAsync(id, ct) is { } view
|
await probes.GetAsync(id, ct) is { } view
|
||||||
? TypedResults.Json(Mappers.ToDto(view), ApiJsonContext.Default.ProbeDto)
|
? 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);
|
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)
|
private static async Task<IResult> RevokeProbeAsync(HttpContext http, ProbeService probes, string id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
Result<ProbeView> result = await probes.RevokeAsync(id, 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");
|
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,86 @@ public static class AuthEndpoints
|
|||||||
public static void MapLogin(RouteGroupBuilder open)
|
public static void MapLogin(RouteGroupBuilder open)
|
||||||
{
|
{
|
||||||
open.MapPost("/auth/login", LoginAsync)
|
open.MapPost("/auth/login", LoginAsync)
|
||||||
|
.AllowsAnonymous()
|
||||||
.Api("auth_login", "Sign in with a username and password", Tag)
|
.Api("auth_login", "Sign in with a username and password", Tag)
|
||||||
.Returns<LoginResponse>().ProducesProblems(400, 401, 429);
|
.Returns<LoginResponse>().ProducesProblems(400, 401, 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)
|
public static void MapSession(RouteGroupBuilder guarded)
|
||||||
{
|
{
|
||||||
guarded.MapPost("/auth/logout", LogoutAsync)
|
guarded.MapPost("/auth/logout", LogoutAsync)
|
||||||
.Api("auth_logout", "Sign out and clear the session cookie", Tag)
|
.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/elevation", ListElevation)
|
||||||
|
.Api("auth_elevation_list", "The elevation windows this session has open (sensitive actions need one)", Tag)
|
||||||
|
.Returns<PageDto<ElevationDto>>().ProducesProblems(401)
|
||||||
|
.Requires(Capability.Authenticated);
|
||||||
|
guarded.MapPost("/auth/elevation/options", ElevationOptionsAsync)
|
||||||
|
.Api("auth_elevation_options", "A challenge for opening an elevation window with a passkey touch", Tag)
|
||||||
|
.Returns<PasskeyRequestOptionsDto>().ProducesProblems(400, 401, 403, 429)
|
||||||
|
.Requires(Capability.Authenticated);
|
||||||
|
guarded.MapPost("/auth/elevation", ElevateAsync)
|
||||||
|
.Api("auth_elevate", "Open an elevation window from a passkey assertion (enroll 15 min, revoke 5 min, credential one use within 60 s)", Tag)
|
||||||
|
.Returns<ElevationDto>().ProducesProblems(400, 401, 403, 429)
|
||||||
|
.Requires(Capability.Authenticated);
|
||||||
|
guarded.MapDelete("/auth/elevation", EndElevation)
|
||||||
|
.Api("auth_elevation_end", "Close all elevation windows of this session", Tag)
|
||||||
|
.Returns<object>(StatusCodes.Status204NoContent).ProducesProblems(401)
|
||||||
|
.Requires(Capability.Authenticated);
|
||||||
guarded.MapGet("/auth/me", Me)
|
guarded.MapGet("/auth/me", Me)
|
||||||
.Api("auth_me", "The current identity", Tag)
|
.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))
|
if (!limiter.TryAcquire(source, out int retryAfter))
|
||||||
{
|
{
|
||||||
return Problems.RetryAfter(http, new FleetError(MoShiroErrors.TooManyRequests, "too many sign-in attempts"), 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);
|
Result<LoginResult> result = await auth.LoginAsync(request.Username ?? "", request.Password ?? "", http.RequestAborted);
|
||||||
|
string remote = source;
|
||||||
if (!result.TryGet(out LoginResult? login, out FleetError? error))
|
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 Problems.From(http, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 跨域的前端(return_session)不用 Cookie:会话令牌放在响应里,之后用 Bearer 带上
|
await audit.RecordAsync(login.UserId, login.Username, AuthMethods.Password, "auth.login", null, null, remote, true, http.RequestAborted);
|
||||||
bool returnSession = request.ReturnSession == true;
|
return IssueSession(http, login, request.ReturnSession == true, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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)
|
if (!returnSession)
|
||||||
{
|
{
|
||||||
http.Response.Cookies.Append(
|
http.Response.Cookies.Append(
|
||||||
@@ -65,8 +115,193 @@ 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);
|
return TypedResults.Json(new LoginResponse(login.UserId, login.Username, login.CsrfToken, Mappers.Time(login.ExpiresAtMs), returnSession ? login.SessionToken : null), ApiJsonContext.Default.LoginResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> LogoutAsync(HttpContext http, AuthService auth)
|
/// <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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>已经有 passkey 的用户再加一个,要先用已有的触碰一次;第一个 passkey(还没有可触碰的)不要求。</summary>
|
||||||
|
private static async Task<IResult?> CredentialGateAsync(HttpContext http, PasskeyService passkeys, bool consume)
|
||||||
|
{
|
||||||
|
string? userId = http.GetPrincipal().UserId;
|
||||||
|
return userId is null || (await passkeys.ListAsync(userId, http.RequestAborted)).Count == 0 ? null : ElevationGate.Require(http, ElevationClass.Credential, consume);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> PasskeyCreationOptionsAsync(HttpContext http, PasskeyService passkeys, BackendOptions options)
|
||||||
|
{
|
||||||
|
if (await CredentialGateAsync(http, passkeys, consume: false) is { } needed)
|
||||||
|
{
|
||||||
|
return needed;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
if (await CredentialGateAsync(http, passkeys, consume: true) is { } needed)
|
||||||
|
{
|
||||||
|
return needed;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
if (ElevationGate.Require(http, ElevationClass.Credential, consume: true) is { } needed)
|
||||||
|
{
|
||||||
|
return needed;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<PageDto<ElevationDto>> ListElevation(HttpContext http, ElevationService elevation)
|
||||||
|
{
|
||||||
|
IReadOnlyList<ElevationWindow> open = http.GetPrincipal().SessionId is { } session ? elevation.Active(session) : [];
|
||||||
|
return TypedResults.Json(new PageDto<ElevationDto>([.. open.Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoElevationDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ElevationOptionsAsync(HttpContext http, ElevationRequest request, PasskeyService passkeys, LoginRateLimiter limiter, BackendOptions options)
|
||||||
|
{
|
||||||
|
IResult limit = Limited(http, limiter, out bool ok, "elevation-options:", limit: PasskeyOptionsPerMinute);
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
return limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Elevation.TryParse(request.Class, out ElevationClass elevation))
|
||||||
|
{
|
||||||
|
return Problems.From(http, ValidationError.Single("class", "must be enroll, revoke or credential"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Result<RequestOptions> result = await passkeys.BeginElevationAsync(http.GetPrincipal(), elevation, PasskeyContextOf(http, options), http.RequestAborted);
|
||||||
|
if (!result.TryGet(out RequestOptions? o, out FleetError? error))
|
||||||
|
{
|
||||||
|
return Problems.From(http, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Response.Headers.CacheControl = "no-store";
|
||||||
|
return TypedResults.Json(new PasskeyRequestOptionsDto(o.Challenge, o.RpId, o.TimeoutMs, o.AllowCredentials), ApiJsonContext.Default.PasskeyRequestOptionsDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ElevateAsync(HttpContext http, ElevationAssertionRequest request, PasskeyService passkeys, AuditService audit, LoginRateLimiter limiter, BackendOptions options)
|
||||||
|
{
|
||||||
|
IResult limit = Limited(http, limiter, out bool ok, "elevation:");
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
return limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Elevation.TryParse(request.Class, out ElevationClass elevation))
|
||||||
|
{
|
||||||
|
return Problems.From(http, ValidationError.Single("class", "must be enroll, revoke or credential"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Principal principal = http.GetPrincipal();
|
||||||
|
Result<ElevationWindow> result = await passkeys.ElevateAsync(
|
||||||
|
principal, elevation, new PasskeyAssertion(request.CredentialId ?? "", request.AuthenticatorData ?? "", request.ClientDataJson ?? "", request.Signature ?? ""), PasskeyContextOf(http, options), http.RequestAborted);
|
||||||
|
if (!result.TryGet(out ElevationWindow? window, out FleetError? error))
|
||||||
|
{
|
||||||
|
await audit.RecordAsync(principal, "auth.elevate", null, Elevation.IdOf(elevation), http.ClientAddress(), false, http.RequestAborted);
|
||||||
|
return Problems.From(http, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetDetail(Elevation.IdOf(elevation));
|
||||||
|
http.Response.Headers.CacheControl = "no-store";
|
||||||
|
return TypedResults.Json(Mappers.ToDto(window), ApiJsonContext.Default.ElevationDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Microsoft.AspNetCore.Http.HttpResults.NoContent EndElevation(HttpContext http, ElevationService elevation)
|
||||||
|
{
|
||||||
|
if (http.GetPrincipal().SessionId is { } session)
|
||||||
|
{
|
||||||
|
elevation.End(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
return TypedResults.NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> LogoutAsync(HttpContext http, AuthService auth, ElevationService elevation)
|
||||||
|
{
|
||||||
|
if (http.GetPrincipal().SessionId is { } own)
|
||||||
|
{
|
||||||
|
elevation.End(own);
|
||||||
|
}
|
||||||
|
|
||||||
string? bearer = PrincipalResolver.BearerOf(http);
|
string? bearer = PrincipalResolver.BearerOf(http);
|
||||||
if (bearer is not null)
|
if (bearer is not null)
|
||||||
{
|
{
|
||||||
@@ -85,7 +320,7 @@ public static class AuthEndpoints
|
|||||||
{
|
{
|
||||||
Principal principal = http.GetPrincipal();
|
Principal principal = http.GetPrincipal();
|
||||||
return TypedResults.Json(
|
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);
|
ApiJsonContext.Default.MeDto);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,17 +17,23 @@ public static class BuildEndpoints
|
|||||||
{
|
{
|
||||||
group.MapGet("/build-info", GetAsync)
|
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)
|
.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)
|
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)
|
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.MapGet("/backend/update", BackendUpdateAsync)
|
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)
|
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)
|
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>
|
/// <summary>登记发布清单:挂在 <see cref="ReleaseGuard"/> 下,除了管理员,CI 用的 <c>release</c> 令牌也能调用(它只能做这一件事)。</summary>
|
||||||
@@ -35,7 +41,8 @@ public static class BuildEndpoints
|
|||||||
{
|
{
|
||||||
group.MapPost("/releases", PublishReleaseAsync)
|
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)
|
.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)
|
private static async Task<IResult> GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, CancellationToken ct)
|
||||||
|
|||||||
@@ -16,14 +16,18 @@ public static class DataEndpoints
|
|||||||
public static void Map(RouteGroupBuilder group)
|
public static void Map(RouteGroupBuilder group)
|
||||||
{
|
{
|
||||||
group.MapGet("/raw/chunks", ListChunksAsync)
|
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)
|
group.MapGet("/raw/chunks/{chunk_id}", DownloadChunkAsync)
|
||||||
.Api("raw_chunks_get", "Download a raw chunk (application/vnd.moshiro.rawchunk)", Tag)
|
.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)
|
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)
|
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(
|
private static async Task<IResult> ListChunksAsync(
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ public static class LinkEndpoints
|
|||||||
public static void Map(RouteGroupBuilder group)
|
public static void Map(RouteGroupBuilder group)
|
||||||
{
|
{
|
||||||
group.MapGet("/links", ListAsync)
|
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)
|
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)
|
private static async Task<IResult> ListAsync(AgentService agents, ProbeService probes, LinkService links, CancellationToken ct)
|
||||||
|
|||||||
@@ -18,33 +18,59 @@ public static class SystemEndpoints
|
|||||||
public static void Map(RouteGroupBuilder group)
|
public static void Map(RouteGroupBuilder group)
|
||||||
{
|
{
|
||||||
group.MapGet("/api-tokens", ListTokensAsync)
|
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)
|
group.MapPost("/api-tokens", CreateTokenAsync)
|
||||||
.Api("api_tokens_create", "Create an API token (scope read or admin); the plaintext is returned only once", TokensTag)
|
.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);
|
.Returns<CreatedApiTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403)
|
||||||
|
.Requires(Capability.KeysManage);
|
||||||
group.MapDelete("/api-tokens/{id}", RevokeTokenAsync)
|
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)
|
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)
|
group.MapPatch("/settings", PatchSettingsAsync)
|
||||||
.Accepts<Dictionary<string, JsonElement>>("application/merge-patch+json", "application/json")
|
.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)
|
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)
|
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)
|
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) =>
|
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);
|
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)
|
private static async Task<IResult> CreateTokenAsync(HttpContext http, AuthService auth, CreateApiTokenRequest request)
|
||||||
{
|
{
|
||||||
Result<CreatedApiToken> result = await auth.CreateApiTokenAsync(request.Name ?? "", request.Scope ?? "", http.RequestAborted);
|
if (CapabilityCatalog.Expand(request.Capabilities ?? []).Any(c => CapabilityCatalog.Find(c)?.Trust == true) && ElevationGate.Require(http, ElevationClass.Credential, consume: true) is { } needed)
|
||||||
|
{
|
||||||
|
return needed; // 带信任类能力的 API key 等于把信任操作交给一份密钥:创建它要用 passkey 签一次
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
if (!result.TryGet(out CreatedApiToken? created, out FleetError? error))
|
||||||
{
|
{
|
||||||
return Problems.From(http, error);
|
return Problems.From(http, error);
|
||||||
|
|||||||
@@ -16,26 +16,34 @@ public static class TaskEndpoints
|
|||||||
public static void Map(RouteGroupBuilder group)
|
public static void Map(RouteGroupBuilder group)
|
||||||
{
|
{
|
||||||
group.MapGet("/tasks", ListAsync)
|
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)
|
group.MapPost("/tasks", CreateAsync)
|
||||||
.Api("tasks_create", "Create a task; params are validated against the kind", TasksTag)
|
.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)
|
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)
|
group.MapPatch("/tasks/{id}", PatchAsync)
|
||||||
.Accepts<PatchTaskRequest>("application/merge-patch+json", "application/json")
|
.Accepts<PatchTaskRequest>("application/merge-patch+json", "application/json")
|
||||||
.Api("tasks_update", "Change params, schedule, target or enabled (merge-patch, If-Match supported)", TasksTag)
|
.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)
|
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)
|
group.MapPost("/lines/{line_id}/tests", StartTestAsync)
|
||||||
.Api("tests_start", "Start an on-demand test on a line", TestsTag)
|
.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)
|
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)
|
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)
|
private static async Task<IResult> ListAsync(TaskService tasks, string? agent_id, string? scope, CancellationToken ct)
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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 and needs a credential window)", Tag)
|
||||||
|
.Returns<TrustStateDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
|
||||||
|
.Requires(Capability.AccountsManage)
|
||||||
|
.ElevationExempt();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
// 第一次(创世)是整个信任根的起点:除了 passkey 签名,还要凭证窗口,避免只拿到一个密码会话的人抢先立根
|
||||||
|
if ((await trust.StateAsync(context, http.RequestAborted)).Initialized is false && ElevationGate.Require(http, ElevationClass.Credential, consume: true) is { } needed)
|
||||||
|
{
|
||||||
|
return needed;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ public static class ApiModule
|
|||||||
public static IServiceCollection AddMoShiroApi(this IServiceCollection services)
|
public static IServiceCollection AddMoShiroApi(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddSingleton<SseLimiter>();
|
services.AddSingleton<SseLimiter>();
|
||||||
|
services.AddSingleton<ClientAddressResolver>();
|
||||||
services.Configure<JsonOptions>(options =>
|
services.Configure<JsonOptions>(options =>
|
||||||
{
|
{
|
||||||
JsonSerializerOptions json = options.SerializerOptions;
|
JsonSerializerOptions json = options.SerializerOptions;
|
||||||
@@ -103,13 +104,15 @@ public static class ApiModule
|
|||||||
RouteGroupBuilder admin = app.MapGroup("/api/admin");
|
RouteGroupBuilder admin = app.MapGroup("/api/admin");
|
||||||
AuthEndpoints.MapLogin(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);
|
AuthEndpoints.MapSession(guarded);
|
||||||
AgentEndpoints.Map(guarded);
|
AgentEndpoints.Map(guarded);
|
||||||
TaskEndpoints.Map(guarded);
|
TaskEndpoints.Map(guarded);
|
||||||
DataEndpoints.Map(guarded);
|
DataEndpoints.Map(guarded);
|
||||||
|
AuditEndpoints.Map(guarded);
|
||||||
|
TrustEndpoints.Map(guarded);
|
||||||
SystemEndpoints.Map(guarded);
|
SystemEndpoints.Map(guarded);
|
||||||
BuildEndpoints.Map(guarded);
|
BuildEndpoints.Map(guarded);
|
||||||
LinkEndpoints.Map(guarded);
|
LinkEndpoints.Map(guarded);
|
||||||
|
|||||||
@@ -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,24 @@ public static class EndpointConventions
|
|||||||
public static RouteHandlerBuilder Api(this RouteHandlerBuilder builder, string operationId, string summary, string tag) =>
|
public static RouteHandlerBuilder Api(this RouteHandlerBuilder builder, string operationId, string summary, string tag) =>
|
||||||
builder.WithName(operationId).WithSummary(summary).WithTags(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>
|
||||||
|
/// 这个端点自己要求 passkey 签名(请求里带着断言),比提权窗口更强,所以不再要求窗口:守卫跳过信任类能力的提权检查。
|
||||||
|
/// </summary>
|
||||||
|
public static RouteHandlerBuilder ElevationExempt(this RouteHandlerBuilder builder) => builder.WithMetadata(new ElevationExemptMarker());
|
||||||
|
|
||||||
|
/// <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) =>
|
public static RouteHandlerBuilder Returns<T>(this RouteHandlerBuilder builder, int status = StatusCodes.Status200OK) =>
|
||||||
builder.Produces<T>(status);
|
builder.Produces<T>(status);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using MoShiro.Backend.Application;
|
using MoShiro.Backend.Application;
|
||||||
using MoShiro.Protocol;
|
using MoShiro.Protocol;
|
||||||
@@ -53,17 +54,32 @@ public static class PrincipalResolver
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>管理层守卫:需要 admin 作用域;Cookie 会话的非 GET 请求必须带 <c>X-CSRF-Token</c>。</summary>
|
/// <summary>
|
||||||
public static class AdminGuard
|
/// 端点声明自己需要的能力(<see cref="EndpointConventions.Requires"/>)。<c>Capability == null</c> 表示匿名可访问(登录)。
|
||||||
|
/// 没有声明的 <c>/api/admin</c> 端点一律拒绝(并有对账测试保证不存在)。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CapabilityRequirement(string? Capability);
|
||||||
|
|
||||||
|
/// <summary>端点自己验 passkey 签名(见 <see cref="EndpointConventions.ElevationExempt"/>)。</summary>
|
||||||
|
public sealed record ElevationExemptMarker;
|
||||||
|
|
||||||
|
/// <summary>管理层守卫:按端点声明的能力授权;Cookie 会话的非 GET 请求必须带 <c>X-CSRF-Token</c>。</summary>
|
||||||
|
public static class CapabilityGuard
|
||||||
{
|
{
|
||||||
public static async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
public static async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||||
{
|
{
|
||||||
HttpContext http = context.HttpContext;
|
HttpContext http = context.HttpContext;
|
||||||
Principal principal = http.GetPrincipal();
|
CapabilityRequirement? requirement = http.GetEndpoint()?.Metadata.GetMetadata<CapabilityRequirement>();
|
||||||
if (!principal.IsAdmin)
|
if (requirement is null)
|
||||||
{
|
{
|
||||||
return principal.Scope != ApiScope.None
|
return Problems.Create(http, MoShiroErrors.Forbidden, "this endpoint has no capability declaration");
|
||||||
? Problems.Create(http, MoShiroErrors.Forbidden, "the token scope does not allow this operation")
|
}
|
||||||
|
|
||||||
|
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");
|
: Problems.Create(http, MoShiroErrors.Unauthorized, "authentication is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,15 +92,50 @@ public static class AdminGuard
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 信任类的操作(加节点、吊销、改凭证):登录用户还要有这一种的提权窗口;API key 自己带着能力,不经过这里
|
||||||
|
if (requirement.Capability is { } trusted && Elevation.For(trusted) is { } elevation && http.GetEndpoint()?.Metadata.GetMetadata<ElevationExemptMarker>() is null && ElevationGate.Require(http, elevation, consume: true) is { } needed)
|
||||||
|
{
|
||||||
|
return needed;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
return await next(context);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>登记发布清单的守卫:管理员(会话要带 CSRF)或 release 令牌;release 令牌只能做这一件事。</summary>
|
/// <summary>提权检查:登录用户(含用 return_session 拿到的 Bearer 会话)要有对应种类的有效窗口,API key 不需要。</summary>
|
||||||
public static class ReleaseGuard
|
public static class ElevationGate
|
||||||
{
|
{
|
||||||
public static async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) =>
|
/// <summary>没问题返回 null;否则 403 <c>elevation_required</c>,detail 是缺的窗口种类,前端据此触碰 passkey 后重试。</summary>
|
||||||
context.HttpContext.GetPrincipal().Scope == ApiScope.Release ? await next(context) : await AdminGuard.InvokeAsync(context, next);
|
public static IResult? Require(HttpContext http, ElevationClass elevation, bool consume)
|
||||||
|
{
|
||||||
|
Principal principal = http.GetPrincipal();
|
||||||
|
if (principal.UserId is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return principal.SessionId is { } session && http.RequestServices.GetRequiredService<ElevationService>().Check(session, elevation, consume)
|
||||||
|
? null
|
||||||
|
: Problems.Create(http, MoShiroErrors.ElevationRequired, Elevation.IdOf(elevation));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>展示层守卫:站点公开时匿名可读;私有时需要登录或只读令牌。</summary>
|
/// <summary>展示层守卫:站点公开时匿名可读;私有时需要登录或只读令牌。</summary>
|
||||||
@@ -93,7 +144,7 @@ public static class PublicGuard
|
|||||||
public static async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
public static async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||||
{
|
{
|
||||||
HttpContext http = context.HttpContext;
|
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");
|
return Problems.Create(http, MoShiroErrors.SitePrivate, "this site is private; sign in or use a read-only token");
|
||||||
}
|
}
|
||||||
@@ -102,5 +153,5 @@ public static class PublicGuard
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>被隐藏(public_visible = false)的线路只对已认证的访问者可见。</summary>
|
/// <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) =>
|
public static ProbeDto ToDto(ProbeView v) =>
|
||||||
new(
|
new(
|
||||||
v.Probe.Id, v.Probe.Name, Time(v.Probe.CreatedAtMs), Time(v.Probe.RevokedAtMs), v.Revoked, v.Online,
|
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) =>
|
public static SeriesDto ToDto(SeriesResult r, bool live) =>
|
||||||
new(
|
new(
|
||||||
@@ -103,7 +103,7 @@ public static class Mappers
|
|||||||
: null;
|
: null;
|
||||||
return new AgentDto(
|
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.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)
|
public static Ipv6DiagnosticDto Ipv6Of(AgentView view)
|
||||||
@@ -154,9 +154,17 @@ 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));
|
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) =>
|
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), installUrl, s.Operators ?? [], installSh, installPs1, matches, s.ServerKey, s.ServerKeyListed);
|
||||||
|
|
||||||
|
public static ElevationDto ToDto(ElevationWindow w) => new(Elevation.IdOf(w.Class), Time(w.ExpiresAtMs));
|
||||||
|
|
||||||
|
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) =>
|
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);
|
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);
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ public static class ErrorCodeHttpMap
|
|||||||
|
|
||||||
Add(400, MoShiroErrors.ValidationFailed, MoShiroErrors.BudgetExceeded, FleetErrors.InvalidParams);
|
Add(400, MoShiroErrors.ValidationFailed, MoShiroErrors.BudgetExceeded, FleetErrors.InvalidParams);
|
||||||
Add(401, MoShiroErrors.Unauthorized, MoShiroErrors.CsrfFailed, FleetErrors.Unauthorized);
|
Add(401, MoShiroErrors.Unauthorized, MoShiroErrors.CsrfFailed, FleetErrors.Unauthorized);
|
||||||
Add(403, MoShiroErrors.Forbidden, MoShiroErrors.SitePrivate, FleetErrors.Forbidden, FleetErrors.ProofInvalid);
|
Add(403, MoShiroErrors.Forbidden, MoShiroErrors.SitePrivate, MoShiroErrors.ElevationRequired, FleetErrors.Forbidden, FleetErrors.ProofInvalid);
|
||||||
Add(
|
Add(
|
||||||
404, MoShiroErrors.NotFound, MoShiroErrors.AgentNotFound, MoShiroErrors.ProbeNotFound, MoShiroErrors.TaskNotFound, MoShiroErrors.TestNotFound, MoShiroErrors.ChunkNotFound,
|
404, MoShiroErrors.NotFound, MoShiroErrors.AgentNotFound, MoShiroErrors.ProbeNotFound, MoShiroErrors.TaskNotFound, MoShiroErrors.TestNotFound, MoShiroErrors.ChunkNotFound,
|
||||||
MoShiroErrors.TokenNotFound, MoShiroErrors.UnknownKind, MoShiroErrors.UnknownPlan, FleetErrors.NotFound);
|
MoShiroErrors.TokenNotFound, MoShiroErrors.UnknownKind, MoShiroErrors.UnknownPlan, FleetErrors.NotFound);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Fleet.Core;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace MoShiro.Backend.Api;
|
namespace MoShiro.Backend.Api;
|
||||||
@@ -5,9 +6,30 @@ namespace MoShiro.Backend.Api;
|
|||||||
/// <summary><c>return_session</c>:跨域的前端用——不设 Cookie,把会话令牌放在响应里,之后用 <c>Authorization: Bearer</c> 带上。</summary>
|
/// <summary><c>return_session</c>:跨域的前端用——不设 Cookie,把会话令牌放在响应里,之后用 <c>Authorization: Bearer</c> 带上。</summary>
|
||||||
public sealed record LoginRequest(string Username, string Password, bool? ReturnSession);
|
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 ElevationRequest(string Class);
|
||||||
|
|
||||||
|
public sealed record ElevationAssertionRequest(string Class, string CredentialId, string AuthenticatorData, string ClientDataJson, string Signature);
|
||||||
|
|
||||||
|
public sealed record ElevationDto(string Class, DateTimeOffset ExpiresAt);
|
||||||
|
|
||||||
|
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 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>(没有任何能力,只能注册 passkey)或 <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);
|
public sealed record LocalLimitsDto(double MaxTotalMbps, long MaxTotalPps, IReadOnlyList<string> AllowedTargetCidrs, int MaxConcurrentTests);
|
||||||
|
|
||||||
@@ -40,21 +62,37 @@ public sealed record AgentDto(
|
|||||||
DateTimeOffset? LastSeenAt,
|
DateTimeOffset? LastSeenAt,
|
||||||
string Ipv6Host = "",
|
string Ipv6Host = "",
|
||||||
bool Ipv6Supported = false,
|
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,
|
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);
|
string NetworkScope = "unknown", bool? HostHasGlobalAddress = null, bool? HostHasDefaultRoute = null);
|
||||||
|
|
||||||
/// <summary>merge-patch:缺省或为 null 的字段不修改。</summary>
|
/// <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 InstallCommandsDto(string Linux, string Macos, string Windows);
|
public sealed record InstallCommandsDto(string Linux, string Macos, string Windows);
|
||||||
|
|
||||||
|
public sealed record TrustProposalDto(string Statement, int Version, string Challenge);
|
||||||
|
|
||||||
|
/// <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="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);
|
||||||
|
|
||||||
public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands);
|
public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands);
|
||||||
|
|
||||||
/// <summary>Agent 眼中连着的一个 Probe(Agent 上报)。</summary>
|
/// <summary>Agent 眼中连着的一个 Probe(Agent 上报)。</summary>
|
||||||
@@ -63,7 +101,7 @@ public sealed record ConnectedProbeDto(string ProbeId, string? Version, string?
|
|||||||
/// <summary>某个 Probe 现在连着的一个 Agent。</summary>
|
/// <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 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>
|
/// <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);
|
public sealed record LinkDto(string ProbeId, string ProbeName, string AgentId, string AgentName, bool Linked, bool? Override, bool Connected);
|
||||||
@@ -72,13 +110,6 @@ public sealed record LinkMatrixDto(bool AutoLink, IReadOnlyList<LinkDto> Items);
|
|||||||
|
|
||||||
public sealed record SetLinkRequest(bool? Enabled);
|
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 PatchProbeRequest(string? Name);
|
||||||
|
|
||||||
public sealed record TaskDto(
|
public sealed record TaskDto(
|
||||||
@@ -120,9 +151,19 @@ public sealed record RawChunkMetaDto(
|
|||||||
bool Partial,
|
bool Partial,
|
||||||
int Bytes);
|
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);
|
public sealed record CreatedApiTokenDto(ApiTokenDto Token, string Plaintext);
|
||||||
|
|
||||||
|
|||||||
@@ -56,8 +56,6 @@ namespace MoShiro.Backend.Api;
|
|||||||
[JsonSerializable(typeof(PageDto<JoinTokenDto>))]
|
[JsonSerializable(typeof(PageDto<JoinTokenDto>))]
|
||||||
[JsonSerializable(typeof(CreateJoinTokenRequest))]
|
[JsonSerializable(typeof(CreateJoinTokenRequest))]
|
||||||
[JsonSerializable(typeof(CreatedJoinTokenDto))]
|
[JsonSerializable(typeof(CreatedJoinTokenDto))]
|
||||||
[JsonSerializable(typeof(ProbeIdentityDto))]
|
|
||||||
[JsonSerializable(typeof(CreateProbeRequest))]
|
|
||||||
[JsonSerializable(typeof(PatchProbeRequest))]
|
[JsonSerializable(typeof(PatchProbeRequest))]
|
||||||
[JsonSerializable(typeof(ProbeDto))]
|
[JsonSerializable(typeof(ProbeDto))]
|
||||||
[JsonSerializable(typeof(PageDto<HostItemDto>))]
|
[JsonSerializable(typeof(PageDto<HostItemDto>))]
|
||||||
@@ -72,9 +70,23 @@ namespace MoShiro.Backend.Api;
|
|||||||
[JsonSerializable(typeof(RawChunkMetaDto))]
|
[JsonSerializable(typeof(RawChunkMetaDto))]
|
||||||
[JsonSerializable(typeof(PageDto<RawChunkMetaDto>))]
|
[JsonSerializable(typeof(PageDto<RawChunkMetaDto>))]
|
||||||
[JsonSerializable(typeof(PageDto<EventDto>))]
|
[JsonSerializable(typeof(PageDto<EventDto>))]
|
||||||
|
[JsonSerializable(typeof(PageDto<AuditDto>))]
|
||||||
[JsonSerializable(typeof(ApiTokenDto))]
|
[JsonSerializable(typeof(ApiTokenDto))]
|
||||||
[JsonSerializable(typeof(PageDto<ApiTokenDto>))]
|
[JsonSerializable(typeof(PageDto<ApiTokenDto>))]
|
||||||
[JsonSerializable(typeof(CreateApiTokenRequest))]
|
[JsonSerializable(typeof(CreateApiTokenRequest))]
|
||||||
|
[JsonSerializable(typeof(CapabilityCatalogDto))]
|
||||||
|
[JsonSerializable(typeof(PasskeyCreationOptionsDto))]
|
||||||
|
[JsonSerializable(typeof(PasskeyRequestOptionsDto))]
|
||||||
|
[JsonSerializable(typeof(RegisterPasskeyRequest))]
|
||||||
|
[JsonSerializable(typeof(PasskeyLoginRequest))]
|
||||||
|
[JsonSerializable(typeof(Fleet.Core.SignedStatementDto), TypeInfoPropertyName = "FleetSignedStatement")]
|
||||||
|
[JsonSerializable(typeof(TrustStateDto))]
|
||||||
|
[JsonSerializable(typeof(ElevationRequest))]
|
||||||
|
[JsonSerializable(typeof(ElevationAssertionRequest))]
|
||||||
|
[JsonSerializable(typeof(ElevationDto))]
|
||||||
|
[JsonSerializable(typeof(PageDto<ElevationDto>))]
|
||||||
|
[JsonSerializable(typeof(PasskeyDto))]
|
||||||
|
[JsonSerializable(typeof(PageDto<PasskeyDto>))]
|
||||||
[JsonSerializable(typeof(CreatedApiTokenDto))]
|
[JsonSerializable(typeof(CreatedApiTokenDto))]
|
||||||
[JsonSerializable(typeof(PageDto<SettingDto>))]
|
[JsonSerializable(typeof(PageDto<SettingDto>))]
|
||||||
[JsonSerializable(typeof(Dictionary<string, JsonElement>), TypeInfoPropertyName = "SettingsPatch")]
|
[JsonSerializable(typeof(Dictionary<string, JsonElement>), TypeInfoPropertyName = "SettingsPatch")]
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ public sealed record EdgePartialDto(bool First, bool Last);
|
|||||||
|
|
||||||
public sealed record GapDto(DateTimeOffset From, DateTimeOffset To, string Reason);
|
public sealed record GapDto(DateTimeOffset From, DateTimeOffset To, string Reason);
|
||||||
|
|
||||||
|
public sealed record AuditDto(long Id, DateTimeOffset At, string Actor, string Method, string Action, string? Target, string? Detail, string? Remote, bool Success);
|
||||||
|
|
||||||
public sealed record EventDto(long Id, string? AgentId, string Kind, DateTimeOffset At, string? Detail);
|
public sealed record EventDto(long Id, string? AgentId, string Kind, DateTimeOffset At, string? Detail);
|
||||||
|
|
||||||
public sealed record TestSummaryDto(string Id, string Plan, string Status, DateTimeOffset RequestedAt, DateTimeOffset? FinishedAt);
|
public sealed record TestSummaryDto(string Id, string Plan, string Status, DateTimeOffset RequestedAt, DateTimeOffset? FinishedAt);
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace MoShiro.Backend.Api;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 一行安装脚本:<c>packaging/install/</c> 下的 <c>install.sh</c> 和 <c>install.ps1</c> 作为嵌入资源打进 Backend,<b>原样返回,不替换任何值</b>。
|
||||||
|
/// 静态,所以它的 SHA-256 在一个版本内固定:CI 构建时算出来写进签名的发布清单,面板给的命令先核对哈希再执行(docs/access.md 第 15 节)。
|
||||||
|
/// 角色、面板地址、代码都走命令里的环境变量。
|
||||||
|
/// </summary>
|
||||||
|
public static class InstallScripts
|
||||||
|
{
|
||||||
|
public const string Sh = "install.sh";
|
||||||
|
public const string Ps1 = "install.ps1";
|
||||||
|
|
||||||
|
/// <summary>CI 的发布公钥(<c>packaging/update/release-key.pub.pem</c>):脚本里内置的就是这一把,测试核对两边一致。</summary>
|
||||||
|
public static string ReleaseKey() => Read("release-key.pub.pem").Trim();
|
||||||
|
|
||||||
|
public static string Script(string resource) => Read(resource);
|
||||||
|
|
||||||
|
/// <summary>脚本内容的 SHA-256(小写十六进制,按 UTF-8 字节)。</summary>
|
||||||
|
public static string Sha256(string resource) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(Read(resource))));
|
||||||
|
|
||||||
|
private static string Read(string resource)
|
||||||
|
{
|
||||||
|
using Stream stream = typeof(InstallScripts).Assembly.GetManifestResourceStream(resource)
|
||||||
|
?? throw new InvalidOperationException($"the embedded resource {resource} is missing");
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
return reader.ReadToEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,5 +7,8 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||||
<ProjectReference Include="..\MoShiro.Backend.Application\MoShiro.Backend.Application.csproj" />
|
<ProjectReference Include="..\MoShiro.Backend.Application\MoShiro.Backend.Application.csproj" />
|
||||||
|
<EmbeddedResource Include="..\..\..\packaging\install\install.sh" LogicalName="install.sh" />
|
||||||
|
<EmbeddedResource Include="..\..\..\packaging\install\install.ps1" LogicalName="install.ps1" />
|
||||||
|
<EmbeddedResource Include="..\..\..\packaging\update\release-key.pub.pem" LogicalName="release-key.pub.pem" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -6,7 +6,7 @@ using MoShiro.Protocol;
|
|||||||
|
|
||||||
namespace MoShiro.Backend.Api;
|
namespace MoShiro.Backend.Api;
|
||||||
|
|
||||||
/// <summary>运维端点:<c>/healthz</c>、<c>/metrics</c>(admin 令牌)、安装脚本。</summary>
|
/// <summary>运维端点:<c>/healthz</c>、<c>/metrics</c>(admin 令牌)、一行安装脚本(<c>/install</c>、<c>/install.ps1</c>)。</summary>
|
||||||
public static class OpsEndpoints
|
public static class OpsEndpoints
|
||||||
{
|
{
|
||||||
private const string Tag = "ops";
|
private const string Tag = "ops";
|
||||||
@@ -17,12 +17,45 @@ public static class OpsEndpoints
|
|||||||
.Api("health_get", "SQLite and the raw data directory are both writable", Tag)
|
.Api("health_get", "SQLite and the raw data directory are both writable", Tag)
|
||||||
.Returns<HealthDto>().ProducesProblems(503);
|
.Returns<HealthDto>().ProducesProblems(503);
|
||||||
app.MapGet("/metrics", MetricsAsync)
|
app.MapGet("/metrics", MetricsAsync)
|
||||||
.Api("metrics_get", "Prometheus metrics (admin token)", Tag)
|
.Api("metrics_get", "Prometheus metrics (requires ops.metrics)", Tag)
|
||||||
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(401, 403);
|
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(401, 403);
|
||||||
app.MapGet("/install.sh", (HttpContext http) => NotShipped(http))
|
// 一行安装:面板给的命令先下载这份静态脚本、核对 SHA-256,再用环境变量里的面板地址、角色和一次性代码执行。
|
||||||
.Api("install_sh", "Installer for Linux nodes", Tag).ProducesProblems(501);
|
// 脚本不含任何一次性的值,所以不需要代码,也可以直接拿去和发布页公布的哈希核对。
|
||||||
app.MapGet("/install.ps1", (HttpContext http) => NotShipped(http))
|
app.MapGet("/install", (HttpContext http) => Static(http, InstallScripts.Sh, "text/x-shellscript"))
|
||||||
.Api("install_ps1", "Installer for Windows nodes", Tag).ProducesProblems(501);
|
.Api("install_sh", "The static one-line installer for Linux and macOS nodes (same bytes for everyone; its SHA-256 is published with each release)", Tag)
|
||||||
|
.Produces(StatusCodes.Status200OK, contentType: "text/x-shellscript");
|
||||||
|
app.MapGet("/install.sh", (HttpContext http) => Static(http, InstallScripts.Sh, "text/x-shellscript"))
|
||||||
|
.ExcludeFromDescription();
|
||||||
|
// 脚本要装的版本:CI 签名的发布清单(原样的字节)和签名。脚本用内置的发布公钥验签,再按清单里的 digest 拉镜像。
|
||||||
|
app.MapGet("/install/manifest", (HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code) => ManifestAsync(http, tokens, releases, options, code, signature: false))
|
||||||
|
.Api("install_manifest", "The signed release manifest of the component an install code is for (exact signed bytes)", Tag)
|
||||||
|
.Produces(StatusCodes.Status200OK, contentType: "application/json").ProducesProblems(404);
|
||||||
|
app.MapGet("/install/manifest.sig", (HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code) => ManifestAsync(http, tokens, releases, options, code, signature: true))
|
||||||
|
.Api("install_manifest_signature", "Base64 DER ECDSA P-256 signature of the manifest", Tag)
|
||||||
|
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404);
|
||||||
|
app.MapGet("/install.ps1", (HttpContext http) => Static(http, InstallScripts.Ps1, "text/plain"))
|
||||||
|
.Api("install_ps1", "The static one-line installer for Windows nodes (same bytes for everyone; its SHA-256 is published with each release)", Tag)
|
||||||
|
.Produces(StatusCodes.Status200OK, contentType: "text/plain");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> ManifestAsync(HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code, bool signature)
|
||||||
|
{
|
||||||
|
string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}";
|
||||||
|
InstallTarget? target = string.IsNullOrEmpty(code) ? null : await tokens.ResolveInstallAsync(code, baseUrl, http.RequestAborted);
|
||||||
|
ReleaseOffer? offer = target is null ? null : await releases.GetAsync(target.Role, http.RequestAborted);
|
||||||
|
if (offer is null)
|
||||||
|
{
|
||||||
|
return Problems.Create(http, MoShiroErrors.NotFound, "no signed release is registered for this install code");
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Response.Headers.CacheControl = "no-store";
|
||||||
|
return signature ? TypedResults.Text(offer.Signature, "text/plain; charset=utf-8") : TypedResults.Text(offer.Manifest, "application/json; charset=utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Microsoft.AspNetCore.Http.HttpResults.ContentHttpResult Static(HttpContext http, string resource, string contentType)
|
||||||
|
{
|
||||||
|
http.Response.Headers.CacheControl = "no-store";
|
||||||
|
return TypedResults.Text(InstallScripts.Script(resource), contentType + "; charset=utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> HealthAsync(HttpContext http, OpsService ops)
|
private static async Task<IResult> HealthAsync(HttpContext http, OpsService ops)
|
||||||
@@ -37,17 +70,13 @@ public static class OpsEndpoints
|
|||||||
private static async Task<IResult> MetricsAsync(HttpContext http, OpsService ops)
|
private static async Task<IResult> MetricsAsync(HttpContext http, OpsService ops)
|
||||||
{
|
{
|
||||||
Principal principal = http.GetPrincipal();
|
Principal principal = http.GetPrincipal();
|
||||||
if (!principal.IsAdmin)
|
if (!principal.Has(Capability.OpsMetrics))
|
||||||
{
|
{
|
||||||
return principal.CanRead
|
return principal.Kind != PrincipalKind.Anonymous
|
||||||
? Problems.Create(http, MoShiroErrors.Forbidden, "an admin token is required")
|
? Problems.Create(http, MoShiroErrors.Forbidden, "the ops.metrics capability is required")
|
||||||
: Problems.Create(http, MoShiroErrors.Unauthorized, "an admin token is required");
|
: Problems.Create(http, MoShiroErrors.Unauthorized, "authentication is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
return TypedResults.Text(await ops.RenderPrometheusAsync(http.RequestAborted), "text/plain; version=0.0.4");
|
return TypedResults.Text(await ops.RenderPrometheusAsync(http.RequestAborted), "text/plain; version=0.0.4");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>安装脚本需要下载节点程序的发布物,那属于打包与部署(M6),目前还没有。</summary>
|
|
||||||
private static IResult NotShipped(HttpContext http) =>
|
|
||||||
Problems.Create(http, StatusCodes.Status501NotImplemented, ErrorCodeHttpMap.NotImplemented.Value, "the node installer ships with packaging (M6)");
|
|
||||||
}
|
}
|
||||||
@@ -70,7 +70,7 @@ public static class LiveEndpoints
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
string client = http.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
string client = http.ClientAddress();
|
||||||
if (!limiter.TryAcquire(client))
|
if (!limiter.TryAcquire(client))
|
||||||
{
|
{
|
||||||
return Problems.Create(http, MoShiroErrors.TooManyStreams, $"at most {options.MaxSsePerClient} live connections per client");
|
return Problems.Create(http, MoShiroErrors.TooManyStreams, $"at most {options.MaxSsePerClient} live connections per client");
|
||||||
|
|||||||
@@ -1,17 +1,37 @@
|
|||||||
using Fleet.Core;
|
using Fleet.Core;
|
||||||
|
using Fleet.Identity;
|
||||||
|
using MoShiro.Protocol;
|
||||||
|
|
||||||
namespace MoShiro.Backend.Application;
|
namespace MoShiro.Backend.Application;
|
||||||
|
|
||||||
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours);
|
/// <param name="Role"><c>agent</c>(默认)或 <c>probe</c>:用这个令牌注册出来的是哪种节点。</param>
|
||||||
|
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool AutoApprove, int MaxUses, int ExpiresInHours, string Role = JoinTokenService.AgentRole, string? CreatedBy = null);
|
||||||
|
|
||||||
/// <summary>各个系统上的安装命令(Linux 和 macOS 都用 install.sh,Windows 用 install.ps1)。</summary>
|
/// <summary>各个系统上的一行安装命令:Linux 用 bash、macOS 用 zsh、Windows 用 PowerShell。命令里只有一次性代码,节点名字默认取主机名;Agent 和 Probe 是同一份脚本,角色由代码决定。</summary>
|
||||||
public sealed record InstallCommands(string Linux, string Macos, string Windows);
|
public sealed record InstallCommands(string Linux, string Macos, string Windows);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 安装命令里除了一次性的码,还带的公开值:信任根的创世指纹(节点把它固定下来)、属主(允许授权这个节点 extension 的 passkey 凭证 id)、
|
||||||
|
/// 和面板提供的静态安装脚本的 SHA-256(命令先核对哈希再执行)。没有建立信任根时 <c>Genesis</c> 为空,节点不固定信任根。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record InstallPlan(string? Genesis, IReadOnlyList<string> Controllers, string ScriptShSha256, string ScriptPs1Sha256);
|
||||||
|
|
||||||
public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands);
|
public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands);
|
||||||
|
|
||||||
|
/// <summary>安装脚本里要填的四个值。</summary>
|
||||||
|
public sealed record InstallTarget(string Role, string Server, string Code, string Image);
|
||||||
|
|
||||||
/// <summary>加入令牌:明文只在创建时返回一次,服务端只保存 SHA-256。</summary>
|
/// <summary>加入令牌:明文只在创建时返回一次,服务端只保存 SHA-256。</summary>
|
||||||
public sealed class JoinTokenService
|
public sealed class JoinTokenService
|
||||||
{
|
{
|
||||||
|
public const string AgentRole = "agent";
|
||||||
|
public const string ProbeRole = "probe";
|
||||||
|
|
||||||
|
/// <summary>令牌里存的「种类」:空表示 Agent,其余就是角色名。</summary>
|
||||||
|
public static string KindOf(string role) => role == ProbeRole ? ProbeRole : "";
|
||||||
|
|
||||||
|
public static string RoleOf(string kind) => kind.Length == 0 ? AgentRole : kind;
|
||||||
|
|
||||||
private readonly IJoinTokenStore _store;
|
private readonly IJoinTokenStore _store;
|
||||||
private readonly BackendOptions _options;
|
private readonly BackendOptions _options;
|
||||||
private readonly TimeProvider _time;
|
private readonly TimeProvider _time;
|
||||||
@@ -23,12 +43,17 @@ public sealed class JoinTokenService
|
|||||||
_time = time;
|
_time = time;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask<Result<CreatedJoinToken>> CreateAsync(CreateJoinTokenRequest request, string? requestBaseUrl, CancellationToken ct)
|
public async ValueTask<Result<CreatedJoinToken>> CreateAsync(CreateJoinTokenRequest request, string? requestBaseUrl, InstallPlan plan, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var errors = new Dictionary<string, string>();
|
var errors = new Dictionary<string, string>();
|
||||||
if (request.MaxUses is < 1 or > 1000)
|
if (request.Role is not (AgentRole or ProbeRole))
|
||||||
{
|
{
|
||||||
errors["max_uses"] = "must be between 1 and 1000";
|
errors["role"] = "must be agent or probe";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.MaxUses is < 1 or > 100_000)
|
||||||
|
{
|
||||||
|
errors["max_uses"] = "must be between 1 and 100000";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.ExpiresInHours is < 1 or > 24 * 365)
|
if (request.ExpiresInHours is < 1 or > 24 * 365)
|
||||||
@@ -54,16 +79,58 @@ public sealed class JoinTokenService
|
|||||||
string plaintext = TokenHash.NewToken("jt");
|
string plaintext = TokenHash.NewToken("jt");
|
||||||
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||||
JoinTokenInfo info = await _store.CreateAsync(
|
JoinTokenInfo info = await _store.CreateAsync(
|
||||||
Ulid.NewPrefixed("jtk", _time), TokenHash.Sha256(plaintext), request.Note, request.Labels ?? [], request.AutoApprove,
|
Ulid.NewPrefixed("jtk", _time), TokenHash.Sha256(plaintext), request.Note, request.Labels ?? [], request.AutoApprove, KindOf(request.Role),
|
||||||
request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, ct);
|
request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, request.CreatedBy, ct);
|
||||||
|
|
||||||
string baseUrl = (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/');
|
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, Commands(BaseUrl(requestBaseUrl), request.Role, plaintext, plan)));
|
||||||
string shell = $"curl -fsSL {baseUrl}/install.sh | sh -s -- --join {plaintext} --name <node-name>";
|
|
||||||
// PowerShell 6+(pwsh):在 cmd 或 PowerShell 里都能直接粘贴
|
|
||||||
string powershell = $"pwsh -NoProfile -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm {baseUrl}/install.ps1))) -Join {plaintext} -Name <node-name>\"";
|
|
||||||
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, new InstallCommands(shell, shell, powershell)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 一行安装命令:先下载静态脚本、核对 SHA-256,一致才执行;面板地址、角色、一次性码、信任根指纹和属主都走环境变量。
|
||||||
|
/// 里面没有任何私钥,码泄露最多让别人注册出一个节点。
|
||||||
|
/// </summary>
|
||||||
|
public static InstallCommands Commands(string baseUrl, string role, string code, InstallPlan plan)
|
||||||
|
{
|
||||||
|
string pin = plan.Genesis is null ? "" : $" MOSHIRO_GENESIS='{plan.Genesis}' MOSHIRO_CONTROLLERS='{string.Join(',', plan.Controllers)}'";
|
||||||
|
string env = $"MOSHIRO_SERVER='{baseUrl}' MOSHIRO_ROLE='{role}' MOSHIRO_CODE='{code}'{pin}";
|
||||||
|
string psPin = plan.Genesis is null ? "" : $"$env:MOSHIRO_GENESIS='{plan.Genesis}'; $env:MOSHIRO_CONTROLLERS='{string.Join(',', plan.Controllers)}'; ";
|
||||||
|
return new InstallCommands(
|
||||||
|
$"f=$(mktemp) && curl -fsSL '{baseUrl}/install' -o \"$f\" && echo \"{plan.ScriptShSha256} $f\" | sha256sum -c - >/dev/null && {env} sh \"$f\"",
|
||||||
|
$"f=$(mktemp) && curl -fsSL '{baseUrl}/install' -o \"$f\" && echo \"{plan.ScriptShSha256} $f\" | shasum -a 256 -c - >/dev/null && {env} sh \"$f\"",
|
||||||
|
$"$f = Join-Path $env:TEMP 'moshiro-install.ps1'; irm '{baseUrl}/install.ps1' -OutFile $f; if ((Get-FileHash $f -Algorithm SHA256).Hash -ne '{plan.ScriptPs1Sha256}') {{ throw 'the install script does not match its published hash' }}; "
|
||||||
|
+ $"$env:MOSHIRO_SERVER='{baseUrl}'; $env:MOSHIRO_ROLE='{role}'; $env:MOSHIRO_CODE='{code}'; {psPin}iex (Get-Content -Raw $f)");
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BaseUrl(string? requestBaseUrl) => (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/');
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 安装脚本要填的值:由代码找到令牌(不消耗),角色、网关、镜像都在这里决定,Agent 和 Probe 共用同一份脚本。
|
||||||
|
/// 代码不存在、已吊销、已过期或用完了返回 null。网关和镜像要是含有会破坏脚本引号的字符也返回 null。
|
||||||
|
/// </summary>
|
||||||
|
public async ValueTask<InstallTarget?> ResolveInstallAsync(string code, string? requestBaseUrl, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (code.Length is 0 or > 200)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
JoinTokenInfo? token = await _store.FindAsync(TokenHash.Sha256(code), ct);
|
||||||
|
if (token is null || token.RevokedAtMs is not null || token.ExpiresAtMs <= _time.GetUtcNow().ToUnixTimeMilliseconds() || token.Uses >= token.MaxUses)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string role = RoleOf(token.Kind);
|
||||||
|
string registry = (_options.NodeImageRegistry ?? "").Trim('/');
|
||||||
|
string ns = _options.ImageNamespace.Trim('/');
|
||||||
|
string image = $"{(registry.Length > 0 ? registry + "/" : "")}{(ns.Length > 0 ? ns : "moshiro")}/moshiro-{role}:latest";
|
||||||
|
string server = BaseUrl(requestBaseUrl);
|
||||||
|
return IsSafe(server) && IsSafe(image) && IsSafe(code) ? new InstallTarget(role, server, code, image) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>值会被放进脚本的单引号里:只允许 URL 和镜像名里正常出现的字符。</summary>
|
||||||
|
private static bool IsSafe(string value) => value.All(c => char.IsAsciiLetterOrDigit(c) || c is '.' or '-' or '_' or ':' or '/' or '[' or ']' or '~' or '%');
|
||||||
|
|
||||||
public ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct) => _store.ListAsync(ct);
|
public ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct) => _store.ListAsync(ct);
|
||||||
|
|
||||||
public ValueTask<bool> RevokeAsync(string id, CancellationToken ct) => _store.RevokeAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
|
public ValueTask<bool> RevokeAsync(string id, CancellationToken ct) => _store.RevokeAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using MoShiro.Protocol;
|
||||||
|
|
||||||
|
namespace MoShiro.Backend.Application;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 自动识别 Agent 的对外地址:Agent 上报本机的公网地址,注册请求的来源地址作为补充(来源是私网或隧道出口时不用)。
|
||||||
|
/// 管理员手填的(<c>*_manual</c>)永远优先;已经选中的自动地址还在候选里就不换,不会因为网卡顺序变化来回改。
|
||||||
|
/// </summary>
|
||||||
|
public static class AgentAddressPolicy
|
||||||
|
{
|
||||||
|
/// <summary>返回要写入的 <c>(public_host, ipv6_host)</c>;没有候选的那一项是 null(保持不变)。</summary>
|
||||||
|
public static (string? PublicHost, string? Ipv6Host) Choose(AgentRecord current, IReadOnlyList<string> reported, string? observed)
|
||||||
|
{
|
||||||
|
var v4 = new List<string>();
|
||||||
|
var v6 = new List<string>();
|
||||||
|
foreach (string text in reported.Append(observed ?? ""))
|
||||||
|
{
|
||||||
|
if (!IPAddress.TryParse(text, out IPAddress? address))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (address.AddressFamily == AddressFamily.InterNetwork && PublicAddress.IsPublicIpv4(address) && !v4.Contains(address.ToString()))
|
||||||
|
{
|
||||||
|
v4.Add(address.ToString());
|
||||||
|
}
|
||||||
|
else if (address.AddressFamily == AddressFamily.InterNetworkV6 && PublicAddress.IsGlobalIpv6(address) && !v6.Contains(address.ToString()))
|
||||||
|
{
|
||||||
|
v6.Add(address.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (current.PublicHostManual ? null : Pick(v4, current.PublicHost), current.Ipv6HostManual ? null : Pick(v6, current.Ipv6Host));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Pick(List<string> candidates, string current) =>
|
||||||
|
candidates.Count == 0 ? null : candidates.Contains(current) ? current : candidates[0];
|
||||||
|
}
|
||||||
@@ -24,9 +24,11 @@ public sealed partial class AgentService
|
|||||||
|
|
||||||
private readonly NodeInfoService _nodes;
|
private readonly NodeInfoService _nodes;
|
||||||
private readonly LinkService _links;
|
private readonly LinkService _links;
|
||||||
|
private readonly TrustService _trust;
|
||||||
|
|
||||||
public AgentService(IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links)
|
public AgentService(IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links, TrustService trust)
|
||||||
{
|
{
|
||||||
|
_trust = trust;
|
||||||
_links = links;
|
_links = links;
|
||||||
_nodes = nodes;
|
_nodes = nodes;
|
||||||
_agents = agents;
|
_agents = agents;
|
||||||
@@ -69,12 +71,12 @@ public sealed partial class AgentService
|
|||||||
errors["name"] = "must be 1-64 characters: letters (any language), digits, '.', '_' or '-'";
|
errors["name"] = "must be 1-64 characters: letters (any language), digits, '.', '_' or '-'";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (patch.PublicHost is not null && !ValidHost(patch.PublicHost, IpVersions.V4))
|
if (patch.PublicHost is not null && patch.PublicHostAuto != true && !ValidHost(patch.PublicHost, IpVersions.V4))
|
||||||
{
|
{
|
||||||
errors["public_host"] = "must be a host name or IP address";
|
errors["public_host"] = "must be a host name or IP address";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (patch.Ipv6Host is not null && patch.Ipv6Host.Length > 0 && !ValidHost(patch.Ipv6Host, IpVersions.V6))
|
if (patch.Ipv6Host is not null && patch.Ipv6HostAuto != true && patch.Ipv6Host.Length > 0 && !ValidHost(patch.Ipv6Host, IpVersions.V6))
|
||||||
{
|
{
|
||||||
errors["ipv6_host"] = "must be an IPv6 address or host name; empty disables IPv6 collection";
|
errors["ipv6_host"] = "must be an IPv6 address or host name; empty disables IPv6 collection";
|
||||||
}
|
}
|
||||||
@@ -95,7 +97,15 @@ public sealed partial class AgentService
|
|||||||
return Result<AgentView>.Fail(error);
|
return Result<AgentView>.Fail(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (patch.Name is not null || patch.PublicHost is not null || patch.Ipv6Host is not null)
|
if (patch.PublicHostAuto == true || patch.Ipv6HostAuto == true)
|
||||||
|
{
|
||||||
|
// 撤销手填:马上按已经上报的地址重新识别
|
||||||
|
(string? host, string? v6) = AgentAddressPolicy.Choose(agent, agent.ReportedAddresses ?? [], null);
|
||||||
|
await _agents.ApplyAutoAddressesAsync(id, host, v6, ct);
|
||||||
|
agent = await _agents.GetAsync(id, ct) ?? agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.Name is not null || patch.PublicHost is not null || patch.Ipv6Host is not null || patch.PublicHostAuto == true || patch.Ipv6HostAuto == true)
|
||||||
{
|
{
|
||||||
await AfterAgentSetChangedAsync(ct);
|
await AfterAgentSetChangedAsync(ct);
|
||||||
}
|
}
|
||||||
@@ -149,7 +159,8 @@ public sealed partial class AgentService
|
|||||||
IReadOnlyList<AgentRecord> agents = await _agents.ListAsync(ct);
|
IReadOnlyList<AgentRecord> agents = await _agents.ListAsync(ct);
|
||||||
long version = long.TryParse(await _settings.GetAsync(TaskPlanner.ProbeAgentsVersionKey, ct), out long v) ? v : 0;
|
long version = long.TryParse(await _settings.GetAsync(TaskPlanner.ProbeAgentsVersionKey, ct), out long v) ? v : 0;
|
||||||
var result = new ProbeAgentList { Version = version };
|
var result = new ProbeAgentList { Version = version };
|
||||||
foreach (AgentRecord agent in agents.Where(a => a.Status == NodeStatus.Active && a.PublicHost.Length > 0 && links.IsLinked(probeId, a.Id.Value)))
|
// 没有上报证书公钥的 Agent(旧版本)还不能被 Probe 连接:Probe 需要它来固定证书和派生敲门密钥
|
||||||
|
foreach (AgentRecord agent in agents.Where(a => a.Status == NodeStatus.Active && a.PublicHost.Length > 0 && a.TlsPublicKey is { Length: > 0 } && links.IsLinked(probeId, a.Id.Value)))
|
||||||
{
|
{
|
||||||
result.Agents.Add(new AgentEndpoint
|
result.Agents.Add(new AgentEndpoint
|
||||||
{
|
{
|
||||||
@@ -159,10 +170,13 @@ public sealed partial class AgentService
|
|||||||
Ipv6Host = agent.Ipv6Supported && agent.Ipv6Host.Length > 0 ? agent.Ipv6Host : null,
|
Ipv6Host = agent.Ipv6Supported && agent.Ipv6Host.Length > 0 ? agent.Ipv6Host : null,
|
||||||
TcpPort = agent.ProbeTcpPort,
|
TcpPort = agent.ProbeTcpPort,
|
||||||
UdpPorts = [.. agent.ProbeUdpPorts],
|
UdpPorts = [.. agent.ProbeUdpPorts],
|
||||||
Spki = Convert.ToBase64String(agent.TlsSpki),
|
PublicKey = Convert.ToBase64String(agent.TlsPublicKey!),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IReadOnlyList<Fleet.Core.SignedStatementDto> chain = await _trust.ChainAsync(ct);
|
||||||
|
result.TrustChain = chain.Count == 0 ? null : [.. chain];
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ public sealed class MoShiroEnrollmentHook : IEnrollmentHook
|
|||||||
|
|
||||||
public async ValueTask OnEnrolledAsync(NodeRecord node, EnrollmentContext context, CancellationToken ct)
|
public async ValueTask OnEnrolledAsync(NodeRecord node, EnrollmentContext context, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
if (node.Id.Value.StartsWith(ProbeId.Prefix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
// Probe:新的对等面公钥要进每个 Agent 的名单,Probe 自己也要拿到 Agent 列表
|
||||||
|
await _service.AfterAgentSetChangedAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
AgentEnrollMeta meta = new();
|
AgentEnrollMeta meta = new();
|
||||||
if (context.ProductMeta is { } raw)
|
if (context.ProductMeta is { } raw)
|
||||||
{
|
{
|
||||||
@@ -33,9 +40,16 @@ public sealed class MoShiroEnrollmentHook : IEnrollmentHook
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// public_host 未提供时,取注册请求的来源 IP
|
// 注册请求带了 public_host 就用它;否则来源地址是公网地址时用来源地址(私网或隧道出口不用,等 hello 里的本机地址)
|
||||||
string host = !string.IsNullOrWhiteSpace(meta.PublicHost) ? meta.PublicHost : context.RemoteIp ?? "";
|
string host = !string.IsNullOrWhiteSpace(meta.PublicHost) ? meta.PublicHost
|
||||||
|
: System.Net.IPAddress.TryParse(context.RemoteIp, out System.Net.IPAddress? remote) && PublicAddress.IsPublicIpv4(remote) ? remote.ToString() : "";
|
||||||
await _agents.ApplyEnrollmentAsync(node.Id, new AgentEnrollment(host, meta.ProbeTcpPort, meta.ProbeUdpPorts, meta.Kinds), ct);
|
await _agents.ApplyEnrollmentAsync(node.Id, new AgentEnrollment(host, meta.ProbeTcpPort, meta.ProbeUdpPorts, meta.Kinds), ct);
|
||||||
|
if (await _agents.GetAsync(node.Id, ct) is { } enrolled)
|
||||||
|
{
|
||||||
|
(_, string? observedV6) = AgentAddressPolicy.Choose(enrolled, [], context.RemoteIp);
|
||||||
|
await _agents.ApplyAutoAddressesAsync(node.Id, null, observedV6, ct);
|
||||||
|
}
|
||||||
|
|
||||||
if (node.Status == NodeStatus.Active)
|
if (node.Status == NodeStatus.Active)
|
||||||
{
|
{
|
||||||
await _service.AfterAgentSetChangedAsync(ct);
|
await _service.AfterAgentSetChangedAsync(ct);
|
||||||
@@ -195,12 +209,19 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Agent 上报 TLS 证书的公钥:指纹由面板自己算,不信节点报的指纹
|
||||||
|
byte[]? tlsKey = null;
|
||||||
byte[]? spki = null;
|
byte[]? spki = null;
|
||||||
if (meta.TlsSpki is not null)
|
if (meta.TlsPublicKey is not null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
spki = Convert.FromBase64String(meta.TlsSpki);
|
byte[] candidate = Convert.FromBase64String(meta.TlsPublicKey);
|
||||||
|
if (Fleet.Identity.NodeIdentity.IsValidPublicKey(candidate))
|
||||||
|
{
|
||||||
|
tlsKey = candidate;
|
||||||
|
spki = Fleet.Identity.NodeIdentity.Fingerprint(candidate);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (FormatException)
|
catch (FormatException)
|
||||||
{
|
{
|
||||||
@@ -211,15 +232,22 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
|
|||||||
AgentRecord? before = await _agents.GetAsync(id, ct);
|
AgentRecord? before = await _agents.GetAsync(id, ct);
|
||||||
string? limitsJson = meta.LocalLimits is null ? null : JsonSerializer.Serialize(meta.LocalLimits, LocalLimitsJson.Default.LocalLimits);
|
string? limitsJson = meta.LocalLimits is null ? null : JsonSerializer.Serialize(meta.LocalLimits, LocalLimitsJson.Default.LocalLimits);
|
||||||
string? ipv6Json = meta.Ipv6 is null ? null : JsonSerializer.Serialize(meta.Ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic);
|
string? ipv6Json = meta.Ipv6 is null ? null : JsonSerializer.Serialize(meta.Ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic);
|
||||||
await _agents.ApplyHelloAsync(id, new AgentHello(kinds, hello.NodeVersion, limitsJson, spki is { Length: 32 } ? spki : null, meta.ProbeTcpPort, meta.ProbeUdpPorts, meta.Ipv6Supported, ipv6Json), ct);
|
await _agents.ApplyHelloAsync(id, new AgentHello(kinds, hello.NodeVersion, limitsJson, spki is { Length: 32 } ? spki : null, meta.ProbeTcpPort, meta.ProbeUdpPorts, meta.Ipv6Supported, ipv6Json, meta.Addresses, tlsKey), ct);
|
||||||
|
|
||||||
// 端口或证书指纹变了,Probe 需要新的列表
|
// 自动识别对外地址:Agent 上报的公网地址;手填的不动
|
||||||
|
if (meta.Addresses is not null && await _agents.GetAsync(id, ct) is { } reported)
|
||||||
|
{
|
||||||
|
(string? publicHost, string? ipv6Host) = AgentAddressPolicy.Choose(reported, meta.Addresses, null);
|
||||||
|
await _agents.ApplyAutoAddressesAsync(id, publicHost, ipv6Host, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 端口、证书指纹或对外地址变了,Probe 需要新的列表
|
||||||
AgentRecord? after = await _agents.GetAsync(id, ct);
|
AgentRecord? after = await _agents.GetAsync(id, ct);
|
||||||
_nodes.ObserveAgentInBackground(id.Value, after?.PublicHost, meta.Platform);
|
_nodes.ObserveAgentInBackground(id.Value, after?.PublicHost, meta.Platform);
|
||||||
await _releases.OfferToNodeAsync(id, ct);
|
await _releases.OfferToNodeAsync(id, ct);
|
||||||
await _schedule.SendToNodeAsync(id, ct);
|
await _schedule.SendToNodeAsync(id, ct);
|
||||||
if (before is not null && after is not null
|
if (before is not null && after is not null
|
||||||
&& (!before.TlsSpki.AsSpan().SequenceEqual(after.TlsSpki) || before.ProbeTcpPort != after.ProbeTcpPort || !before.ProbeUdpPorts.SequenceEqual(after.ProbeUdpPorts) || before.Ipv6Supported != after.Ipv6Supported)
|
&& (!before.TlsSpki.AsSpan().SequenceEqual(after.TlsSpki) || !(before.TlsPublicKey ?? []).AsSpan().SequenceEqual(after.TlsPublicKey ?? []) || before.ProbeTcpPort != after.ProbeTcpPort || !before.ProbeUdpPorts.SequenceEqual(after.ProbeUdpPorts) || before.Ipv6Supported != after.Ipv6Supported || before.PublicHost != after.PublicHost || before.Ipv6Host != after.Ipv6Host)
|
||||||
&& after.Status == NodeStatus.Active)
|
&& after.Status == NodeStatus.Active)
|
||||||
{
|
{
|
||||||
await _service.AfterAgentSetChangedAsync(ct);
|
await _service.AfterAgentSetChangedAsync(ct);
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using Fleet.Core;
|
using Fleet.Core;
|
||||||
using Fleet.Identity;
|
|
||||||
using Fleet.Server;
|
using Fleet.Server;
|
||||||
using MoShiro.Protocol;
|
using MoShiro.Protocol;
|
||||||
|
|
||||||
namespace MoShiro.Backend.Application;
|
namespace MoShiro.Backend.Application;
|
||||||
|
|
||||||
/// <summary>本地硬上限:写在 Probe 本机的配置里,面板无法修改(在路由器上建议调低)。</summary>
|
|
||||||
public sealed record ProbeLimits(double MaxTestMbps, long MaxTotalPps);
|
|
||||||
|
|
||||||
/// <summary><c>probe.json</c>:只在生成(或轮换密钥)时返回一次,含私钥;<c>Server</c> 是 Probe 要连的面板地址。</summary>
|
|
||||||
public sealed record ProbeIdentityFile(ProbeId ProbeId, string PrivateKey, string Server, ProbeLimits Limits);
|
|
||||||
|
|
||||||
/// <summary>某个 Probe 现在连着的一个 Agent(Agent 上报)。</summary>
|
/// <summary>某个 Probe 现在连着的一个 Agent(Agent 上报)。</summary>
|
||||||
public sealed record ProbeConnection(NodeId AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, long SinceMs, string IpVersion = IpVersions.V4);
|
public sealed record ProbeConnection(NodeId AgentId, string AgentName, bool AgentOnline, string? Version, string? Remote, long SinceMs, string IpVersion = IpVersions.V4);
|
||||||
|
|
||||||
@@ -21,8 +14,9 @@ public sealed record ProbeView(ProbeRecord Probe, bool Online, IReadOnlyList<Pro
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Probe 身份(Backend 生成,私钥只返回一次):可以有多个,每个都能改名、轮换密钥、吊销、删除。
|
/// Probe:用加入令牌注册(密钥由 Probe 自己生成,Backend 从不接触私钥),可以有多个,每个都能改名、吊销、删除。
|
||||||
/// 所有未吊销的 Probe 的公钥都会写进每个 Agent 的期望状态(<c>peers</c>);吊销、删除、轮换密钥之后,Agent 立刻断开对应会话,Probe 到面板的连接也会被拒绝。
|
/// 所有未吊销的 Probe 的对等面公钥都会写进每个 Agent 的期望状态(<c>peers</c>);吊销、删除之后,Agent 立刻断开对应会话,Probe 到面板的连接也被断开。
|
||||||
|
/// 要换密钥就吊销后重新注册(Probe 的 id 会变)。
|
||||||
/// 一条线路是一对(Probe,Agent)。
|
/// 一条线路是一对(Probe,Agent)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed partial class ProbeService
|
public sealed partial class ProbeService
|
||||||
@@ -46,26 +40,6 @@ public sealed partial class ProbeService
|
|||||||
_time = time;
|
_time = time;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask<Result<ProbeIdentityFile>> CreateAsync(string? name, string server, CancellationToken ct)
|
|
||||||
{
|
|
||||||
string finalName = string.IsNullOrWhiteSpace(name) ? "probe" : name.Trim();
|
|
||||||
if (!NamePattern().IsMatch(finalName))
|
|
||||||
{
|
|
||||||
return ValidationError.Fail<ProbeIdentityFile>("name", "must be 1-64 characters: letters (any language), digits, '.', '_' or '-'");
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((await _probes.ListAsync(ct)).Any(p => string.Equals(p.Name, finalName, StringComparison.OrdinalIgnoreCase)))
|
|
||||||
{
|
|
||||||
return Result<ProbeIdentityFile>.Fail(MoShiroErrors.NameConflict, $"a probe named {finalName} already exists");
|
|
||||||
}
|
|
||||||
|
|
||||||
using NodeIdentity identity = NodeIdentity.Generate();
|
|
||||||
var probe = new ProbeRecord(Ulid.NewPrefixed(ProbeId.Prefix, _time), finalName, identity.PublicKeySpki, _time.GetUtcNow().ToUnixTimeMilliseconds(), null);
|
|
||||||
await _probes.SaveAsync(probe, ct);
|
|
||||||
_gateway.NotifyAllTaskSetsChanged();
|
|
||||||
return Result<ProbeIdentityFile>.Ok(BuildFile(probe, identity, server));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<IReadOnlyList<ProbeView>> ListAsync(CancellationToken ct)
|
public async ValueTask<IReadOnlyList<ProbeView>> ListAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
IReadOnlyList<ProbeRecord> probes = await _probes.ListAsync(ct);
|
IReadOnlyList<ProbeRecord> probes = await _probes.ListAsync(ct);
|
||||||
@@ -98,27 +72,6 @@ public sealed partial class ProbeService
|
|||||||
return Result<ProbeView>.Ok((await GetAsync(id, ct))!);
|
return Result<ProbeView>.Ok((await GetAsync(id, ct))!);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>换一把新密钥(id 不变):旧私钥立即失效,所有 Agent 上这个 Probe 的会话被断开。返回新的 probe.json(只此一次)。</summary>
|
|
||||||
public async ValueTask<Result<ProbeIdentityFile>> RotateAsync(string id, string server, CancellationToken ct)
|
|
||||||
{
|
|
||||||
if (await _probes.GetAsync(id, ct) is not { } probe)
|
|
||||||
{
|
|
||||||
return Result<ProbeIdentityFile>.Fail(MoShiroErrors.ProbeNotFound, "probe not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (probe.RevokedAtMs is not null)
|
|
||||||
{
|
|
||||||
return Result<ProbeIdentityFile>.Fail(MoShiroErrors.ProbeRevoked, "a revoked probe cannot get a new key; create a new probe instead");
|
|
||||||
}
|
|
||||||
|
|
||||||
using NodeIdentity identity = NodeIdentity.Generate();
|
|
||||||
ProbeRecord updated = probe with { PublicKey = identity.PublicKeySpki };
|
|
||||||
await _probes.SaveAsync(updated, ct);
|
|
||||||
_gateway.NotifyAllTaskSetsChanged();
|
|
||||||
await DisconnectEverywhereAsync(id, ct);
|
|
||||||
return Result<ProbeIdentityFile>.Ok(BuildFile(updated, identity, server));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>吊销:保留记录和历史数据,公钥从所有 Agent 的名单里移除,Agent 立即断开它的会话。</summary>
|
/// <summary>吊销:保留记录和历史数据,公钥从所有 Agent 的名单里移除,Agent 立即断开它的会话。</summary>
|
||||||
public async ValueTask<Result<ProbeView>> RevokeAsync(string id, CancellationToken ct)
|
public async ValueTask<Result<ProbeView>> RevokeAsync(string id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -164,14 +117,6 @@ public sealed partial class ProbeService
|
|||||||
return await _gateway.DisconnectProbeAsync(agentId, new ProbeId(probeId), ct);
|
return await _gateway.DisconnectProbeAsync(agentId, new ProbeId(probeId), ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ValueTask DisconnectEverywhereAsync(string probeId, CancellationToken ct)
|
|
||||||
{
|
|
||||||
foreach ((NodeId agent, _) in _runtime.ConnectionsOf(probeId))
|
|
||||||
{
|
|
||||||
await _gateway.DisconnectProbeAsync(agent, new ProbeId(probeId), ct);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private ProbeView View(ProbeRecord probe, IReadOnlyList<AgentRecord> agents)
|
private ProbeView View(ProbeRecord probe, IReadOnlyList<AgentRecord> agents)
|
||||||
{
|
{
|
||||||
var connections = new List<ProbeConnection>();
|
var connections = new List<ProbeConnection>();
|
||||||
@@ -187,9 +132,6 @@ public sealed partial class ProbeService
|
|||||||
return new ProbeView(probe, _gateway.IsOnline(new NodeId(probe.Id)), [.. connections.OrderBy(c => c.AgentName, StringComparer.Ordinal)]);
|
return new ProbeView(probe, _gateway.IsOnline(new NodeId(probe.Id)), [.. connections.OrderBy(c => c.AgentName, StringComparer.Ordinal)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ProbeIdentityFile BuildFile(ProbeRecord probe, NodeIdentity identity, string server) =>
|
|
||||||
new(new ProbeId(probe.Id), Convert.ToBase64String(identity.ExportPkcs8()), server, new ProbeLimits(MaxTestMbps: 100, MaxTotalPps: 20_000));
|
|
||||||
|
|
||||||
[GeneratedRegex(@"^[\p{L}\p{M}\p{N}._-]{1,64}$")]
|
[GeneratedRegex(@"^[\p{L}\p{M}\p{N}._-]{1,64}$")]
|
||||||
private static partial Regex NamePattern();
|
private static partial Regex NamePattern();
|
||||||
}
|
}
|
||||||
@@ -35,6 +35,13 @@ public static class ApplicationServiceCollectionExtensions
|
|||||||
services.AddSingleton<IngestService>();
|
services.AddSingleton<IngestService>();
|
||||||
services.AddSingleton<WindowComputer>();
|
services.AddSingleton<WindowComputer>();
|
||||||
services.AddSingleton<AuthService>();
|
services.AddSingleton<AuthService>();
|
||||||
|
services.AddSingleton<PasskeyService>();
|
||||||
|
services.AddSingleton<AuditService>();
|
||||||
|
services.AddSingleton<ServerIdentityService>();
|
||||||
|
services.AddSingleton<Fleet.Server.IServerIdentity>(sp => sp.GetRequiredService<ServerIdentityService>());
|
||||||
|
services.AddSingleton<TrustService>();
|
||||||
|
services.AddSingleton<Fleet.Server.IOperatorTrustSource>(sp => sp.GetRequiredService<TrustService>());
|
||||||
|
services.AddSingleton<ElevationService>();
|
||||||
services.AddSingleton<SettingsService>();
|
services.AddSingleton<SettingsService>();
|
||||||
services.AddSingleton<ReleaseService>();
|
services.AddSingleton<ReleaseService>();
|
||||||
services.AddSingleton<ScheduleService>();
|
services.AddSingleton<ScheduleService>();
|
||||||
|
|||||||
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user